From 2a6b19f0d5293bb0ecbcc53ff86100bfe07c8431 Mon Sep 17 00:00:00 2001 From: Corrin Lakeland Date: Mon, 6 Jul 2026 22:03:01 +1200 Subject: [PATCH 01/73] KAN-278 chore: drop replaces lists from squashed baselines All instances (prod/UAT/demo) are past the squash and have the baseline rows recorded in their ledgers, so the replaces lists are dead weight (the follow-up noted in PR #434). Removing them now makes the upcoming client->company app-label surgery unambiguous: no stale ("client", ...) entries for the migration loader to resolve. Co-Authored-By: Claude Fable 5 --- apps/accounting/migrations/0001_baseline.py | 10 - apps/accounting/migrations/0002_baseline.py | 4 - apps/accounts/migrations/0001_baseline.py | 18 -- apps/accounts/migrations/0002_baseline.py | 4 - .../0003_seed_system_automation_user.py | 3 - apps/client/migrations/0001_baseline.py | 26 -- apps/crm/migrations/0001_baseline.py | 10 - .../0002_seed_phone_call_schedules.py | 3 - apps/job/migrations/0001_baseline.py | 109 -------- .../migrations/0002_seed_labour_subtypes.py | 5 - apps/operations/migrations/0001_baseline.py | 5 - apps/process/migrations/0001_baseline.py | 6 - apps/purchasing/migrations/0001_baseline.py | 36 --- apps/quoting/migrations/0001_baseline.py | 23 -- apps/workflow/migrations/0001_baseline.py | 240 ------------------ .../migrations/0002_seed_xero_pay_items.py | 3 - .../0003_seed_celery_beat_schedules.py | 5 - 17 files changed, 510 deletions(-) diff --git a/apps/accounting/migrations/0001_baseline.py b/apps/accounting/migrations/0001_baseline.py index 2789339bc..e08f412b8 100644 --- a/apps/accounting/migrations/0001_baseline.py +++ b/apps/accounting/migrations/0001_baseline.py @@ -12,16 +12,6 @@ class Migration(migrations.Migration): initial = True - replaces = [ - ("accounting", "0001_initial"), - ("accounting", "0002_initial"), - ("accounting", "0003_alter_invoice_job"), - ("accounting", "0004_protect_critical_fks"), - ("accounting", "0005_quote_number"), - ("accounting", "0006_alter_bill_table_alter_billlineitem_table_and_more"), - ("accounting", "0007_partial_invoice_billing_metadata"), - ] - dependencies = [ ("client", "0001_baseline"), ] diff --git a/apps/accounting/migrations/0002_baseline.py b/apps/accounting/migrations/0002_baseline.py index 0c37215ac..c459575d3 100644 --- a/apps/accounting/migrations/0002_baseline.py +++ b/apps/accounting/migrations/0002_baseline.py @@ -8,10 +8,6 @@ class Migration(migrations.Migration): initial = True - replaces = [ - ("accounting", "0008_normalize_xero_raw_json_strings"), - ] - dependencies = [ ("accounting", "0001_baseline"), ("client", "0001_baseline"), diff --git a/apps/accounts/migrations/0001_baseline.py b/apps/accounts/migrations/0001_baseline.py index e4859523e..c2ac1e64e 100644 --- a/apps/accounts/migrations/0001_baseline.py +++ b/apps/accounts/migrations/0001_baseline.py @@ -11,24 +11,6 @@ class Migration(migrations.Migration): initial = True - replaces = [ - ("accounts", "0001_initial"), - ("accounts", "0002_initial"), - ("accounts", "0003_alter_historicalstaff_updated_at_and_more"), - ("accounts", "0004_add_staff_permissions_tables"), - ("accounts", "0005_alter_staff_groups_alter_staff_user_permissions"), - ("accounts", "0006_historicalstaff_date_left_staff_date_left"), - ("accounts", "0007_auto_20250730_2359"), - ("accounts", "0008_remove_historicalstaff_is_active_and_more"), - ("accounts", "0009_historicalstaff_xero_user_id_staff_xero_user_id"), - ("accounts", "0010_rename_is_staff_historicalstaff_is_office_staff_and_more"), - ("accounts", "0011_remove_ims_payroll_fields"), - ("accounts", "0012_add_base_wage_rate"), - ("accounts", "0013_alter_historicalstaff_table_alter_staff_table"), - ("accounts", "0014_add_is_workshop_staff"), - ("accounts", "0016_historicalstaff_default_labour_subtype_and_more"), - ] - dependencies = [] operations = [ diff --git a/apps/accounts/migrations/0002_baseline.py b/apps/accounts/migrations/0002_baseline.py index 6faa3e2a1..97ef6434f 100644 --- a/apps/accounts/migrations/0002_baseline.py +++ b/apps/accounts/migrations/0002_baseline.py @@ -9,10 +9,6 @@ class Migration(migrations.Migration): initial = True - replaces = [ - ("accounts", "0017_backfill_staff_default_labour_subtype"), - ] - dependencies = [ ("accounts", "0001_baseline"), ("auth", "0012_alter_user_first_name_max_length"), diff --git a/apps/accounts/migrations/0003_seed_system_automation_user.py b/apps/accounts/migrations/0003_seed_system_automation_user.py index b460a50c9..d3b303a41 100644 --- a/apps/accounts/migrations/0003_seed_system_automation_user.py +++ b/apps/accounts/migrations/0003_seed_system_automation_user.py @@ -40,9 +40,6 @@ def delete_system_automation_user( class Migration(migrations.Migration): - replaces = [ - ("accounts", "0015_create_system_automation_user"), - ] dependencies = [ ("accounts", "0002_baseline"), diff --git a/apps/client/migrations/0001_baseline.py b/apps/client/migrations/0001_baseline.py index c6edd8ddf..eff64690a 100644 --- a/apps/client/migrations/0001_baseline.py +++ b/apps/client/migrations/0001_baseline.py @@ -14,32 +14,6 @@ class Migration(migrations.Migration): initial = True - replaces = [ - ("client", "0001_initial"), - ("client", "0002_clientcontact"), - ("client", "0003_add_xero_merge_tracking"), - ("client", "0004_populate_merge_fields"), - ("client", "0005_client_is_supplier"), - ("client", "0006_alter_client_name"), - ("client", "0007_delete_empty_name_contacts"), - ("client", "0008_merge_duplicate_contacts"), - ("client", "0009_clientcontact_unique_client_contact_name"), - ("client", "0010_add_is_active_to_clientcontact"), - ("client", "0011_convert_empty_strings_to_null"), - ("client", "0012_supplierpickupaddress"), - ("client", "0013_add_google_fields_to_pickup_address"), - ("client", "0014_add_suburb_to_pickup_address"), - ("client", "0015_populate_xero_addresses"), - ("client", "0016_alter_client_table_alter_clientcontact_table_and_more"), - ("client", "0017_reassign_stranded_merged_client_fks"), - ("client", "0018_client_allow_jobs"), - ("client", "0019_client_name_fts_index"), - ("client", "0020_suppliersearchalias_and_more"), - ("client", "0021_clientcontactmethod"), - ("client", "0022_client_name_trgm_index"), - ("client", "0023_drop_scalar_phone_fields"), - ] - dependencies = [] operations = [ diff --git a/apps/crm/migrations/0001_baseline.py b/apps/crm/migrations/0001_baseline.py index b044779fc..99570b94b 100644 --- a/apps/crm/migrations/0001_baseline.py +++ b/apps/crm/migrations/0001_baseline.py @@ -12,16 +12,6 @@ class Migration(migrations.Migration): initial = True - replaces = [ - ("crm", "0001_initial"), - ("crm", "0003_phonecallrecording_archive_error"), - ("crm", "0004_phonecallrecord_job_link"), - ("crm", "0006_phone_endpoints_and_classification"), - ("crm", "0007_alter_phoneprovidersettings_password_and_more"), - ("crm", "0008_phone_call_number_indexes"), - ("crm", "0009_phone_call_normalized_numbers"), - ] - dependencies = [ ("client", "0001_baseline"), ("job", "0001_baseline"), diff --git a/apps/crm/migrations/0002_seed_phone_call_schedules.py b/apps/crm/migrations/0002_seed_phone_call_schedules.py index 08e3b5b03..a4d63c3ea 100644 --- a/apps/crm/migrations/0002_seed_phone_call_schedules.py +++ b/apps/crm/migrations/0002_seed_phone_call_schedules.py @@ -74,9 +74,6 @@ def remove_phone_call_schedules( class Migration(migrations.Migration): - replaces = [ - ("crm", "0005_phone_call_sync_near_realtime"), - ] dependencies = [ ("crm", "0001_baseline"), diff --git a/apps/job/migrations/0001_baseline.py b/apps/job/migrations/0001_baseline.py index b61ecbd07..37a56db51 100644 --- a/apps/job/migrations/0001_baseline.py +++ b/apps/job/migrations/0001_baseline.py @@ -15,115 +15,6 @@ class Migration(migrations.Migration): initial = True - replaces = [ - ("job", "0001_initial"), - ("job", "0002_alter_historicaljob_pricing_type_and_more"), - ("job", "0003_historicaljob_contact_email_job_contact_email"), - ("job", "0004_alter_historicaljob_contact_phone_and_more"), - ("job", "0005_remove_materialentry_source_stock_and_more"), - ("job", "0006_add_material_adjustment_jobpart_models"), - ("job", "0007_add_materialentry_add_adjustmententry"), - ("job", "0007_fix_jobpart_state"), - ("job", "0008_alter_jobpart_options_and_more"), - ("job", "0009_historicaljob_priority_job_priority_and_more"), - ("job", "0010_populate_priority_for_existing_jobs"), - ("job", "0011_rename_revenue_adjustment_adjustmententry_price_adjustment_and_more"), - ("job", "0011_update_purchase_order_line_reference"), - ("job", "0012_alter_jobevent_job"), - ("job", "0012_merge_20250604_2123"), - ("job", "0013_merge_20250605_0811"), - ("job", "0014_alter_jobpart_table"), - ("job", "0015_alter_historicaljob_client_alter_job_client_and_more"), - ("job", "0016_add_contact_foreignkey"), - ("job", "0017_migrate_contact_data"), - ("job", "0018_add_accounting_date_fields"), - ("job", "0018_costing_initial"), - ("job", "0019_job_latest_sets"), - ("job", "0019_populate_accounting_dates"), - ("job", "0020_change_priority_to_float"), - ("job", "0021_add_is_quote_adjustment_field"), - ("job", "0022_merge_20250615_1511"), - ("job", "0023_add_new_job_statuses"), - ("job", "0024_rename_pricing_jobpricing"), - ("job", "0025_update_job_statuses_for_kanban"), - ("job", "0026_alter_historicaljob_status_alter_job_status"), - ("job", "0027_quotespreadsheet"), - ("job", "0028_add_job_quote_chat_model"), - ("job", "0029_remove_historicaljob_contact_email_and_more"), - ("job", "0030_migrate_pricing_to_costing_and_cleanup"), - ("job", "0031_remove_jobpart_job_pricing_and_more"), - ("job", "0032_fix_blank_job_names"), - ("job", "0033_update_job_statuses_for_simplified_kanban"), - ("job", "0034_remove_legacy_status_choices"), - ("job", "0035_alter_jobevent_id"), - ("job", "0036_complete_uuid_transition"), - ("job", "0037_finalize_uuid_transition"), - ("job", "0038_add_event_deduplication"), - ("job", "0039_alter_job_options_costline_xero_expense_id_and_more"), - ("job", "0040_historicaljob_fully_invoiced_job_fully_invoiced"), - ("job", "0041_populate_fully_invoiced"), - ("job", "0042_add_xero_default_task_id"), - ("job", "0043_alter_costline_xero_last_modified_and_more"), - ("job", "0044_alter_costline_options_costline_created_at_and_more"), - ("job", "0045_create_job_delta_rejection"), - ("job", "0046_add_delta_fields_to_job_event"), - ("job", "0047_add_accounting_date_nullable"), - ("job", "0048_populate_accounting_date"), - ("job", "0049_make_accounting_date_not_null"), - ("job", "0050_backfill_costset_summaries"), - ("job", "0051_fix_costset_summary_default"), - ("job", "0052_alter_costline_desc"), - ("job", "0053_add_speed_quality_tradeoff"), - ("job", "0054_protect_critical_fks"), - ("job", "0055_add_price_cap"), - ("job", "0056_add_safety_document"), - ("job", "0057_safetydocument_google_docs"), - ("job", "0058_safetydocument_document_number_and_more"), - ("job", "0059_costline_approved"), - ("job", "0060_alter_historicaljob_rejected_flag_and_more"), - ("job", "0061_fix_labour_cost_lines"), - ("job", "0062_alter_costline_meta_ext_refs"), - ("job", "0063_job_payroll_category"), - ("job", "0064_job_default_xero_pay_item"), - ("job", "0065_make_default_xero_pay_item_required"), - ("job", "0066_zero_shop_job_revenue"), - ("job", "0067_normalize_wage_rate_multiplier"), - ("job", "0068_add_completed_at"), - ("job", "0069_fix_fully_invoiced"), - ("job", "0070_delete_safetydocument"), - ("job", "0071_add_rdti_type"), - ("job", "0072_rename_job_quote_c_job_id_24cfd1_idx_job_jobquot_job_id_c83a63_idx_and_more"), - ("job", "0073_add_min_max_people"), - ("job", "0074_jobdeltarejection_resolved_and_more"), - ("job", "0075_backfill_jobevent_status_from_historicaljob"), - ("job", "0076_add_jobevent_detail_field"), - ("job", "0077_backfill_jobevent_detail"), - ("job", "0078_backfill_jobevent_staff_from_history"), - ("job", "0079_alter_jobevent_staff_not_null"), - ("job", "0080_backfill_jobevent_status_delta"), - ("job", "0081_backfill_jobevent_creation_delta"), - ("job", "0082_drop_jobevent_description"), - ("job", "0083_enable_pg_trgm"), - ("job", "0084_reconcile_time_xero_pay_items"), - ("job", "0085_backfill_time_rate_multipliers"), - ("job", "0086_repoint_stale_time_pay_items"), - ("job", "0087_costline_staff_entry_seq"), - ("job", "0088_reclassify_stock_consumption_costlines"), - ("job", "0089_backfill_costline_staff_entry_seq"), - ("job", "0090_costline_staff_entry_seq_constraints"), - ("job", "0091_require_latest_costsets_drop_historicaljob"), - ("job", "0092_laboursubtype_joblabourrate"), - ("job", "0094_costline_labour_subtype"), - ("job", "0095_backfill_job_labour_rates_and_costline_subtypes"), - ("job", "0096_remove_job_charge_out_rate"), - ("job", "0097_alter_joblabourrate_options"), - ("job", "0100_reclassify_labour_cost_lines"), - ("job", "0101_reclassify_generic_labour_cost_lines"), - ("job", "0102_rerate_onsite_open_jobs"), - ("job", "0103_add_job_is_urgent"), - ("job", "0104_non_negative_labour_rates"), - ] - dependencies = [ ("client", "0001_baseline"), ("workflow", "0001_baseline"), diff --git a/apps/job/migrations/0002_seed_labour_subtypes.py b/apps/job/migrations/0002_seed_labour_subtypes.py index 10032f954..44a38c132 100644 --- a/apps/job/migrations/0002_seed_labour_subtypes.py +++ b/apps/job/migrations/0002_seed_labour_subtypes.py @@ -50,11 +50,6 @@ def unseed_subtypes(apps: StateApps, schema_editor: BaseDatabaseSchemaEditor) -> class Migration(migrations.Migration): - replaces = [ - ("job", "0093_seed_labour_subtypes"), - ("job", "0098_clean_labour_subtype_catalogue"), - ("job", "0099_add_onsite_quoting_and_reactivate_delivery"), - ] dependencies = [ ("job", "0001_baseline"), diff --git a/apps/operations/migrations/0001_baseline.py b/apps/operations/migrations/0001_baseline.py index f3924ffaf..2c906a9e7 100644 --- a/apps/operations/migrations/0001_baseline.py +++ b/apps/operations/migrations/0001_baseline.py @@ -12,11 +12,6 @@ class Migration(migrations.Migration): initial = True - replaces = [ - ("operations", "0001_initial_operations_models"), - ("operations", "0002_not_reached_in_horizon"), - ] - dependencies = [ ("job", "0001_baseline"), migrations.swappable_dependency(settings.AUTH_USER_MODEL), diff --git a/apps/process/migrations/0001_baseline.py b/apps/process/migrations/0001_baseline.py index b74ac9789..d0133ebc5 100644 --- a/apps/process/migrations/0001_baseline.py +++ b/apps/process/migrations/0001_baseline.py @@ -12,12 +12,6 @@ class Migration(migrations.Migration): initial = True - replaces = [ - ("process", "0001_initial"), - ("process", "0002_formentry_staff_historicalformentry_staff_and_more"), - ("process", "0003_alter_form_table_alter_formentry_table_and_more"), - ] - dependencies = [ ("job", "0001_baseline"), migrations.swappable_dependency(settings.AUTH_USER_MODEL), diff --git a/apps/purchasing/migrations/0001_baseline.py b/apps/purchasing/migrations/0001_baseline.py index f7f7e182e..fa1bd201f 100644 --- a/apps/purchasing/migrations/0001_baseline.py +++ b/apps/purchasing/migrations/0001_baseline.py @@ -14,42 +14,6 @@ class Migration(migrations.Migration): initial = True - replaces = [ - ("purchasing", "0001_move_purchase_models_state"), - ("purchasing", "0002_move_purchase_models_database"), - ("purchasing", "0003_alter_purchaseorder_xero_tenant_id_and_more"), - ("purchasing", "0004_stock"), - ("purchasing", "0005_add_stock_in_database"), - ("purchasing", "0006_add_stock_item_code_xero_id"), - ("purchasing", "0007_stock_unique_xero_id"), - ("purchasing", "0008_stock_parsed_at_stock_parser_confidence_and_more"), - ("purchasing", "0009_purchaseorderline_item_code"), - ("purchasing", "0010_add_raw_json_to_purchaseorder"), - ("purchasing", "0011_stock_xero_inventory_tracked"), - ("purchasing", "0012_add_unit_revenue_to_stock"), - ("purchasing", "0013_populate_unit_revenue_from_existing_data"), - ("purchasing", "0014_remove_retail_rate_field"), - ("purchasing", "0015_stock_active_source_purchase_order_line_id_and_more"), - ("purchasing", "0016_add_product_catalog_source"), - ("purchasing", "0017_remove_stock_notes"), - ("purchasing", "0018_add_xero_line_item_id"), - ("purchasing", "0019_populate_xero_line_item_id"), - ("purchasing", "0020_add_purchaseorderevent"), - ("purchasing", "0021_clean_po_line_descriptions"), - ("purchasing", "0022_add_pickup_address_to_po"), - ("purchasing", "0023_dedupe_stock_item_codes"), - ("purchasing", "0024_stock_item_code_unique"), - ("purchasing", "0025_add_xero_last_synced_to_stock"), - ("purchasing", "0026_remove_xero_last_synced_default"), - ("purchasing", "0027_add_created_by_to_purchase_order"), - ("purchasing", "0028_update_created_by_from_events"), - ("purchasing", "0029_alter_purchaseorder_table_and_more"), - ("purchasing", "0030_add_stock_updated_at"), - ("purchasing", "0031_stock_fts_index"), - ("purchasing", "0032_stock_parser_attempted_at"), - ("purchasing", "0033_use_aluminium_metal_type"), - ] - dependencies = [ ("client", "0001_baseline"), ("job", "0001_baseline"), diff --git a/apps/quoting/migrations/0001_baseline.py b/apps/quoting/migrations/0001_baseline.py index 979dba1d7..3bf913acc 100644 --- a/apps/quoting/migrations/0001_baseline.py +++ b/apps/quoting/migrations/0001_baseline.py @@ -13,29 +13,6 @@ class Migration(migrations.Migration): initial = True - replaces = [ - ("quoting", "0001_initial"), - ("quoting", "0002_supplierpricelist_supplierproduct_price_list"), - ("quoting", "0003_alter_supplierpricelist_supplier_and_more"), - ("quoting", "0004_scrapejob"), - ("quoting", "0005_alter_supplierproduct_unique_together_and_more"), - ("quoting", "0006_alter_supplierproduct_unique_together_and_more"), - ("quoting", "0007_supplierproduct_parsed_alloy_and_more"), - ("quoting", "0008_parse_existing_products"), - ("quoting", "0010_productparsingmapping_item_code_is_in_xero"), - ("quoting", "0011_add_mapping_hash_to_supplierproduct"), - ("quoting", "0012_supplierproduct_last_scraped"), - ("quoting", "0013_productparsingmapping_derived_key"), - ("quoting", "0014_make_parser_fields_nullable"), - ("quoting", "0015_protect_critical_fks"), - ("quoting", "0016_protect_supplier_relationships"), - ("quoting", "0017_add_is_discontinued_to_supplierproduct"), - ("quoting", "0018_mark_404_products_as_discontinued"), - ("quoting", "0019_use_aluminium_metal_type"), - ("quoting", "0020_suppliercredential_supplierscraperconfig_and_more"), - ("quoting", "0021_migrate_steel_and_tube_credentials"), - ] - dependencies = [ ("client", "0001_baseline"), migrations.swappable_dependency(settings.AUTH_USER_MODEL), diff --git a/apps/workflow/migrations/0001_baseline.py b/apps/workflow/migrations/0001_baseline.py index b7036553c..09e54e6ba 100644 --- a/apps/workflow/migrations/0001_baseline.py +++ b/apps/workflow/migrations/0001_baseline.py @@ -13,246 +13,6 @@ class Migration(migrations.Migration): initial = True - replaces = [ - ("workflow", "0001_initial"), - ("workflow", "0003_companydefaults_alter_client_xero_contact_id"), - ("workflow", "0004_companydefaults_company_name"), - ("workflow", "0005_remove_companydefaults_id_and_more"), - ("workflow", "0006_quotepricing"), - ("workflow", "0007_remove_historicaljob_client_name_and_more"), - ("workflow", "0008_remove_jobpricing_job_pricing_number"), - ("workflow", "0009_historicaljob_job_is_valid_job_job_is_valid"), - ("workflow", "0010_remove_historicaljob_job_name_remove_job_job_name"), - ("workflow", "0011_remove_timeentry_minutes_timeentry_description_and_more"), - ("workflow", "0012_alter_timeentry_staff"), - ("workflow", "0013_alter_timeentry_date"), - ("workflow", "0014_historicaljob_quote_acceptance_date_and_more"), - ("workflow", "0015_historicaljob_delivery_date_job_delivery_date"), - ("workflow", "0016_materialentry_item_code"), - ("workflow", "0017_materialentry_comments"), - ("workflow", "0018_rename_cost_adjustmententry_cost_adjustment_and_more"), - ("workflow", "0019_alter_historicaljob_delivery_date_and_more"), - ("workflow", "0020_historicaljob_material_gauge_quantity_and_more"), - ("workflow", "0021_alter_historicaljob_description_and_more"), - ("workflow", "0022_alter_historicaljob_description_and_more"), - ("workflow", "0023_alter_historicaljob_contact_phone_and_more"), - ("workflow", "0024_alter_jobpricing_options_jobpricing_revision_number"), - ("workflow", "0025_historicaljob_latest_estimate_pricing_and_more"), - ("workflow", "0026_alter_job_latest_estimate_pricing_and_more"), - ("workflow", "0027_remove_jobpricing_job"), - ("workflow", "0028_jobpricing_job"), - ("workflow", "0029_alter_jobpricing_job"), - ("workflow", "0030_alter_jobpricing_job"), - ("workflow", "0031_alter_jobpricing_job"), - ("workflow", "0032_alter_job_latest_estimate_pricing_and_more"), - ("workflow", "0033_remove_jobpricing_job_and_more"), - ("workflow", "0034_alter_materialentry_quantity_and_more"), - ("workflow", "0035_alter_adjustmententry_comments_and_more"), - ("workflow", "0036_historicalstaff_hours_fri_historicalstaff_hours_mon_and_more"), - ("workflow", "0037_remove_historicalstaff_charge_out_rate_and_more"), - ("workflow", "0038_historicaljob_shop_job_job_shop_job"), - ("workflow", "0039_historicaljob_charge_out_rate_job_charge_out_rate"), - ("workflow", "0040_alter_historicaljob_charge_out_rate_and_more"), - ("workflow", "0041_timeentry_wage_rate_multiplier"), - ("workflow", "0042_jobpricing_job"), - ("workflow", "0043_alter_jobpricing_job"), - ("workflow", "0044_remove_timeentry_mins_per_item_timeentry_hours_and_more"), - ("workflow", "0045_remove_timeentry_minutes"), - ("workflow", "0046_alter_adjustmententry_options_alter_bill_options_and_more"), - ("workflow", "0047_rename_last_modified_bill_xero_last_modified_and_more"), - ("workflow", "0048_billlineitem_xero_id_invoicelineitem_xero_id"), - ("workflow", "0049_companydefaults_created_at_and_more"), - ("workflow", "0050_alter_historicalstaff_created_at_and_more"), - ("workflow", "0051_alter_historicalstaff_updated_at_and_more"), - ("workflow", "0052_alter_jobpricing_job"), - ("workflow", "0053_alter_job_latest_estimate_pricing_and_more"), - ("workflow", "0054_remove_historicaljob_shop_job_remove_job_shop_job_and_more"), - ("workflow", "0055_alter_historicaljob_description_and_more"), - ("workflow", "0056_rename_total_bill_total_excl_tax_and_more"), - ("workflow", "0057_rename_line_amount_billlineitem_line_amount_excl_tax_and_more"), - ("workflow", "0058_alter_billlineitem_options_and_more"), - ("workflow", "0059_rename_creditlineitem_creditnotelineitem_and_more"), - ("workflow", "0060_xerojournal_xerojournallineitem"), - ("workflow", "0061_xerojournallineitem_xero_last_modified"), - ("workflow", "0062_xerojournal_xero_last_modified"), - ("workflow", "0063_rename_date_xerojournal_journal_date"), - ("workflow", "0064_remove_xerojournallineitem_xero_last_modified"), - ("workflow", "0065_alter_xerojournal_journal_number"), - ("workflow", "0066_jobfile"), - ("workflow", "0067_jobfile_status"), - ("workflow", "0067_supplier_purchase_purchaseline_and_more"), - ("workflow", "0068_jobevent"), - ("workflow", "0068_purchase_created_at_purchase_updated_at_and_more"), - ("workflow", "0069_rename_user_jobevent_staff"), - ("workflow", "0070_jobevent_event_type"), - ("workflow", "0071_alter_jobevent_timestamp"), - ("workflow", "0072_merge_20250113_1626"), - ("workflow", "0073_jobpricing_is_historical"), - ("workflow", "0074_bill_xero_last_synced_client_xero_last_synced_and_more"), - ("workflow", "0075_invoice_online_url_quote"), - ("workflow", "0075_jobfile_print_on_jobsheet"), - ("workflow", "0076_invoice_job"), - ("workflow", "0076_remove_jobpricing_pricing_type_and_more"), - ("workflow", "0077_alter_quote_job"), - ("workflow", "0078_alter_quote_job"), - ("workflow", "0079_merge_20250207_1950"), - ("workflow", "0080_alter_historicaljob_material_gauge_quantity_and_more"), - ("workflow", "0080_historicaljob_complex_job_job_complex_job_and_more"), - ("workflow", "0081_add_last_xero_sync_to_company_defaults"), - ("workflow", "0082_add_last_xero_deep_sync"), - ("workflow", "0083_setup_xero_sync_service"), - ("workflow", "0084_cleanup_company_defaults"), - ("workflow", "0085_auto_20250302_2205"), - ("workflow", "0086_alter_historicaljob_contact_person_and_more"), - ("workflow", "0087_companydefaults_is_primary"), - ("workflow", "0088_merge_20250305_0549"), - ("workflow", "0089_companydefaults_starting_job_number_and_more"), - ("workflow", "0090_alter_staff_options"), - ("workflow", "0091_rename_coolected_historicaljob_collected_and_more"), - ("workflow", "0093_historicaljob_notes_job_notes"), - ("workflow", "0094_migrate_material_gauge_to_notes"), - ("workflow", "0095_alter_staff_options_and_more"), - ("workflow", "0096_merge_20250310_2039"), - ("workflow", "0097_alter_historicalstaff_ims_payroll_id_and_more"), - ("workflow", "0098_alter_historicalstaff_ims_payroll_id_and_more"), - ("workflow", "0099_historicaljob_created_by_job_created_by"), - ("workflow", "0100_companydefaults_xero_tenant_id"), - ("workflow", "0101_merge_20250317_1527"), - ("workflow", "0102_add_job_to_purchase_line"), - ("workflow", "0103_companydefaults_starting_po_number"), - ("workflow", "0104_alter_purchaseorder_expected_delivery"), - ("workflow", "0105_add_xero_fields_to_purchase_order"), - ("workflow", "0106_add_price_tbc_to_purchase_order_line"), - ("workflow", "0107_purchaseorder_online_url_purchaseorder_raw_json"), - ("workflow", "0108_xeroaccount_xero_last_synced"), - ("workflow", "0109_stock"), - ("workflow", "0110_purchaseorder_reference"), - ("workflow", "0111_alter_timeentry_minutes_per_item"), - ("workflow", "0112_remove_purchaseline_purchase_and_more"), - ("workflow", "0113_remove_stock_source_id_materialentry_source_stock_and_more"), - ("workflow", "0114_stock_is_active"), - ("workflow", "0115_alter_stock_id"), - ("workflow", "0116_stock_alloy_stock_location_stock_metal_type_and_more"), - ("workflow", "0117_alter_stock_metal_type"), - ("workflow", "0118_alter_materialentry_source_stock"), - ("workflow", "0119_alter_materialentry_source_stock"), - ("workflow", "0120_add_purchase_order_deleted_status"), - ("workflow", "0121_purchaseorderline_alloy_purchaseorderline_location_and_more"), - ("workflow", "0122_companydefaults_anthropic_api_key_and_more"), - ("workflow", "0123_alter_purchaseorder_order_date_and_more"), - ("workflow", "0124_companydefaults_billable_threshold_amber_and_more"), - ("workflow", "0125_normalize_po_numbers"), - ("workflow", "0126_alter_purchaseorder_order_date"), - ("workflow", "0127_remove_purchaseorder_raw_json_and_more"), - ("workflow", "0128_stock_retail_rate"), - ("workflow", "0129_companydefaults_gemini_api_key_stock_job_and_more"), - ("workflow", "0130_purchaseorderline_raw_line_data_stock_job_and_more"), - ("workflow", "0131_aiprovider_companydefaults_anthropic_api_key_and_more"), - ("workflow", "0132_stock_job_stock_source_purchase_order_line"), - ("workflow", "0133_bill_xero_tenant_id_client_xero_tenant_id_and_more"), - ("workflow", "0134_alter_purchaseorder_order_date"), - ("workflow", "0135_job_people_staff_icon_and_more"), - ("workflow", "0136_rename_job_status"), - ("workflow", "0137_auto_20250508_2012"), - ("workflow", "0138_stock_job_stock_source_purchase_order_line"), - ("workflow", "0139_bill_xero_tenant_id_client_xero_tenant_id_and_more"), - ("workflow", "0140_remove_staff_groups_remove_staff_user_permissions_and_more"), - ("workflow", "0141_remove_timeentry_job_pricing_remove_timeentry_staff_and_more"), - ("workflow", "0142_remove_historicaljob_client_and_more"), - ("workflow", "0143_alter_stock_job"), - ("workflow", "0144_remove_adjustmententry_job_pricing_and_more"), - ("workflow", "0145_fix_jobpricing_chain"), - ("workflow", "0146_delete_adjustmententry_delete_materialentry"), - ("workflow", "0147_companydefaults_po_prefix"), - ("workflow", "0148_client_primary_contact_email_and_more"), - ("workflow", "0149_remove_client_secondary_contact_email_and_more"), - ("workflow", "0150_client_all_phones"), - ("workflow", "0151_remove_purchaseline_purchase_and_more"), - ("workflow", "0152_remove_companydefaults_anthropic_api_key_and_more"), - ("workflow", "0153_delete_client_client"), - ("workflow", "0154_auto_20250604_1054"), - ("workflow", "0155_remove_stock_job_remove_stock_source_parent_stock_and_more"), - ("workflow", "0156_remove_stock_job_remove_stock_source_parent_stock_and_more"), - ("workflow", "0157_remove_bill_client_remove_billlineitem_bill_and_more"), - ("workflow", "0158_delete_bill_delete_billlineitem_delete_creditnote_and_more"), - ("workflow", "0159_add_shop_client_name_field"), - ("workflow", "0160_alter_aiprovider_provider_type"), - ("workflow", "0161_companydefaults_gdrive_quotes_folder_id_and_more"), - ("workflow", "0162_alter_companydefaults_gdrive_quotes_folder_id_and_more"), - ("workflow", "0163_rename_active_to_default_add_model_name"), - ("workflow", "0164_serviceapikey"), - ("workflow", "0165_app_error_models"), - ("workflow", "0166_alter_apperror_options_alter_xeroerror_options_and_more"), - ("workflow", "0167_alter_aiprovider_table"), - ("workflow", "0168_enhance_app_error_model"), - ("workflow", "0169_remove_aiprovider_company_field"), - ("workflow", "0170_companydefaults_xero_annual_leave_earnings_rate_id_and_more"), - ("workflow", "0171_protect_critical_fks"), - ("workflow", "0172_alter_xerotoken_scope"), - ("workflow", "0173_add_test_client_name_field"), - ("workflow", "0174_add_xero_payroll_models"), - ("workflow", "0175_make_payroll_fields_required"), - ("workflow", "0176_add_company_address_fields"), - ("workflow", "0177_add_company_email_url"), - ("workflow", "0178_add_company_acronym"), - ("workflow", "0179_add_xero_payroll_calendar_id"), - ("workflow", "0180_alter_xerotoken_scope"), - ("workflow", "0181_payrollcategory"), - ("workflow", "0182_populate_payroll_categories"), - ("workflow", "0183_remove_payroll_fields_from_companydefaults"), - ("workflow", "0184_add_xero_shortcode"), - ("workflow", "0185_fix_unpaid_leave_and_remove_posts_to_xero"), - ("workflow", "0186_simplify_payroll_category"), - ("workflow", "0188_delete_payrollcategory"), - ("workflow", "0189_add_xero_payroll_calendar_id"), - ("workflow", "0190_add_openai_provider_type"), - ("workflow", "0191_add_annual_leave_loading"), - ("workflow", "0192_add_financial_year_start_month"), - ("workflow", "0193_add_job_gp_target_percentage"), - ("workflow", "0194_add_profit_thresholds"), - ("workflow", "0195_rename_kpi_threshold_fields"), - ("workflow", "0196_alter_companydefaults_kpi_daily_billable_hours_amber_and_more"), - ("workflow", "0197_alter_xerotoken_scope"), - ("workflow", "0198_add_shared_drive_fields"), - ("workflow", "0199_populate_shared_drive_ids"), - ("workflow", "0200_xerosynccursor"), - ("workflow", "0201_add_xero_payroll_start_date"), - ("workflow", "0202_populate_xero_payroll_start_date"), - ("workflow", "0203_companydefaults_solo"), - ("workflow", "0204_alter_xerotoken_scope"), - ("workflow", "0205_companydefaults_company_phone"), - ("workflow", "0206_companydefaults_logo_companydefaults_logo_wide"), - ("workflow", "0207_unique_pay_item_name"), - ("workflow", "0208_enable_xero_sync"), - ("workflow", "0209_add_accounting_provider"), - ("workflow", "0209_rename_workflow_ap_timesta_ae5a69_idx_workflow_ap_timesta_a3d224_idx_and_more"), - ("workflow", "0210_merge_20260412_2225"), - ("workflow", "0211_apperror_app_error_resolved_msg_idx"), - ("workflow", "0212_companydefaults_singleton_check"), - ("workflow", "0213_companydefaults_weekend_timesheets_enabled"), - ("workflow", "0214_cachestate"), - ("workflow", "0215_add_daily_approved_hours_target"), - ("workflow", "0216_workshop_efficiency_factor"), - ("workflow", "0217_xeroapp"), - ("workflow", "0218_populate_xero_app"), - ("workflow", "0219_delete_xerotoken"), - ("workflow", "0221_drop_django_apscheduler_tables"), - ("workflow", "0222_remove_xero_sync_service"), - ("workflow", "0223_delete_cachestate"), - ("workflow", "0224_remove_app_error_resolved_msg_idx"), - ("workflow", "0225_alter_companydefaults_annual_leave_loading"), - ("workflow", "0227_delete_xero_journal"), - ("workflow", "0228_session_replay"), - ("workflow", "0230_session_replay_file_storage"), - ("workflow", "0231_companydefaults_shop_client_fk"), - ("workflow", "0232_rename_workflow_ap_timesta_a3d224_idx_workflow_apperror_time_sev_idx_and_more"), - ("workflow", "0233_companydefaults_job_delta_soft_fail_and_more"), - ("workflow", "0234_nullable_company_defaults_optional_urls"), - ("workflow", "0235_searchtelemetryevent"), - ("workflow", "0236_remove_companydefaults_charge_out_rate"), - ("workflow", "0237_remove_companydefaults_phone_fields"), - ] - dependencies = [ ("client", "0001_baseline"), migrations.swappable_dependency(settings.AUTH_USER_MODEL), diff --git a/apps/workflow/migrations/0002_seed_xero_pay_items.py b/apps/workflow/migrations/0002_seed_xero_pay_items.py index 35fff7c49..6dedb0c00 100644 --- a/apps/workflow/migrations/0002_seed_xero_pay_items.py +++ b/apps/workflow/migrations/0002_seed_xero_pay_items.py @@ -76,9 +76,6 @@ def delete_seed_xero_pay_items( class Migration(migrations.Migration): - replaces = [ - ("workflow", "0187_create_xero_pay_item"), - ] dependencies = [ ("workflow", "0001_baseline"), diff --git a/apps/workflow/migrations/0003_seed_celery_beat_schedules.py b/apps/workflow/migrations/0003_seed_celery_beat_schedules.py index 265608ed3..7df327045 100644 --- a/apps/workflow/migrations/0003_seed_celery_beat_schedules.py +++ b/apps/workflow/migrations/0003_seed_celery_beat_schedules.py @@ -145,11 +145,6 @@ def remove_schedules(apps: StateApps, schema_editor: BaseDatabaseSchemaEditor) - class Migration(migrations.Migration): - replaces = [ - ("workflow", "0220_seed_celery_beat_schedule"), - ("workflow", "0226_seed_stock_metadata_parse_schedule"), - ("workflow", "0229_seed_session_replay_purge_schedule"), - ] dependencies = [ ("workflow", "0002_seed_xero_pay_items"), From 6657326e88fe89f05edac4f855e215c31279c6a4 Mon Sep 17 00:00:00 2001 From: Corrin Lakeland Date: Mon, 6 Jul 2026 22:37:30 +1200 Subject: [PATCH 02/73] KAN-278 refactor: rename client app/model to company (backend + generated API client) Pure structural rename - no behavioral change, zero row-value changes: - apps/client -> apps/company (app label 'company'), model Client -> Company - FK/field renames: Job.client, Invoice/Bill/CreditNote/Quote.client, PhoneCallRecord.client -> company; CompanyDefaults.shop_client -> shop_company, test_client_name -> test_company_name - ClientContact/ClientContactMethod/Supplier class names unchanged (Stage B) - Index/constraint renames (company_name_fts_idx, company_name_trgm_idx, crm_phone_company_call_idx, unique_company_* etc.) - relabel_client_app surgery command: pre-migrate ledger relabel (deletes the 23 historic ghost rows, keeps 0001_baseline), content-type relabel, client_* -> company_* table renames; wired into deploy.sh before migrate - Rename migrations on top of the squashed baselines: proxy Delete/Create dance around RenameModel (contenttypes trap), Remove-constraint -> RenameField -> Add-constraint ordering (Q()-condition trap) - API surface: api/clients/ -> api/companies/, client_id/client_name -> company_*; schema.yml + generated api.ts regenerated (hand-written frontend swept in the follow-up commit on this branch) - Persisted values deliberately unchanged (Step 3): telemetry domain 'client', JobEvent 'client_changed', kanban 'client_contains', data-quality cross_client/effective_client_id, crm client_match param Co-Authored-By: Claude Fable 5 --- CLAUDE.md | 6 +- apps/accounting/migrations/0001_baseline.py | 4 +- apps/accounting/migrations/0002_baseline.py | 8 +- .../migrations/0003_rename_client_company.py | 30 + apps/accounting/models/invoice.py | 16 +- apps/accounting/models/quote.py | 2 +- apps/accounting/serializers/core.py | 6 +- .../serializers/rdti_spend_serializers.py | 2 +- .../serializers/sales_pipeline_serializers.py | 2 +- .../accounting/serializers/wip_serializers.py | 2 +- apps/accounting/services/core.py | 60 +- .../services/invoice_calculation.py | 2 +- .../accounting/services/rdti_spend_service.py | 4 +- .../services/sales_pipeline_service.py | 6 +- apps/accounting/services/wip_service.py | 4 +- apps/accounting/tests/test_core_nplusone.py | 22 +- .../tests/test_invoice_calculation.py | 10 +- apps/accounting/tests/test_invoice_models.py | 8 +- .../tests/test_sales_pipeline_service.py | 58 +- apps/accounting/views/sales_forecast_view.py | 16 +- .../management/commands/merge_clients.py | 131 - apps/client/tests/test_client_jobs_queries.py | 31 - apps/client/urls_rest.py | 108 - apps/{client => company}/__init__.py | 68 +- apps/{client => company}/admin.py | 0 apps/{client => company}/apps.py | 4 +- .../management/commands/merge_companies.py | 133 + .../migrations/0001_baseline.py | 14 +- .../migrations/0002_rename_client_company.py | 202 ++ ...03_alter_clientcontact_company_and_more.py | 58 + .../migrations/__init__.py | 0 apps/{client => company}/models.py | 237 +- apps/{client => company}/serializers.py | 124 +- apps/{client => company}/services/__init__.py | 8 +- .../services/company_merge_service.py} | 100 +- .../services/company_rest_service.py} | 544 +-- .../services/duplicate_phone_report.py | 34 +- .../services/geocoding_service.py | 0 apps/{client => company}/tests/__init__.py | 0 .../tests/test_company_fts_search.py} | 176 +- .../tests/test_company_invoice_summary.py} | 94 +- .../tests/test_company_jobs_queries.py | 31 + .../tests/test_company_merge_service.py} | 228 +- .../tests/test_contact_methods.py | 164 +- .../tests/test_duplicate_phone_report.py | 30 +- .../tests/test_get_company_for_xero.py} | 64 +- apps/company/urls_rest.py | 108 + apps/{client => company}/utils.py | 2 +- apps/{client => company}/views/__init__.py | 34 +- .../views/address_views.py | 4 +- .../views/company_rest_views.py} | 364 +- .../views/contact_method_viewset.py} | 22 +- .../views/contact_viewset.py} | 32 +- .../views/supplier_pickup_address_viewset.py | 24 +- .../views/supplier_search_alias_views.py | 34 +- apps/crm/migrations/0001_baseline.py | 6 +- .../migrations/0004_rename_client_company.py | 30 + apps/crm/models.py | 20 +- apps/crm/serializers.py | 20 +- apps/crm/services/phone_call_service.py | 102 +- apps/crm/tests/test_phone_call_service.py | 134 +- apps/crm/tests/test_phone_endpoint_guard.py | 20 +- apps/crm/views/phone_call_views.py | 16 +- .../management/commands/create_shop_jobs.py | 8 +- apps/job/migrations/0001_baseline.py | 6 +- .../migrations/0003_rename_client_company.py | 15 + apps/job/models/costing.py | 2 +- apps/job/models/job.py | 66 +- apps/job/models/job_event.py | 4 +- apps/job/serializers/costing_serializer.py | 16 +- .../data_quality_report_serializers.py | 12 +- .../job_profitability_report_serializers.py | 2 +- apps/job/serializers/job_serializer.py | 105 +- apps/job/serializers/kanban_serializer.py | 12 +- apps/job/services/chat_service.py | 2 +- apps/job/services/data_integrity_service.py | 141 +- apps/job/services/data_quality_report.py | 6 +- apps/job/services/job_profitability_report.py | 8 +- apps/job/services/job_rest_service.py | 64 +- apps/job/services/job_service.py | 2 +- apps/job/services/kanban_service.py | 48 +- apps/job/services/labour_subtype_service.py | 8 +- apps/job/services/mcp_chat_service.py | 2 +- apps/job/services/quote_mode_controller.py | 6 +- apps/job/services/workshop_pdf_service.py | 34 +- apps/job/services/workshop_service.py | 4 +- apps/job/tests/_pdf_golden_fixtures.py | 8 +- apps/job/tests/fixtures/chat_test_data.json | 10 +- apps/job/tests/test_chat_api_endpoints.py | 26 +- apps/job/tests/test_chat_performance.py | 14 +- apps/job/tests/test_chat_service.py | 44 +- .../tests/test_costline_schema_validation.py | 8 +- .../test_costline_time_rate_serialization.py | 10 +- .../job/tests/test_delivery_docket_service.py | 8 +- apps/job/tests/test_duplicate_phones_view.py | 12 +- apps/job/tests/test_event_deduplication.py | 10 +- apps/job/tests/test_job_event_tracking.py | 24 +- apps/job/tests/test_job_files_api.py | 8 +- apps/job/tests/test_job_invoicing.py | 10 +- apps/job/tests/test_job_latest_costsets.py | 16 +- apps/job/tests/test_job_quote_chat_model.py | 10 +- apps/job/tests/test_job_rest_service.py | 16 +- .../job/tests/test_job_summary_pdf_service.py | 12 +- .../job/tests/test_kanban_reorder_priority.py | 10 +- apps/job/tests/test_kanban_search.py | 130 +- apps/job/tests/test_kanban_service.py | 22 +- apps/job/tests/test_labour_subtypes.py | 22 +- apps/job/tests/test_mcp_tool_integration.py | 28 +- apps/job/tests/test_modern_timesheet_views.py | 12 +- apps/job/tests/test_paid_flag_service.py | 10 +- apps/job/tests/test_quote_modes.py | 16 +- apps/job/tests/test_workshop_pdf_service.py | 30 +- apps/job/tests/test_workshop_timesheet_api.py | 8 +- apps/job/utils.py | 2 +- apps/job/views/data_quality_report_views.py | 6 +- apps/job/views/delivery_docket_view.py | 2 +- apps/job/views/job_rest_views.py | 10 +- apps/job/views/kanban_view_api.py | 8 +- apps/job/views/modern_timesheet_views.py | 6 +- apps/job/views/month_end_rest_view.py | 4 +- apps/job/views/workshop_view.py | 2 +- .../workshop_schedule_serializer.py | 4 +- .../tests/test_scheduler_persistence.py | 18 +- .../tests/test_scheduler_service.py | 14 +- .../tests/test_workshop_schedule_api.py | 16 +- .../views/workshop_schedule_view.py | 8 +- apps/process/services/google_docs_service.py | 4 +- apps/process/services/safety_ai_service.py | 4 +- apps/process/tests/conftest.py | 14 +- apps/process/tests/test_procedure_service.py | 10 +- apps/purchasing/migrations/0001_baseline.py | 6 +- apps/purchasing/models.py | 4 +- apps/purchasing/serializers.py | 22 +- .../services/purchasing_rest_service.py | 8 +- .../services/quote_to_po_service.py | 6 +- .../services/supplier_search_service.py | 50 +- .../tests/test_purchase_order_line_usage.py | 2 +- .../tests/test_quote_to_po_service.py | 4 +- apps/purchasing/tests/test_serializers.py | 12 +- .../purchasing/tests/test_stock_fts_search.py | 22 +- .../tests/test_stock_metadata_tasks.py | 10 +- apps/purchasing/tests/test_supplier_search.py | 20 +- .../purchasing/views/purchasing_rest_views.py | 16 +- .../views/stock_search_rest_view.py | 2 +- apps/quoting/__init__.py | 2 +- apps/quoting/admin.py | 14 +- apps/quoting/mcp.py | 8 +- apps/quoting/migrations/0001_baseline.py | 12 +- apps/quoting/models.py | 12 +- apps/quoting/services/pdf_data_validation.py | 6 +- apps/quoting/services/pdf_import_service.py | 38 +- .../quoting/tests/test_pdf_data_validation.py | 4 +- .../test_supplier_scraper_credentials.py | 6 +- apps/quoting/tests_mcp.py | 18 +- apps/testing.py | 2 +- .../management/commands/create_special_job.py | 8 +- .../daily_timesheet_serializers.py | 2 +- .../modern_timesheet_serializers.py | 10 +- .../services/daily_timesheet_service.py | 4 +- .../tests/test_daily_timesheet_service.py | 28 +- apps/timesheet/tests/test_jobs_api_filter.py | 8 +- .../tests/test_weekly_timesheet_service.py | 8 +- apps/timesheet/views/api.py | 22 +- apps/workflow/accounting/provider.py | 8 +- apps/workflow/accounting/types.py | 4 +- apps/workflow/accounting/xero/provider.py | 24 +- apps/workflow/api/reports/job_movement.py | 6 +- apps/workflow/api/xero/__init__.py | 36 +- apps/workflow/api/xero/push.py | 92 +- apps/workflow/api/xero/reprocess_xero.py | 145 +- apps/workflow/api/xero/seed.py | 62 +- apps/workflow/api/xero/sync.py | 14 +- apps/workflow/api/xero/transforms.py | 140 +- apps/workflow/fixtures/company_defaults.json | 6 +- .../fixtures/company_defaults_prospect.json | 6 +- apps/workflow/fixtures/initial_data.json | 2 +- .../management/commands/e2e_cleanup.py | 50 +- .../management/commands/relabel_client_app.py | 84 + .../commands/seed_xero_from_database.py | 98 +- apps/workflow/middleware.py | 8 +- apps/workflow/migrations/0001_baseline.py | 4 +- .../0005_rename_shop_client_fields.py | 20 + ...r_companydefaults_shop_company_and_more.py | 35 + apps/workflow/models/company_defaults.py | 12 +- .../workflow/models/search_telemetry_event.py | 2 +- apps/workflow/models/settings_metadata.py | 8 +- apps/workflow/serializers.py | 6 +- apps/workflow/services/db_scrubber.py | 48 +- .../tests/test_api_schema_coverage.py | 2 +- .../tests/test_company_defaults_api.py | 14 +- .../workflow/tests/test_data_versions_view.py | 28 +- apps/workflow/tests/test_db_scrubber.py | 2 +- apps/workflow/tests/test_dev_demo_export.py | 36 +- .../tests/test_localdate_regression.py | 42 +- apps/workflow/tests/test_push_contacts.py | 50 +- .../workflow/tests/test_relabel_client_app.py | 163 + .../tests/test_seed_xero_from_database.py | 10 +- apps/workflow/tests/test_sync_clients.py | 86 +- .../tests/test_xero_document_raw_json.py | 20 +- apps/workflow/tests/test_xero_po_manager.py | 4 +- apps/workflow/tests/test_xero_quota_floor.py | 8 +- .../test_xero_transform_contact_resolution.py | 44 +- apps/workflow/views/data_versions_view.py | 6 +- apps/workflow/views/search_telemetry_view.py | 2 +- apps/workflow/views/xero/xero_base_manager.py | 30 +- .../views/xero/xero_invoice_manager.py | 30 +- apps/workflow/views/xero/xero_po_manager.py | 6 +- .../workflow/views/xero/xero_quote_manager.py | 26 +- apps/workflow/views/xero/xero_view.py | 8 +- docketworks/settings.py | 4 +- docketworks/urls.py | 2 +- docs/architecture.md | 10 +- docs/restore-prod-to-nonprod.md | 24 +- docs/urls/company.md | 56 + frontend/schema.yml | 2054 ++++++------ frontend/src/api/generated/api.ts | 574 ++-- mypy-baseline.txt | 2976 ++++++++--------- scripts/analyze_client_contacts.py | 20 +- scripts/fix_test_client.py | 34 - scripts/fix_test_company.py | 34 + scripts/geocode_addresses.py | 6 +- .../integration/verify_xero_batch_order.py | 2 +- .../verify_xero_client_quote_contract.py | 32 +- ...s_to_xero.py => push_companies_to_xero.py} | 114 +- scripts/restore_checks/check_django_orm.py | 4 +- scripts/restore_checks/check_shop_client.py | 22 - scripts/restore_checks/check_shop_company.py | 22 + scripts/restore_checks/check_test_client.py | 26 - scripts/restore_checks/check_test_company.py | 26 + scripts/restore_checks/check_xero_seed.py | 6 +- scripts/restore_checks/fix_shop_client.py | 31 - scripts/restore_checks/fix_shop_company.py | 31 + scripts/restore_checks/test_serializers.py | 16 +- scripts/server/deploy.sh | 7 + scripts/test_full_quote_conversation.py | 2 +- scripts/test_quote_import.py | 12 +- scripts/validate_restore_progress.py | 20 +- 237 files changed, 6957 insertions(+), 6205 deletions(-) create mode 100644 apps/accounting/migrations/0003_rename_client_company.py delete mode 100644 apps/client/management/commands/merge_clients.py delete mode 100644 apps/client/tests/test_client_jobs_queries.py delete mode 100644 apps/client/urls_rest.py rename apps/{client => company}/__init__.py (53%) rename apps/{client => company}/admin.py (100%) rename apps/{client => company}/apps.py (61%) create mode 100644 apps/company/management/commands/merge_companies.py rename apps/{client => company}/migrations/0001_baseline.py (98%) create mode 100644 apps/company/migrations/0002_rename_client_company.py create mode 100644 apps/company/migrations/0003_alter_clientcontact_company_and_more.py rename apps/{client => company}/migrations/__init__.py (100%) rename apps/{client => company}/models.py (79%) rename apps/{client => company}/serializers.py (74%) rename apps/{client => company}/services/__init__.py (83%) rename apps/{client/services/client_merge_service.py => company/services/company_merge_service.py} (69%) rename apps/{client/services/client_rest_service.py => company/services/company_rest_service.py} (56%) rename apps/{client => company}/services/duplicate_phone_report.py (76%) rename apps/{client => company}/services/geocoding_service.py (100%) rename apps/{client => company}/tests/__init__.py (100%) rename apps/{client/tests/test_client_fts_search.py => company/tests/test_company_fts_search.py} (57%) rename apps/{client/tests/test_client_invoice_summary.py => company/tests/test_company_invoice_summary.py} (63%) create mode 100644 apps/company/tests/test_company_jobs_queries.py rename apps/{client/tests/test_client_merge_service.py => company/tests/test_company_merge_service.py} (74%) rename apps/{client => company}/tests/test_contact_methods.py (71%) rename apps/{client => company}/tests/test_duplicate_phone_report.py (74%) rename apps/{client/tests/test_get_client_for_xero.py => company/tests/test_get_company_for_xero.py} (75%) create mode 100644 apps/company/urls_rest.py rename apps/{client => company}/utils.py (94%) rename apps/{client => company}/views/__init__.py (54%) rename apps/{client => company}/views/address_views.py (97%) rename apps/{client/views/client_rest_views.py => company/views/company_rest_views.py} (61%) rename apps/{client/views/client_contact_method_viewset.py => company/views/contact_method_viewset.py} (86%) rename apps/{client/views/client_contact_viewset.py => company/views/contact_viewset.py} (61%) rename apps/{client => company}/views/supplier_pickup_address_viewset.py (67%) rename apps/{client => company}/views/supplier_search_alias_views.py (83%) create mode 100644 apps/crm/migrations/0004_rename_client_company.py create mode 100644 apps/job/migrations/0003_rename_client_company.py create mode 100644 apps/workflow/management/commands/relabel_client_app.py create mode 100644 apps/workflow/migrations/0005_rename_shop_client_fields.py create mode 100644 apps/workflow/migrations/0006_alter_companydefaults_shop_company_and_more.py create mode 100644 apps/workflow/tests/test_relabel_client_app.py create mode 100644 docs/urls/company.md delete mode 100644 scripts/fix_test_client.py create mode 100644 scripts/fix_test_company.py rename scripts/{push_clients_to_xero.py => push_companies_to_xero.py} (56%) delete mode 100644 scripts/restore_checks/check_shop_client.py create mode 100644 scripts/restore_checks/check_shop_company.py delete mode 100644 scripts/restore_checks/check_test_client.py create mode 100644 scripts/restore_checks/check_test_company.py delete mode 100644 scripts/restore_checks/fix_shop_client.py create mode 100644 scripts/restore_checks/fix_shop_company.py diff --git a/CLAUDE.md b/CLAUDE.md index f3d534734..e1b51dfec 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -51,7 +51,7 @@ Django-based job/project management system for jobbing shops and custom work bus - **`workflow`** - Central hub, Xero integration, auth middleware - **`job`** - Job lifecycle, Kanban status tracking (Quoting → In Progress → Completed → Archived), audit trails - **`accounts`** - Custom Staff model extending AbstractBaseUser, authentication -- **`client`** - Customer management, bidirectional Xero contact sync +- **`company`** - Customer management, bidirectional Xero contact sync - **`timesheet`** - Time tracking, billable/non-billable, wage rates - **`purchasing`** - POs, stock management, Xero integration, links to CostLine via ext_refs - **`accounting`** - KPIs, financial reporting, invoice generation @@ -65,7 +65,7 @@ Django-based job/project management system for jobbing shops and custom work bus Job → CostSet (1:many) → CostLine (1:many) PurchaseOrder → PurchaseOrderLine → Stock → CostLine Staff → CostLine (time entries) -Client → Job (1:many) +Company → Job (1:many) ``` **Key Design Patterns:** @@ -83,7 +83,7 @@ Client → Job (1:many) TIME entries (kind='time'): - staff_id (str, UUID): Reference to Staff member - date (str, ISO): Date work performed (legacy - use accounting_date field instead) -- is_billable (bool): Whether billable to client +- is_billable (bool): Whether billable to company - wage_rate_multiplier/rate_multiplier (float): Rate multiplier (e.g., 1.5 for overtime) - note (str): Optional notes - created_from_timesheet (bool): True if from modern timesheet UI diff --git a/apps/accounting/migrations/0001_baseline.py b/apps/accounting/migrations/0001_baseline.py index e08f412b8..08ecf0a4d 100644 --- a/apps/accounting/migrations/0001_baseline.py +++ b/apps/accounting/migrations/0001_baseline.py @@ -13,7 +13,7 @@ class Migration(migrations.Migration): initial = True dependencies = [ - ("client", "0001_baseline"), + ("company", "0001_baseline"), ] operations = [ @@ -414,7 +414,7 @@ class Migration(migrations.Migration): ( "client", models.ForeignKey( - on_delete=django.db.models.deletion.PROTECT, to="client.client" + on_delete=django.db.models.deletion.PROTECT, to="company.client" ), ), ], diff --git a/apps/accounting/migrations/0002_baseline.py b/apps/accounting/migrations/0002_baseline.py index c459575d3..14b78fbc6 100644 --- a/apps/accounting/migrations/0002_baseline.py +++ b/apps/accounting/migrations/0002_baseline.py @@ -10,7 +10,7 @@ class Migration(migrations.Migration): dependencies = [ ("accounting", "0001_baseline"), - ("client", "0001_baseline"), + ("company", "0001_baseline"), ("job", "0001_baseline"), ("workflow", "0001_baseline"), ] @@ -39,7 +39,7 @@ class Migration(migrations.Migration): model_name="creditnote", name="client", field=models.ForeignKey( - on_delete=django.db.models.deletion.PROTECT, to="client.client" + on_delete=django.db.models.deletion.PROTECT, to="company.client" ), ), migrations.AddField( @@ -65,7 +65,7 @@ class Migration(migrations.Migration): model_name="invoice", name="client", field=models.ForeignKey( - on_delete=django.db.models.deletion.PROTECT, to="client.client" + on_delete=django.db.models.deletion.PROTECT, to="company.client" ), ), migrations.AddField( @@ -102,7 +102,7 @@ class Migration(migrations.Migration): model_name="quote", name="client", field=models.ForeignKey( - on_delete=django.db.models.deletion.PROTECT, to="client.client" + on_delete=django.db.models.deletion.PROTECT, to="company.client" ), ), migrations.AddField( diff --git a/apps/accounting/migrations/0003_rename_client_company.py b/apps/accounting/migrations/0003_rename_client_company.py new file mode 100644 index 000000000..15e6cc7be --- /dev/null +++ b/apps/accounting/migrations/0003_rename_client_company.py @@ -0,0 +1,30 @@ +from django.db import migrations + + +class Migration(migrations.Migration): + dependencies = [ + ("accounting", "0002_baseline"), + ] + + operations = [ + migrations.RenameField( + model_name="invoice", + old_name="client", + new_name="company", + ), + migrations.RenameField( + model_name="bill", + old_name="client", + new_name="company", + ), + migrations.RenameField( + model_name="creditnote", + old_name="client", + new_name="company", + ), + migrations.RenameField( + model_name="quote", + old_name="client", + new_name="company", + ), + ] diff --git a/apps/accounting/models/invoice.py b/apps/accounting/models/invoice.py index 0d0fa257d..cf6397fb5 100644 --- a/apps/accounting/models/invoice.py +++ b/apps/accounting/models/invoice.py @@ -6,7 +6,7 @@ from django.utils import timezone if TYPE_CHECKING: - from apps.client.models import Client + from apps.company.models import Company from apps.accounting.enums import InvoiceStatus @@ -23,7 +23,7 @@ class BaseXeroInvoiceDocument(models.Model): max_length=255, null=True, blank=True ) # For reference only - we are not fully multi-tenant yet number = models.CharField(max_length=255) - client = models.ForeignKey("client.Client", on_delete=models.PROTECT) + company = models.ForeignKey("company.Company", on_delete=models.PROTECT) date = models.DateField() due_date = models.DateField(null=True, blank=True) status = models.CharField( @@ -43,7 +43,7 @@ class Meta: abstract = True def __str__(self) -> str: - return f"{self.number} - {self.client.name}" + return f"{self.number} - {self.company.name}" @property def total_amount(self) -> Decimal: @@ -125,13 +125,13 @@ class Meta: ordering = ["-date", "number"] @property - def supplier(self) -> "Client": - """Return the client as 'supplier' for bills.""" - return self.client + def supplier(self) -> "Company": + """Return the company as 'supplier' for bills.""" + return self.company @supplier.setter - def supplier(self, value: "Client") -> None: - self.client = value + def supplier(self, value: "Company") -> None: + self.company = value class CreditNote(BaseXeroInvoiceDocument): diff --git a/apps/accounting/models/quote.py b/apps/accounting/models/quote.py index a99f2fa71..5379c17bd 100644 --- a/apps/accounting/models/quote.py +++ b/apps/accounting/models/quote.py @@ -15,7 +15,7 @@ class Quote(models.Model): job = models.OneToOneField( "job.Job", on_delete=models.PROTECT, related_name="quote", null=True, blank=True ) - client = models.ForeignKey("client.Client", on_delete=models.PROTECT) + company = models.ForeignKey("company.Company", on_delete=models.PROTECT) date = models.DateField() status = models.CharField( max_length=50, choices=QuoteStatus.choices, default=QuoteStatus.DRAFT diff --git a/apps/accounting/serializers/core.py b/apps/accounting/serializers/core.py index 70e4c675d..dbc45890c 100644 --- a/apps/accounting/serializers/core.py +++ b/apps/accounting/serializers/core.py @@ -21,7 +21,7 @@ class KPIJobBreakdownSerializer(serializers.Serializer[Any]): serializers.CharField() ) # Changed to CharField to match frontend schema job_name = serializers.CharField() # Changed from job_display_name - client_name = serializers.CharField() # Added client_name field + company_name = serializers.CharField() # Added company_name field billable_hours = serializers.FloatField() # Added billable_hours field revenue = serializers.FloatField() cost = serializers.FloatField() @@ -160,7 +160,7 @@ class JobAgingJobDataSerializer(serializers.Serializer[Any]): id = serializers.CharField() job_number = serializers.IntegerField() name = serializers.CharField() - client_name = serializers.CharField() + company_name = serializers.CharField() status = serializers.CharField() status_display = serializers.CharField() financial_data = JobAgingFinancialDataSerializer() @@ -208,7 +208,7 @@ class StaffPerformanceJobBreakdownSerializer(serializers.Serializer[Any]): job_id = serializers.CharField() job_number = serializers.IntegerField() job_name = serializers.CharField() - client_name = serializers.CharField() + company_name = serializers.CharField() billable_hours = serializers.FloatField() non_billable_hours = serializers.FloatField() total_hours = serializers.FloatField() diff --git a/apps/accounting/serializers/rdti_spend_serializers.py b/apps/accounting/serializers/rdti_spend_serializers.py index e5fdc2fa3..5953fe968 100644 --- a/apps/accounting/serializers/rdti_spend_serializers.py +++ b/apps/accounting/serializers/rdti_spend_serializers.py @@ -36,7 +36,7 @@ class RDTISpendJobDetailSerializer(serializers.Serializer[Any]): job_id = serializers.CharField() job_number = serializers.IntegerField() job_name = serializers.CharField() - client_name = serializers.CharField() + company_name = serializers.CharField() rdti_type = serializers.CharField() hours = serializers.FloatField() cost = serializers.FloatField() diff --git a/apps/accounting/serializers/sales_pipeline_serializers.py b/apps/accounting/serializers/sales_pipeline_serializers.py index 32a86322e..9c241d531 100644 --- a/apps/accounting/serializers/sales_pipeline_serializers.py +++ b/apps/accounting/serializers/sales_pipeline_serializers.py @@ -101,7 +101,7 @@ class SalesPipelineSnapshotJobSerializer(serializers.Serializer[Any]): id = serializers.CharField() job_number = serializers.IntegerField() name = serializers.CharField() - client_name = serializers.CharField(allow_blank=True) + company_name = serializers.CharField(allow_blank=True) hours = serializers.FloatField() value = serializers.FloatField() days_in_stage = serializers.IntegerField() diff --git a/apps/accounting/serializers/wip_serializers.py b/apps/accounting/serializers/wip_serializers.py index e7f6c3e52..c7190a3de 100644 --- a/apps/accounting/serializers/wip_serializers.py +++ b/apps/accounting/serializers/wip_serializers.py @@ -22,7 +22,7 @@ class WIPJobSerializer(serializers.Serializer[Any]): job_number = serializers.IntegerField() name = serializers.CharField() - client = serializers.CharField() + company = serializers.CharField() status = serializers.CharField() time_cost = serializers.FloatField() time_rev = serializers.FloatField() diff --git a/apps/accounting/services/core.py b/apps/accounting/services/core.py index 7d12c425c..a62a9fd84 100644 --- a/apps/accounting/services/core.py +++ b/apps/accounting/services/core.py @@ -153,7 +153,7 @@ def get_job_breakdown_for_date(cls, target_date: date) -> List[Dict[str, Any]]: List of job breakdowns with profit details """ defaults = CompanyDefaults.get_solo() - shop_client_id = defaults.shop_client_id + shop_company_id = defaults.shop_company_id excluded_staff_ids = get_payroll_excluded_staff_ids() # Get cost lines for the target date from 'actual' cost sets @@ -165,7 +165,7 @@ def get_job_breakdown_for_date(cls, target_date: date) -> List[Dict[str, Any]]: cost_set__kind="actual", accounting_date=target_date, ) - .select_related("cost_set__job__client") + .select_related("cost_set__job__company") ) # For time entries, exclude staff that shouldn't be included @@ -190,7 +190,7 @@ def get_job_breakdown_for_date(cls, target_date: date) -> List[Dict[str, Any]]: "job_id": str(job.id), "job_number": job_number, "job_display_name": job.job_display_name, - "client_name": job.client.name, # Add client name + "company_name": job.company.name, # Add company name "labour_revenue": 0, "labour_cost": 0, "material_revenue": 0, @@ -199,8 +199,8 @@ def get_job_breakdown_for_date(cls, target_date: date) -> List[Dict[str, Any]]: "adjustment_cost": 0, } - # Only count billable time revenue if not shop client - if line.is_billable == "true" and job.client_id != shop_client_id: + # Only count billable time revenue if not shop company + if line.is_billable == "true" and job.company_id != shop_company_id: job_data[job_number]["labour_revenue"] += float(line.total_rev) job_data[job_number]["labour_cost"] += float(line.total_cost) @@ -215,7 +215,7 @@ def get_job_breakdown_for_date(cls, target_date: date) -> List[Dict[str, Any]]: "job_id": str(job.id), "job_number": job_number, "job_display_name": job.job_display_name, - "client_name": job.client.name, # Add client name + "company_name": job.company.name, # Add company name "labour_revenue": 0, "labour_cost": 0, "material_revenue": 0, @@ -237,7 +237,7 @@ def get_job_breakdown_for_date(cls, target_date: date) -> List[Dict[str, Any]]: "job_id": str(job.id), "job_number": job_number, "job_display_name": job.job_display_name, - "client_name": job.client.name, # Add client name + "company_name": job.company.name, # Add company name "labour_revenue": 0, "labour_cost": 0, "material_revenue": 0, @@ -263,7 +263,7 @@ def get_job_breakdown_for_date(cls, target_date: date) -> List[Dict[str, Any]]: if ( line.cost_set.job.job_number == job_number and line.is_billable == "true" - and line.cost_set.job.client_id != shop_client_id + and line.cost_set.job.company_id != shop_company_id ): billable_hours += float(line.quantity) @@ -282,7 +282,7 @@ def get_job_breakdown_for_date(cls, target_date: date) -> List[Dict[str, Any]]: "job_id": data["job_id"], "job_number": str(job_number), "job_name": data["job_display_name"], - "client_name": data["client_name"], + "company_name": data["company_name"], "billable_hours": billable_hours, "revenue": total_revenue, "cost": total_cost, @@ -313,7 +313,7 @@ def get_calendar_data(cls, year: int, month: int) -> Dict[str, Any]: logger.info(f"Generating KPI calendar data for {year}-{month}") defaults = CompanyDefaults.get_solo() - shop_client_id = defaults.shop_client_id + shop_company_id = defaults.shop_company_id thresholds = cls.get_company_thresholds() logger.debug( f"Using thresholds: green={thresholds['kpi_daily_billable_hours_green']}, " @@ -389,16 +389,16 @@ def get_calendar_data(cls, year: int, month: int) -> Dict[str, Any]: hours = line.quantity time_entries_by_date[line_date]["total_hours"] += hours - # Check if billable and not shop client + # Check if billable and not shop company if ( line.is_billable == "true" - and line.cost_set.job.client_id != shop_client_id + and line.cost_set.job.company_id != shop_company_id ): time_entries_by_date[line_date]["billable_hours"] += hours time_entries_by_date[line_date]["time_revenue"] += line.total_rev # Check if shop hours - if line.cost_set.job.client_id == shop_client_id: + if line.cost_set.job.company_id == shop_company_id: time_entries_by_date[line_date]["shop_hours"] += hours time_entries_by_date[line_date]["staff_cost"] += line.total_cost @@ -782,7 +782,7 @@ def get_job_aging_data(include_archived: bool = False) -> Dict[str, Any]: try: # Get active jobs (exclude archived unless requested) - jobs_query = Job.objects.select_related("client").prefetch_related( + jobs_query = Job.objects.select_related("company").prefetch_related( "events", "cost_sets__cost_lines" ) @@ -799,7 +799,7 @@ def get_job_aging_data(include_archived: bool = False) -> Dict[str, Any]: additional_context={ "operation": "fetch_jobs_for_aging_report", "include_archived": include_archived, - "query_filters": "active_jobs_with_client_and_cost_data", + "query_filters": "active_jobs_with_company_and_cost_data", }, ) @@ -816,7 +816,7 @@ def get_job_aging_data(include_archived: bool = False) -> Dict[str, Any]: "id": str(job.id), "job_number": job.job_number, "name": job.name, - "client_name": job.client.name if job.client else "No Client", + "company_name": job.company.name if job.company else "No Company", "status": job.status, "status_display": job.get_status_display(), "price_cap": job.price_cap, @@ -833,7 +833,7 @@ def get_job_aging_data(include_archived: bool = False) -> Dict[str, Any]: "operation": "process_individual_job_for_aging", "job_number": job.job_number, "job_status": job.status, - "client_name": job.client.name if job.client else None, + "company_name": job.company.name if job.company else None, }, ) # Continue processing other jobs @@ -1189,7 +1189,7 @@ def get_staff_performance_data( accounting_date__gte=start_date, accounting_date__lte=end_date, ) - .select_related("cost_set__job__client") + .select_related("cost_set__job__company") ) # Get all active staff @@ -1201,7 +1201,7 @@ def get_staff_performance_data( staff_data = [] include_job_breakdown = staff_id is not None - shop_client_id = CompanyDefaults.get_solo().shop_client_id + shop_company_id = CompanyDefaults.get_solo().shop_company_id lines_by_staff_id: Dict[str, List[CostLine]] = {} for line in cost_lines: @@ -1216,7 +1216,7 @@ def get_staff_performance_data( staff, staff_cost_lines, include_job_breakdown, - shop_client_id, + shop_company_id, ) # Only include staff with recorded hours in the period if staff_metrics["total_hours"] > 0: @@ -1261,7 +1261,7 @@ def _calculate_staff_metrics( staff: Staff, cost_lines: List[CostLine], include_job_breakdown: bool = False, - shop_client_id: UUID | None = None, + shop_company_id: UUID | None = None, ) -> Dict[str, Any]: """ Calculate performance metrics for a single staff member. @@ -1275,15 +1275,15 @@ def _calculate_staff_metrics( Dict containing staff performance metrics """ total_hours = float(sum(line.quantity for line in cost_lines)) - if shop_client_id is None: - shop_client_id = CompanyDefaults.get_solo().shop_client_id + if shop_company_id is None: + shop_company_id = CompanyDefaults.get_solo().shop_company_id billable_hours = float( sum( line.quantity for line in cost_lines if line.is_billable_meta == "true" - and line.cost_set.job.client_id != shop_client_id + and line.cost_set.job.company_id != shop_company_id ) ) @@ -1320,7 +1320,7 @@ def _calculate_staff_metrics( if include_job_breakdown: job_breakdown = StaffPerformanceService._calculate_job_breakdown( cost_lines, - shop_client_id, + shop_company_id, ) staff_metrics["job_breakdown"] = job_breakdown @@ -1329,7 +1329,7 @@ def _calculate_staff_metrics( @staticmethod def _calculate_job_breakdown( cost_lines: models.QuerySet, - shop_client_id: UUID | None = None, + shop_company_id: UUID | None = None, ) -> List[Dict[str, Any]]: """ Calculate job-level breakdown for cost lines. @@ -1341,8 +1341,8 @@ def _calculate_job_breakdown( List of job breakdown dictionaries """ job_data = {} - if shop_client_id is None: - shop_client_id = CompanyDefaults.get_solo().shop_client_id + if shop_company_id is None: + shop_company_id = CompanyDefaults.get_solo().shop_company_id for line in cost_lines: job = line.cost_set.job @@ -1353,7 +1353,7 @@ def _calculate_job_breakdown( "job_id": job_id, "job_number": job.job_number or "", "job_name": job.name or "", - "client_name": job.client.name if job.client else "", + "company_name": job.company.name if job.company else "", "billable_hours": 0.0, "non_billable_hours": 0.0, "revenue": 0.0, @@ -1362,7 +1362,7 @@ def _calculate_job_breakdown( hours = float(line.quantity) # Shop jobs are always non-billable regardless of the is_billable flag - is_shop_job = line.cost_set.job.client_id == shop_client_id + is_shop_job = line.cost_set.job.company_id == shop_company_id is_billable = line.is_billable_meta == "true" and not is_shop_job if is_billable: diff --git a/apps/accounting/services/invoice_calculation.py b/apps/accounting/services/invoice_calculation.py index 8a5dcbf5e..ac783abf0 100644 --- a/apps/accounting/services/invoice_calculation.py +++ b/apps/accounting/services/invoice_calculation.py @@ -40,7 +40,7 @@ def get_prior_valid_invoice_total(job: Job) -> Decimal: def get_job_for_invoice_calculation(job_id: UUID) -> Job: - job = Job.objects.select_related("client", "latest_quote", "latest_actual").get( + job = Job.objects.select_related("company", "latest_quote", "latest_actual").get( id=job_id ) diff --git a/apps/accounting/services/rdti_spend_service.py b/apps/accounting/services/rdti_spend_service.py index 9b681fd89..2b43e41b9 100644 --- a/apps/accounting/services/rdti_spend_service.py +++ b/apps/accounting/services/rdti_spend_service.py @@ -55,7 +55,7 @@ def _build_report(start_date: date, end_date: date) -> dict[str, Any]: job_id=F("cost_set__job__id"), job_number=F("cost_set__job__job_number"), job_name=F("cost_set__job__name"), - client_name=F("cost_set__job__client__name"), + company_name=F("cost_set__job__company__name"), rdti_type=Coalesce( F("cost_set__job__rdti_type"), Value("unclassified") ), @@ -76,7 +76,7 @@ def _build_report(start_date: date, end_date: date) -> dict[str, Any]: "job_id": str(row["job_id"]), "job_number": row["job_number"], "job_name": row["job_name"], - "client_name": row["client_name"] or "", + "company_name": row["company_name"] or "", "rdti_type": row["rdti_type"], "hours": float(row["hours"]), "cost": float(row["cost"]), diff --git a/apps/accounting/services/sales_pipeline_service.py b/apps/accounting/services/sales_pipeline_service.py index 9439508a8..8934b6ccb 100644 --- a/apps/accounting/services/sales_pipeline_service.py +++ b/apps/accounting/services/sales_pipeline_service.py @@ -72,7 +72,7 @@ FUNNEL_PATH_ESTIMATING = "estimating" # Cap on how many sample job records each warning bucket carries back to the -# client. The full count is always reported. +# company. The full count is always reported. WARNING_SAMPLE_CAP = 10 # Warning codes (machine-readable). @@ -283,7 +283,7 @@ def _fetch_jobs(job_ids: Iterable[Any]) -> dict[Any, Job]: if not ids: return {} jobs = Job.objects.filter(id__in=ids).select_related( - "latest_quote", "latest_estimate", "client" + "latest_quote", "latest_estimate", "company" ) return {j.id: j for j in jobs} @@ -731,7 +731,7 @@ def _build_snapshot( "id": str(job.id), "job_number": job.job_number, "name": job.name, - "client_name": job.client.name if job.client_id else "", + "company_name": job.company.name if job.company_id else "", "hours": hours, "value": value or 0.0, "days_in_stage": days_in_stage, diff --git a/apps/accounting/services/wip_service.py b/apps/accounting/services/wip_service.py index 07a6881a0..0f11e087a 100644 --- a/apps/accounting/services/wip_service.py +++ b/apps/accounting/services/wip_service.py @@ -60,7 +60,7 @@ def get_wip_data(report_date: date, method: str) -> dict[str, Any]: Job.objects.filter(fully_invoiced=False, rejected_flag=False) .exclude(status__in=NO_WORK_STATUSES) .exclude(latest_actual__isnull=True) - .select_related("latest_actual", "client") + .select_related("latest_actual", "company") .order_by("job_number") ) except AlreadyLoggedException: @@ -155,7 +155,7 @@ def _aggregate_job( return { "job_number": job.job_number, "name": job.name, - "client": str(job.client) if job.client else "N/A", + "company": str(job.company) if job.company else "N/A", "status": job.status, "time_cost": float(totals["time_cost"] or Decimal("0")), "time_rev": float(totals["time_rev"] or Decimal("0")), diff --git a/apps/accounting/tests/test_core_nplusone.py b/apps/accounting/tests/test_core_nplusone.py index 78b29ec78..3f0c3faaa 100644 --- a/apps/accounting/tests/test_core_nplusone.py +++ b/apps/accounting/tests/test_core_nplusone.py @@ -8,25 +8,25 @@ from apps.accounting.services.core import KPIService, StaffPerformanceService from apps.accounts.models import Staff -from apps.client.models import Client +from apps.company.models import Company from apps.job.models import CostLine, Job, LabourSubtype from apps.testing import BaseTestCase from apps.workflow.models import XeroPayItem class AccountingCoreQueryTests(BaseTestCase): - """Accounting reports must not query job/client data once per row. + """Accounting reports must not query job/company data once per row. The reports loop over CostLine rows and grouped staff metrics. These tests - catch refactors that drop the preloaded job/client data by using multiple + catch refactors that drop the preloaded job/company data by using multiple rows and a fixed query budget for the report boundary. """ def setUp(self): super().setUp() self.target_date = date(2026, 5, 22) - self.client = Client.objects.create( - name="Accounting Nplusone Client", + self.company = Company.objects.create( + name="Accounting Nplusone Company", email="accounting-nplusone@example.com", xero_last_modified=timezone.now(), ) @@ -47,7 +47,7 @@ def _create_job(self, job_number: int) -> Job: job = Job.objects.create( job_number=job_number, name=f"Accounting Job {job_number}", - client=self.client, + company=self.company, default_xero_pay_item=self.pay_item, staff=self.test_staff, ) @@ -76,11 +76,11 @@ def _create_time_line(self, job: Job, staff: Staff, hours: str = "2.000"): ) def test_kpi_job_breakdown_preloads_job_client(self): - """Job breakdown must not fetch each job's client inside the loop. + """Job breakdown must not fetch each job's company inside the loop. - This catches removing ``select_related("cost_set__job__client")`` by + This catches removing ``select_related("cost_set__job__company")`` by asserting the report stays at fixed query overhead plus one query per - CostLine kind, even when it emits client names. + CostLine kind, even when it emits company names. """ job = self._create_job(98801) CostLine.objects.create( @@ -98,12 +98,12 @@ def test_kpi_job_breakdown_preloads_job_client(self): with self.assertNumQueries(5): breakdown = KPIService.get_job_breakdown_for_date(self.target_date) - self.assertEqual(breakdown[0]["client_name"], "Accounting Nplusone Client") + self.assertEqual(breakdown[0]["company_name"], "Accounting Nplusone Company") def test_staff_performance_groups_prefetched_cost_lines_by_staff(self): """Staff performance must group prefetched lines without per-staff queries. - This catches metric code that looks up CostLine/job/client data inside + This catches metric code that looks up CostLine/job/company data inside the staff loop by asserting two staff with separate jobs stay within a fixed query ceiling. """ diff --git a/apps/accounting/tests/test_invoice_calculation.py b/apps/accounting/tests/test_invoice_calculation.py index aa365afe0..6cd80aff4 100644 --- a/apps/accounting/tests/test_invoice_calculation.py +++ b/apps/accounting/tests/test_invoice_calculation.py @@ -15,7 +15,7 @@ get_job_for_invoice_calculation, get_prior_valid_invoice_total, ) -from apps.client.models import Client +from apps.company.models import Company from apps.job.models import Job from apps.job.models.costing import CostLine from apps.testing import BaseTestCase @@ -25,14 +25,14 @@ class TestInvoiceCalculation(BaseTestCase): """Tests for calculate_invoice_amount().""" def setUp(self): - self.client_obj = Client.objects.create( - name="Test Client", + self.client_obj = Company.objects.create( + name="Test Company", xero_last_modified=timezone.now(), ) def _create_job(self, pricing_methodology="time_materials"): job = Job( - client=self.client_obj, + company=self.client_obj, name="Test Job", pricing_methodology=pricing_methodology, ) @@ -53,7 +53,7 @@ def _add_revenue_line(self, cost_set, revenue): def _create_invoice(self, job, amount, status="AUTHORISED"): return Invoice.objects.create( job=job, - client=self.client_obj, + company=self.client_obj, xero_id=uuid.uuid4(), number=f"INV-{uuid.uuid4().hex[:8]}", status=status, diff --git a/apps/accounting/tests/test_invoice_models.py b/apps/accounting/tests/test_invoice_models.py index b597fb5e7..349c49e11 100644 --- a/apps/accounting/tests/test_invoice_models.py +++ b/apps/accounting/tests/test_invoice_models.py @@ -5,7 +5,7 @@ from apps.accounting.enums import InvoiceStatus from apps.accounting.models import Invoice, InvoiceLineItem -from apps.client.models import Client +from apps.company.models import Company from apps.testing import BaseTestCase @@ -15,15 +15,15 @@ def test_total_amount_sums_reverse_line_items(self): when line items are added or modified — a bug here silently corrupts invoice totals displayed to users and synced to Xero. """ - client = Client.objects.create( - name="Invoice Model Client", + company = Company.objects.create( + name="Invoice Model Company", xero_last_modified=timezone.now(), ) invoice = Invoice.objects.create( xero_id=uuid4(), xero_tenant_id="tenant-id", number="INV-001", - client=client, + company=company, date=timezone.localdate(), due_date=timezone.localdate(), status=InvoiceStatus.DRAFT, diff --git a/apps/accounting/tests/test_sales_pipeline_service.py b/apps/accounting/tests/test_sales_pipeline_service.py index df20ef5e9..f45f8e587 100644 --- a/apps/accounting/tests/test_sales_pipeline_service.py +++ b/apps/accounting/tests/test_sales_pipeline_service.py @@ -15,7 +15,7 @@ from django.utils import timezone from apps.accounting.services import SalesPipelineService -from apps.client.models import Client +from apps.company.models import Company from apps.job.models import Job, JobEvent from apps.job.models.costing import CostSet from apps.testing import BaseTestCase @@ -32,14 +32,14 @@ def _nz_dt(d: date, hour: int = 12) -> datetime: class SalesPipelineServiceFixturesMixin: """Shared fixture-builders. Use via multiple inheritance with BaseTestCase.""" - def _make_client(self, name: str = "Acme Co") -> Client: - return Client.objects.create( + def _make_client(self, name: str = "Acme Co") -> Company: + return Company.objects.create( name=name, email=f"{name.lower().replace(' ', '_')}@example.com", xero_last_modified="2024-01-01T00:00:00Z", ) - def _make_job(self, *, name: str, client: Client, created_dt: datetime) -> Job: + def _make_job(self, *, name: str, company: Company, created_dt: datetime) -> Job: """Create a job with a deterministic ``job_created`` event at ``created_dt``. ``Job.save()`` itself does not emit ``job_created`` — that lives in @@ -49,7 +49,7 @@ def _make_job(self, *, name: str, client: Client, created_dt: datetime) -> Job: pay_item = XeroPayItem.get_ordinary_time() job = Job( name=name, - client=client, + company=company, created_by=self.test_staff, default_xero_pay_item=pay_item, ) @@ -61,7 +61,7 @@ def _make_job(self, *, name: str, client: Client, created_dt: datetime) -> Job: staff=self.test_staff, detail={ "job_name": job.name, - "client_name": client.name if client else "Shop Job", + "company_name": company.name if company else "Shop Job", "contact_name": None, "initial_status": job.get_status_display(), "pricing_methodology": job.get_pricing_methodology_display(), @@ -144,7 +144,7 @@ def setUp(self) -> None: def test_status_changed_into_approved_counts(self): job = self._make_job( - name="J1", client=self.client_obj, created_dt=_nz_dt(date(2026, 1, 5)) + name="J1", company=self.client_obj, created_dt=_nz_dt(date(2026, 1, 5)) ) self._attach_quote(job, hours=12.5) self._add_status_change( @@ -162,7 +162,7 @@ def test_status_changed_into_approved_counts(self): def test_quote_accepted_counts(self): job = self._make_job( - name="J2", client=self.client_obj, created_dt=_nz_dt(date(2026, 1, 5)) + name="J2", company=self.client_obj, created_dt=_nz_dt(date(2026, 1, 5)) ) self._attach_quote(job, hours=4.0) self._add_status_change( @@ -176,7 +176,7 @@ def test_quote_accepted_counts(self): def test_direct_approved_counts_in_direct_bucket(self): job = self._make_job( - name="J3", client=self.client_obj, created_dt=_nz_dt(date(2026, 1, 5)) + name="J3", company=self.client_obj, created_dt=_nz_dt(date(2026, 1, 5)) ) self._attach_quote(job, hours=6.0) # Straight from draft to approved — no awaiting and no quote_accepted. @@ -191,7 +191,7 @@ def test_direct_approved_counts_in_direct_bucket(self): def test_dedupe_approved_then_in_progress_same_period(self): job = self._make_job( - name="J4", client=self.client_obj, created_dt=_nz_dt(date(2026, 1, 5)) + name="J4", company=self.client_obj, created_dt=_nz_dt(date(2026, 1, 5)) ) self._attach_quote(job, hours=10.0) self._add_status_change( @@ -221,7 +221,9 @@ def test_target_reflects_company_defaults(self): def test_missing_hours_summary_excludes_and_warns(self): job = self._make_job( - name="No hours", client=self.client_obj, created_dt=_nz_dt(date(2026, 1, 5)) + name="No hours", + company=self.client_obj, + created_dt=_nz_dt(date(2026, 1, 5)), ) # Remove hours from the default quote/estimate summaries so hours # resolution genuinely fails (Job.save() seeds both with hours=0.0). @@ -250,7 +252,7 @@ def test_replays_to_historical_status_not_live(self): # the historical state is "awaiting_approval". job = self._make_job( name="Replay job", - client=self.client_obj, + company=self.client_obj, created_dt=_nz_dt(date(2026, 1, 5)), ) self._attach_quote(job, hours=7.0, rev=2100.0) @@ -282,7 +284,7 @@ def test_days_in_stage_uses_most_recent_entry(self): # Created in awaiting_approval, bounced back to draft, then back to # awaiting_approval. Days-in-stage measured from the latest entry. job = self._make_job( - name="Bouncy", client=self.client_obj, created_dt=_nz_dt(date(2026, 1, 1)) + name="Bouncy", company=self.client_obj, created_dt=_nz_dt(date(2026, 1, 1)) ) # Override creation status to awaiting_approval for this test so the # historical replay anchor starts there. @@ -308,14 +310,14 @@ def test_days_in_stage_uses_most_recent_entry(self): def test_draft_uses_estimate_summary_awaiting_uses_quote(self): d_job = self._make_job( - name="DraftJ", client=self.client_obj, created_dt=_nz_dt(date(2026, 2, 1)) + name="DraftJ", company=self.client_obj, created_dt=_nz_dt(date(2026, 2, 1)) ) self._attach_estimate(d_job, hours=2.5) # Quote also exists but draft must use estimate self._attach_quote(d_job, hours=99.9) a_job = self._make_job( - name="AwaitJ", client=self.client_obj, created_dt=_nz_dt(date(2026, 2, 1)) + name="AwaitJ", company=self.client_obj, created_dt=_nz_dt(date(2026, 2, 1)) ) self._attach_estimate(a_job, hours=99.9) self._attach_quote(a_job, hours=4.5) @@ -341,7 +343,7 @@ def test_narrowed_fetch_preserves_historical_replay(self): """ job = self._make_job( name="LongHistory", - client=self.client_obj, + company=self.client_obj, created_dt=_nz_dt(date(2023, 1, 15)), ) self._attach_quote(job, hours=9.0, rev=1800.0) @@ -372,7 +374,7 @@ def test_narrowed_fetch_applies_lower_bound_for_in_window_query(self): # (single queryset), not the in-window queryset. outside_job = self._make_job( name="Outside", - client=self.client_obj, + company=self.client_obj, created_dt=_nz_dt(date(2022, 3, 1)), ) self._attach_estimate(outside_job, hours=2.0) @@ -413,7 +415,7 @@ def test_missing_creation_anchor_excludes_and_warns(self): # Build a job and then delete its job_created event. job = self._make_job( name="Anchorless", - client=self.client_obj, + company=self.client_obj, created_dt=_nz_dt(date(2026, 2, 1)), ) self._attach_quote(job, hours=5.0) @@ -442,7 +444,7 @@ def test_median_p80_sample_size(self): created = date(2026, 2, 1) approved_dt = _nz_dt(date(2026, 2, 1) + timedelta(days=gap)) j = self._make_job( - name=f"V{i}", client=self.client_obj, created_dt=_nz_dt(created) + name=f"V{i}", company=self.client_obj, created_dt=_nz_dt(created) ) self._attach_quote(j, hours=1.0) self._add_status_change( @@ -474,7 +476,7 @@ def test_pre_window_creation_resolved_for_in_window_approval(self): """ job = self._make_job( name="OldCreatedNowArchived", - client=self.client_obj, + company=self.client_obj, created_dt=_nz_dt(date(2024, 1, 15)), ) self._attach_quote(job, hours=4.0) @@ -521,7 +523,7 @@ def test_missing_creation_anchor_excludes_and_warns(self): job_created anchor must produce a warning, not silent exclusion.""" job = self._make_job( name="VelAnchorless", - client=self.client_obj, + company=self.client_obj, created_dt=_nz_dt(date(2026, 2, 1)), ) self._attach_quote(job, hours=1.0) @@ -557,7 +559,7 @@ def setUp(self) -> None: def test_categorization_is_mutually_exclusive(self): # Accepted via quote_accepted j_acc = self._make_job( - name="Acc", client=self.client_obj, created_dt=_nz_dt(date(2026, 3, 2)) + name="Acc", company=self.client_obj, created_dt=_nz_dt(date(2026, 3, 2)) ) self._attach_quote(j_acc, hours=1.0) self._add_status_change( @@ -567,7 +569,7 @@ def test_categorization_is_mutually_exclusive(self): # Rejected j_rej = self._make_job( - name="Rej", client=self.client_obj, created_dt=_nz_dt(date(2026, 3, 3)) + name="Rej", company=self.client_obj, created_dt=_nz_dt(date(2026, 3, 3)) ) self._attach_quote(j_rej, hours=2.0) self._add_status_change( @@ -577,7 +579,7 @@ def test_categorization_is_mutually_exclusive(self): # Waiting j_wait = self._make_job( - name="Wait", client=self.client_obj, created_dt=_nz_dt(date(2026, 3, 4)) + name="Wait", company=self.client_obj, created_dt=_nz_dt(date(2026, 3, 4)) ) self._attach_quote(j_wait, hours=4.0) self._add_status_change( @@ -586,7 +588,7 @@ def test_categorization_is_mutually_exclusive(self): # Direct j_dir = self._make_job( - name="Dir", client=self.client_obj, created_dt=_nz_dt(date(2026, 3, 5)) + name="Dir", company=self.client_obj, created_dt=_nz_dt(date(2026, 3, 5)) ) self._attach_estimate(j_dir, hours=8.0) self._add_status_change( @@ -595,7 +597,7 @@ def test_categorization_is_mutually_exclusive(self): # Still draft j_draft = self._make_job( - name="Drf", client=self.client_obj, created_dt=_nz_dt(date(2026, 3, 6)) + name="Drf", company=self.client_obj, created_dt=_nz_dt(date(2026, 3, 6)) ) self._attach_estimate(j_draft, hours=16.0) @@ -622,7 +624,7 @@ def test_missing_creation_anchor_excludes_and_warns(self): being silently dropped.""" job = self._make_job( name="FunnelAnchorless", - client=self.client_obj, + company=self.client_obj, created_dt=_nz_dt(date(2026, 3, 5)), ) self._attach_quote(job, hours=3.0) @@ -661,7 +663,7 @@ def test_rolling_average_derived_from_weekly_series(self): approved_day = end_date - timedelta(weeks=2 - i, days=2) # Friday-ish j = self._make_job( name=f"T{i}", - client=self.client_obj, + company=self.client_obj, created_dt=_nz_dt(approved_day - timedelta(days=10)), ) self._attach_quote(j, hours=hours) diff --git a/apps/accounting/views/sales_forecast_view.py b/apps/accounting/views/sales_forecast_view.py index 5cf2e0cd7..23b36a990 100644 --- a/apps/accounting/views/sales_forecast_view.py +++ b/apps/accounting/views/sales_forecast_view.py @@ -211,7 +211,7 @@ class SalesForecastMonthDetailAPIView(APIView): "type": "object", "properties": { "date": {"type": "string", "format": "date"}, - "client_name": {"type": "string"}, + "company_name": {"type": "string"}, "job_number": {"type": "integer", "nullable": True}, "job_name": {"type": "string", "nullable": True}, "invoice_numbers": {"type": "string", "nullable": True}, @@ -311,7 +311,7 @@ def _build_comparison_rows(self, year: int, month: int) -> List[Dict[str, Any]]: ~Q(status__in=["DRAFT", "DELETED", "VOIDED"]), date__year=year, date__month=month, - ).select_related("client", "job", "job__client") + ).select_related("company", "job", "job__company") # Get jobs with actual revenue in this month jobs_with_revenue = self._get_jobs_with_revenue(year, month) @@ -331,7 +331,7 @@ def _build_comparison_rows(self, year: int, month: int) -> List[Dict[str, Any]]: def build_row( row_date: str, - client_name: str, + company_name: str, job_number: Optional[int], job_name: Optional[str], invoice_numbers: Optional[str], @@ -348,7 +348,7 @@ def build_row( variance_all_time = round(total_xero_all_time - total_jm_all_time, 2) return { "date": row_date, - "client_name": client_name, + "company_name": company_name, "job_number": job_number, "job_name": job_name, "invoice_numbers": invoice_numbers, @@ -389,7 +389,7 @@ def get_job_note(job: Optional[Job], match_type: str) -> Optional[str]: rows.append( build_row( row_date=job_invoices[0].date.isoformat(), - client_name=job.client.name, + company_name=job.company.name, job_number=job.job_number, job_name=job.name, invoice_numbers=", ".join(inv.number for inv in job_invoices), @@ -410,7 +410,7 @@ def get_job_note(job: Optional[Job], match_type: str) -> Optional[str]: rows.append( build_row( row_date=invoice.date.isoformat(), - client_name=invoice.client.name, + company_name=invoice.company.name, job_number=None, job_name=None, invoice_numbers=invoice.number, @@ -425,7 +425,7 @@ def get_job_note(job: Optional[Job], match_type: str) -> Optional[str]: for job_id in jobs_with_revenue.keys(): if job_id in jobs_with_invoices: continue - job = Job.objects.select_related("client").get(id=job_id) + job = Job.objects.select_related("company").get(id=job_id) monthly_revenue = float(jobs_with_revenue[job_id]) start_date = job.start_date completion = job.last_financial_activity_date @@ -435,7 +435,7 @@ def get_job_note(job: Optional[Job], match_type: str) -> Optional[str]: rows.append( build_row( row_date=completion.isoformat() if completion else None, - client_name=job.client.name, + company_name=job.company.name, job_number=job.job_number, job_name=job.name, invoice_numbers=None, diff --git a/apps/client/management/commands/merge_clients.py b/apps/client/management/commands/merge_clients.py deleted file mode 100644 index d8377caf0..000000000 --- a/apps/client/management/commands/merge_clients.py +++ /dev/null @@ -1,131 +0,0 @@ -from django.core.management.base import BaseCommand -from django.db import transaction - -from apps.accounts.models import Staff -from apps.client.models import Client -from apps.client.services.client_merge_service import reassign_client_fk_records -from apps.job.models import Job -from apps.workflow.models import CompanyDefaults - - -class Command(BaseCommand): - help = "Merge duplicate clients with the same name" - - def add_arguments(self, parser): - parser.add_argument( - "--name", - type=str, - help="Client name to check for duplicates. If not provided, " - "then raise a value error", - ) - parser.add_argument( - "--auto", - action="store_true", - help="Automatically merge without confirmation", - ) - - def handle(self, *args, **options): - # Determine which client name to look for - client_name = options.get("name") - - if not client_name: - # Use the configured shop client from CompanyDefaults - company_defaults = CompanyDefaults.get_solo() - client_name = company_defaults.shop_client.name - else: - pass # explicit client name provided by caller - - self.stdout.write(f"Looking for duplicate clients with name: '{client_name}'") - - # Find all clients with this name - duplicate_clients = Client.objects.filter(name=client_name).order_by( - "django_created_at" - ) - count = duplicate_clients.count() - - if count == 0: - self.stdout.write( - self.style.WARNING(f"No clients found with name '{client_name}'") - ) - return - elif count == 1: - self.stdout.write( - self.style.SUCCESS( - f"Only one client found with name '{client_name}' - " - f"no duplicates to fix" - ) - ) - return - - self.stdout.write( - self.style.WARNING(f"Found {count} clients with name '{client_name}':") - ) - - # Display information about each client - clients_with_job_counts = [] - for i, client in enumerate(duplicate_clients): - job_count = Job.objects.filter(client=client).count() - clients_with_job_counts.append((client, job_count)) - - self.stdout.write(f"{i + 1}. Client ID: {client.pk}") - self.stdout.write(f" Created: {client.django_created_at}") - self.stdout.write(f" Jobs: {job_count}") - if client.xero_contact_id: - self.stdout.write(f" Xero Contact ID: {client.xero_contact_id}") - - # Sort by job count (descending) and then by creation date (ascending) - clients_with_job_counts.sort(key=lambda x: (-x[1], x[0].django_created_at)) - - primary_client = clients_with_job_counts[0][0] - self.stdout.write( - self.style.SUCCESS( - f"Recommended primary client: {primary_client.pk} " - f"(has {clients_with_job_counts[0][1]} jobs)" - ) - ) - - # Ask for confirmation unless --auto flag is used - if not options["auto"]: - response = input( - "Do you want to merge all duplicates into this client? (yes/no): " - ) - if response.lower() != "yes": - self.stdout.write(self.style.WARNING("Operation cancelled")) - return - - # Merge duplicates - with transaction.atomic(): - for client, job_count in clients_with_job_counts[1:]: - self.stdout.write( - f"Merging client {client.pk} into {primary_client.pk}..." - ) - - # Reassign every client-FK record (Jobs, Invoices, Bills, - # Credit Notes, Quotes, POs, supplier references). The prior - # implementation only moved Jobs; the others ended up orphaned - # on the deleted client via the PROTECT constraint failure - # path — or silently lost on cascade with the old pointer. - counts = reassign_client_fk_records( - client, - primary_client, - Staff.get_automation_user(), - logger_prefix="[manual-merge] ", - ) - self.stdout.write(f" Reassigned records: {counts}") - - # Delete the duplicate client — safe now that every PROTECTed - # FK has been moved onto the primary. - client.delete() - self.stdout.write(f" Deleted duplicate client {client.pk}") - - self.stdout.write( - self.style.SUCCESS( - f"Success! All duplicates merged into client {primary_client.pk}" - ) - ) - self.stdout.write( - self.style.SUCCESS( - f"Total jobs now associated with this client: " - f"{Job.objects.filter(client=primary_client).count()}" - ) - ) diff --git a/apps/client/tests/test_client_jobs_queries.py b/apps/client/tests/test_client_jobs_queries.py deleted file mode 100644 index 754d27f04..000000000 --- a/apps/client/tests/test_client_jobs_queries.py +++ /dev/null @@ -1,31 +0,0 @@ -from __future__ import annotations - -from django.db import connection -from django.test.utils import CaptureQueriesContext - -from apps.client.models import Client -from apps.client.services.client_rest_service import ClientRestService -from apps.job.models import Job -from apps.testing import BaseTestCase - - -class ClientJobsQueryTests(BaseTestCase): - """get_client_jobs reads job.quoted per job; without select_related on - quote that lazy-loads once per job and the dev/E2E n+1 guard raises.""" - - def test_get_client_jobs_does_not_lazy_load_quotes(self) -> None: - client_obj = Client.objects.create( - name="Client Jobs Query Client", - email="client-jobs-queries@example.com", - xero_last_modified="2024-01-01T00:00:00Z", - ) - for name in ("Client Jobs Query Job 1", "Client Jobs Query Job 2"): - job = Job(name=name, client=client_obj) - job.save(staff=self.test_staff) - - with CaptureQueriesContext(connection) as ctx: - jobs = ClientRestService.get_client_jobs(client_obj.id) - - self.assertEqual(len(jobs), 2) - # exists() guard + the jobs query (quote joined in, not lazy-loaded) - self.assertLessEqual(len(ctx.captured_queries), 2) diff --git a/apps/client/urls_rest.py b/apps/client/urls_rest.py deleted file mode 100644 index 3c14fc721..000000000 --- a/apps/client/urls_rest.py +++ /dev/null @@ -1,108 +0,0 @@ -""" -Client REST URLs - -REST URLs for Client module following RESTful patterns: -- Clearly defined endpoints -- Appropriate HTTP verbs -- Consistent structure with other REST modules -""" - -from django.urls import include, path -from rest_framework.routers import DefaultRouter - -from apps.client.views.address_views import AddressValidateView -from apps.client.views.client_contact_method_viewset import ClientContactMethodViewSet -from apps.client.views.client_contact_viewset import ClientContactViewSet -from apps.client.views.client_rest_views import ( - ClientCreateRestView, - ClientJobsRestView, - ClientListAllRestView, - ClientRetrieveRestView, - ClientSearchRestView, - ClientUpdateRestView, - JobContactRestView, -) -from apps.client.views.supplier_pickup_address_viewset import ( - SupplierPickupAddressViewSet, -) -from apps.client.views.supplier_search_alias_views import ( - ClientSupplierAliasListCreateView, - SupplierAliasDetailView, -) - -app_name = "clients_rest" - -# Router for ViewSet-based endpoints -router = DefaultRouter() -router.register( - "contact-methods", - ClientContactMethodViewSet, - basename="client-contact-method", -) -router.register("contacts", ClientContactViewSet, basename="client-contact") -router.register( - "pickup-addresses", SupplierPickupAddressViewSet, basename="supplier-pickup-address" -) - -urlpatterns = [ - # Client list all REST endpoint - path( - "all/", - ClientListAllRestView.as_view(), - name="client_list_all_rest", - ), - # Client creation REST endpoint - path( - "create/", - ClientCreateRestView.as_view(), - name="client_create_rest", - ), - # Client search REST endpoint - path( - "search/", - ClientSearchRestView.as_view(), - name="client_search_rest", - ), - # Client retrieve REST endpoint - path( - "/", - ClientRetrieveRestView.as_view(), - name="client_retrieve_rest", - ), - # Client update REST endpoint - path( - "/update/", - ClientUpdateRestView.as_view(), - name="client_update_rest", - ), - # Client jobs REST endpoint - path( - "/jobs/", - ClientJobsRestView.as_view(), - name="client_jobs_rest", - ), - path( - "/supplier-aliases/", - ClientSupplierAliasListCreateView.as_view(), - name="client_supplier_aliases_rest", - ), - path( - "supplier-aliases//", - SupplierAliasDetailView.as_view(), - name="supplier_alias_detail_rest", - ), - # Job contact REST endpoint - path( - "jobs//contact/", - JobContactRestView.as_view(), - name="job_contact_rest", - ), - # Address validation endpoint - path( - "addresses/validate/", - AddressValidateView.as_view(), - name="address_validate", - ), - # ViewSet routes (contacts CRUD) - path("", include(router.urls)), -] diff --git a/apps/client/__init__.py b/apps/company/__init__.py similarity index 53% rename from apps/client/__init__.py rename to apps/company/__init__.py index 789a287a4..a78879f7c 100644 --- a/apps/client/__init__.py +++ b/apps/company/__init__.py @@ -1,6 +1,6 @@ # This file is autogenerated by update_init.py script -from .apps import ClientConfig +from .apps import CompanyConfig from .utils import date_to_datetime # Conditional imports (only when Django is ready) @@ -9,10 +9,10 @@ if apps.ready: from .models import ( - Client, ClientContact, ClientContactMethod, - ClientQuerySet, + Company, + CompanyQuerySet, PhoneAssignmentConflictError, Supplier, SupplierPickupAddress, @@ -21,20 +21,20 @@ from .serializers import ( ClientContactMethodSerializer, ClientContactSerializer, - ClientCreateResponseSerializer, - ClientCreateSerializer, - ClientDetailResponseSerializer, - ClientDuplicateErrorResponseSerializer, - ClientErrorResponseSerializer, - ClientJobHeaderSerializer, - ClientJobsResponseSerializer, - ClientListResponseSerializer, - ClientNameOnlySerializer, - ClientSearchResponseSerializer, - ClientSearchResultSerializer, - ClientSerializer, - ClientUpdateResponseSerializer, - ClientUpdateSerializer, + CompanyCreateResponseSerializer, + CompanyCreateSerializer, + CompanyDetailResponseSerializer, + CompanyDuplicateErrorResponseSerializer, + CompanyErrorResponseSerializer, + CompanyJobHeaderSerializer, + CompanyJobsResponseSerializer, + CompanyListResponseSerializer, + CompanyNameOnlySerializer, + CompanySearchResponseSerializer, + CompanySearchResultSerializer, + CompanySerializer, + CompanyUpdateResponseSerializer, + CompanyUpdateSerializer, JobContactResponseSerializer, JobContactUpdateSerializer, StandardErrorSerializer, @@ -47,27 +47,27 @@ pass __all__ = [ - "Client", - "ClientConfig", "ClientContact", "ClientContactMethod", "ClientContactMethodSerializer", "ClientContactSerializer", - "ClientCreateResponseSerializer", - "ClientCreateSerializer", - "ClientDetailResponseSerializer", - "ClientDuplicateErrorResponseSerializer", - "ClientErrorResponseSerializer", - "ClientJobHeaderSerializer", - "ClientJobsResponseSerializer", - "ClientListResponseSerializer", - "ClientNameOnlySerializer", - "ClientQuerySet", - "ClientSearchResponseSerializer", - "ClientSearchResultSerializer", - "ClientSerializer", - "ClientUpdateResponseSerializer", - "ClientUpdateSerializer", + "Company", + "CompanyConfig", + "CompanyCreateResponseSerializer", + "CompanyCreateSerializer", + "CompanyDetailResponseSerializer", + "CompanyDuplicateErrorResponseSerializer", + "CompanyErrorResponseSerializer", + "CompanyJobHeaderSerializer", + "CompanyJobsResponseSerializer", + "CompanyListResponseSerializer", + "CompanyNameOnlySerializer", + "CompanyQuerySet", + "CompanySearchResponseSerializer", + "CompanySearchResultSerializer", + "CompanySerializer", + "CompanyUpdateResponseSerializer", + "CompanyUpdateSerializer", "JobContactResponseSerializer", "JobContactUpdateSerializer", "PhoneAssignmentConflictError", diff --git a/apps/client/admin.py b/apps/company/admin.py similarity index 100% rename from apps/client/admin.py rename to apps/company/admin.py diff --git a/apps/client/apps.py b/apps/company/apps.py similarity index 61% rename from apps/client/apps.py rename to apps/company/apps.py index c221d32c0..cd2ead460 100644 --- a/apps/client/apps.py +++ b/apps/company/apps.py @@ -1,6 +1,6 @@ from django.apps import AppConfig -class ClientConfig(AppConfig): +class CompanyConfig(AppConfig): default_auto_field = "django.db.models.BigAutoField" - name = "apps.client" + name = "apps.company" diff --git a/apps/company/management/commands/merge_companies.py b/apps/company/management/commands/merge_companies.py new file mode 100644 index 000000000..467d77d83 --- /dev/null +++ b/apps/company/management/commands/merge_companies.py @@ -0,0 +1,133 @@ +from django.core.management.base import BaseCommand +from django.db import transaction + +from apps.accounts.models import Staff +from apps.company.models import Company +from apps.company.services.company_merge_service import reassign_company_fk_records +from apps.job.models import Job +from apps.workflow.models import CompanyDefaults + + +class Command(BaseCommand): + help = "Merge duplicate companies with the same name" + + def add_arguments(self, parser): + parser.add_argument( + "--name", + type=str, + help="Company name to check for duplicates. If not provided, " + "then raise a value error", + ) + parser.add_argument( + "--auto", + action="store_true", + help="Automatically merge without confirmation", + ) + + def handle(self, *args, **options): + # Determine which company name to look for + company_name = options.get("name") + + if not company_name: + # Use the configured shop company from CompanyDefaults + company_defaults = CompanyDefaults.get_solo() + company_name = company_defaults.shop_company.name + else: + pass # explicit company name provided by caller + + self.stdout.write( + f"Looking for duplicate companies with name: '{company_name}'" + ) + + # Find all companies with this name + duplicate_companies = Company.objects.filter(name=company_name).order_by( + "django_created_at" + ) + count = duplicate_companies.count() + + if count == 0: + self.stdout.write( + self.style.WARNING(f"No companies found with name '{company_name}'") + ) + return + elif count == 1: + self.stdout.write( + self.style.SUCCESS( + f"Only one company found with name '{company_name}' - " + f"no duplicates to fix" + ) + ) + return + + self.stdout.write( + self.style.WARNING(f"Found {count} companies with name '{company_name}':") + ) + + # Display information about each company + companies_with_job_counts = [] + for i, company in enumerate(duplicate_companies): + job_count = Job.objects.filter(company=company).count() + companies_with_job_counts.append((company, job_count)) + + self.stdout.write(f"{i + 1}. Company ID: {company.pk}") + self.stdout.write(f" Created: {company.django_created_at}") + self.stdout.write(f" Jobs: {job_count}") + if company.xero_contact_id: + self.stdout.write(f" Xero Contact ID: {company.xero_contact_id}") + + # Sort by job count (descending) and then by creation date (ascending) + companies_with_job_counts.sort(key=lambda x: (-x[1], x[0].django_created_at)) + + primary_company = companies_with_job_counts[0][0] + self.stdout.write( + self.style.SUCCESS( + f"Recommended primary company: {primary_company.pk} " + f"(has {companies_with_job_counts[0][1]} jobs)" + ) + ) + + # Ask for confirmation unless --auto flag is used + if not options["auto"]: + response = input( + "Do you want to merge all duplicates into this company? (yes/no): " + ) + if response.lower() != "yes": + self.stdout.write(self.style.WARNING("Operation cancelled")) + return + + # Merge duplicates + with transaction.atomic(): + for company, job_count in companies_with_job_counts[1:]: + self.stdout.write( + f"Merging company {company.pk} into {primary_company.pk}..." + ) + + # Reassign every company-FK record (Jobs, Invoices, Bills, + # Credit Notes, Quotes, POs, supplier references). The prior + # implementation only moved Jobs; the others ended up orphaned + # on the deleted company via the PROTECT constraint failure + # path — or silently lost on cascade with the old pointer. + counts = reassign_company_fk_records( + company, + primary_company, + Staff.get_automation_user(), + logger_prefix="[manual-merge] ", + ) + self.stdout.write(f" Reassigned records: {counts}") + + # Delete the duplicate company — safe now that every PROTECTed + # FK has been moved onto the primary. + company.delete() + self.stdout.write(f" Deleted duplicate company {company.pk}") + + self.stdout.write( + self.style.SUCCESS( + f"Success! All duplicates merged into company {primary_company.pk}" + ) + ) + self.stdout.write( + self.style.SUCCESS( + f"Total jobs now associated with this company: " + f"{Job.objects.filter(company=primary_company).count()}" + ) + ) diff --git a/apps/client/migrations/0001_baseline.py b/apps/company/migrations/0001_baseline.py similarity index 98% rename from apps/client/migrations/0001_baseline.py rename to apps/company/migrations/0001_baseline.py index eff64690a..4746dd232 100644 --- a/apps/client/migrations/0001_baseline.py +++ b/apps/company/migrations/0001_baseline.py @@ -99,7 +99,7 @@ class Migration(migrations.Migration): null=True, on_delete=django.db.models.deletion.SET_NULL, related_name="merged_from_clients", - to="client.client", + to="company.client", ), ), ], @@ -116,7 +116,7 @@ class Migration(migrations.Migration): "indexes": [], "constraints": [], }, - bases=("client.client",), + bases=("company.client",), ), migrations.CreateModel( name="ClientContact", @@ -184,7 +184,7 @@ class Migration(migrations.Migration): help_text="The client this contact belongs to", on_delete=django.db.models.deletion.CASCADE, related_name="contacts", - to="client.client", + to="company.client", ), ), ], @@ -233,7 +233,7 @@ class Migration(migrations.Migration): null=True, on_delete=django.db.models.deletion.CASCADE, related_name="contact_methods", - to="client.client", + to="company.client", ), ), ( @@ -243,7 +243,7 @@ class Migration(migrations.Migration): null=True, on_delete=django.db.models.deletion.CASCADE, related_name="contact_methods", - to="client.clientcontact", + to="company.clientcontact", ), ), ], @@ -377,7 +377,7 @@ class Migration(migrations.Migration): help_text="The supplier this pickup address belongs to", on_delete=django.db.models.deletion.CASCADE, related_name="pickup_addresses", - to="client.client", + to="company.client", ), ), ], @@ -408,7 +408,7 @@ class Migration(migrations.Migration): models.ForeignKey( on_delete=django.db.models.deletion.CASCADE, related_name="supplier_search_aliases", - to="client.client", + to="company.client", ), ), ], diff --git a/apps/company/migrations/0002_rename_client_company.py b/apps/company/migrations/0002_rename_client_company.py new file mode 100644 index 000000000..345162bcc --- /dev/null +++ b/apps/company/migrations/0002_rename_client_company.py @@ -0,0 +1,202 @@ +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("company", "0001_baseline"), + # Every app below has a baseline migration with a lazy FK reference + # to "company.client" (the pre-rename model name). RenameModel only + # repoints references already present in the in-memory state at the + # moment it runs; without these edges the topological sort is free to + # schedule this migration before those apps' baselines are replayed, + # leaving their FKs pointing at a model name that no longer resolves. + ("accounting", "0002_baseline"), + ("crm", "0001_baseline"), + ("job", "0001_baseline"), + ("purchasing", "0001_baseline"), + ("quoting", "0001_baseline"), + ("workflow", "0001_baseline"), + ] + + operations = [ + # RenameModel repoints FK/M2M references to the renamed model but does + # not rewrite the `bases` tuple of proxy models that inherit from it + # (Supplier(Client) -> would still record bases=("company.client",) + # after the rename, which no longer resolves). Worse, the contenttypes + # app injects a RenameContentType operation IMMEDIATELY after every + # RenameModel at migrate time, and that operation renders the full + # project state right there. So the proxy must be gone from state + # BEFORE the rename and recreated on the new base AFTER it. A proxy + # has no physical table (allow_migrate_model is False), so the + # Delete+Create pair is a state-only no-op against the database. + migrations.DeleteModel(name="Supplier"), + migrations.RenameModel(old_name="Client", new_name="Company"), + migrations.CreateModel( + name="Supplier", + fields=[], + options={ + "proxy": True, + "indexes": [], + "constraints": [], + }, + bases=("company.company",), + ), + migrations.RenameIndex( + model_name="company", + old_name="client_name_fts_idx", + new_name="company_name_fts_idx", + ), + migrations.RenameIndex( + model_name="company", + old_name="client_name_trgm_idx", + new_name="company_name_trgm_idx", + ), + migrations.AlterField( + model_name="company", + name="merged_into", + field=models.ForeignKey( + blank=True, + help_text="The company this was merged into", + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="merged_from_companies", + to="company.company", + ), + ), + # Constraints referencing a field must be removed (using the old field + # name they were defined with) BEFORE the field is renamed, and any + # replacement added AFTER: RenameField does not rewrite field names + # embedded in Q() conditions on other constraints, so removing a + # conditioned constraint after the rename fails to resolve the old + # field name against the post-rename model state. + migrations.RemoveConstraint( + model_name="clientcontact", + name="unique_client_contact_name", + ), + migrations.RenameField( + model_name="clientcontact", + old_name="client", + new_name="company", + ), + migrations.AddConstraint( + model_name="clientcontact", + constraint=models.UniqueConstraint( + fields=("company", "name"), name="unique_company_contact_name" + ), + ), + migrations.RemoveConstraint( + model_name="clientcontactmethod", + name="client_contact_method_one_owner", + ), + migrations.RemoveConstraint( + model_name="clientcontactmethod", + name="unique_client_contact_method_value", + ), + migrations.RemoveConstraint( + model_name="clientcontactmethod", + name="unique_client_primary_contact_method", + ), + # These two constraints keep their names across the rename (they were + # never named after "client"), but their conditions embed the old + # field name too, so they need the same remove-before/add-after + # treatment as the constraints above. + migrations.RemoveConstraint( + model_name="clientcontactmethod", + name="unique_contact_contact_method_value", + ), + migrations.RemoveConstraint( + model_name="clientcontactmethod", + name="unique_contact_primary_contact_method", + ), + migrations.RenameField( + model_name="clientcontactmethod", + old_name="client", + new_name="company", + ), + migrations.AddConstraint( + model_name="clientcontactmethod", + constraint=models.CheckConstraint( + condition=( + models.Q(("company__isnull", False), ("contact__isnull", True)) + | models.Q(("company__isnull", True), ("contact__isnull", False)) + ), + name="contact_method_one_owner", + ), + ), + migrations.AddConstraint( + model_name="clientcontactmethod", + constraint=models.UniqueConstraint( + condition=models.Q( + ("company__isnull", False), ("contact__isnull", True) + ), + fields=("company", "method_type", "normalized_value"), + name="unique_company_contact_method_value", + ), + ), + migrations.AddConstraint( + model_name="clientcontactmethod", + constraint=models.UniqueConstraint( + condition=models.Q( + ("company__isnull", False), + ("contact__isnull", True), + ("is_primary", True), + ), + fields=("company", "method_type"), + name="unique_company_primary_contact_method", + ), + ), + migrations.AddConstraint( + model_name="clientcontactmethod", + constraint=models.UniqueConstraint( + condition=models.Q( + ("company__isnull", True), ("contact__isnull", False) + ), + fields=("contact", "method_type", "normalized_value"), + name="unique_contact_contact_method_value", + ), + ), + migrations.AddConstraint( + model_name="clientcontactmethod", + constraint=models.UniqueConstraint( + condition=models.Q( + ("company__isnull", True), + ("contact__isnull", False), + ("is_primary", True), + ), + fields=("contact", "method_type"), + name="unique_contact_primary_contact_method", + ), + ), + migrations.RemoveConstraint( + model_name="suppliersearchalias", + name="unique_supplier_search_alias_per_client", + ), + migrations.RenameField( + model_name="suppliersearchalias", + old_name="client", + new_name="company", + ), + migrations.AddConstraint( + model_name="suppliersearchalias", + constraint=models.UniqueConstraint( + fields=("company", "alias"), + name="unique_supplier_search_alias_per_company", + ), + ), + migrations.RemoveConstraint( + model_name="supplierpickupaddress", + name="unique_supplier_pickup_address_name", + ), + migrations.RenameField( + model_name="supplierpickupaddress", + old_name="client", + new_name="company", + ), + migrations.AddConstraint( + model_name="supplierpickupaddress", + constraint=models.UniqueConstraint( + fields=("company", "name"), name="unique_supplier_pickup_address_name" + ), + ), + ] diff --git a/apps/company/migrations/0003_alter_clientcontact_company_and_more.py b/apps/company/migrations/0003_alter_clientcontact_company_and_more.py new file mode 100644 index 000000000..8054de51a --- /dev/null +++ b/apps/company/migrations/0003_alter_clientcontact_company_and_more.py @@ -0,0 +1,58 @@ +# Generated by Django 6.0.4 on 2026-07-06 10:20 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("company", "0002_rename_client_company"), + ] + + operations = [ + migrations.AlterField( + model_name="clientcontact", + name="company", + field=models.ForeignKey( + help_text="The company this contact belongs to", + on_delete=django.db.models.deletion.CASCADE, + related_name="contacts", + to="company.company", + ), + ), + migrations.AlterField( + model_name="clientcontact", + name="is_primary", + field=models.BooleanField( + default=False, + help_text="Indicates if this is the primary contact for the company", + ), + ), + migrations.AlterField( + model_name="company", + name="allow_jobs", + field=models.BooleanField( + default=True, + help_text="If False, this company cannot be selected as the company on a Job. Use for Xero contacts that must exist (tax authorities, internal accounts, etc.) but should never appear on a job. Automatically set to False when a company is archived or merged in Xero.", + ), + ), + migrations.AlterField( + model_name="company", + name="xero_archived", + field=models.BooleanField( + default=False, + help_text="Indicates if this company has been archived/merged in Xero", + ), + ), + migrations.AlterField( + model_name="company", + name="xero_merged_into_id", + field=models.CharField( + blank=True, + help_text="The Xero contact ID this company was merged into (temporary storage)", + max_length=255, + null=True, + ), + ), + ] diff --git a/apps/client/migrations/__init__.py b/apps/company/migrations/__init__.py similarity index 100% rename from apps/client/migrations/__init__.py rename to apps/company/migrations/__init__.py diff --git a/apps/client/models.py b/apps/company/models.py similarity index 79% rename from apps/client/models.py rename to apps/company/models.py index 80fa138ee..567cb4f71 100644 --- a/apps/client/models.py +++ b/apps/company/models.py @@ -42,8 +42,8 @@ def _augment_update_fields( return names -class ClientQuerySet(models.QuerySet): - """Custom queryset for Client with precomputed invoice aggregates.""" +class CompanyQuerySet(models.QuerySet): + """Custom queryset for Company with precomputed invoice aggregates.""" def with_invoice_summary(self): output = models.DecimalField(max_digits=12, decimal_places=2) @@ -57,21 +57,21 @@ def with_invoice_summary(self): ) -class Client(models.Model): - # CHECKLIST - when adding a new field or property to Client, check these locations: - # 1. CLIENT_DIRECT_FIELDS below (if it's a model field) - # 2. _format_client_detail() in apps/client/services/client_rest_service.py - # 3. _format_client_summary() in apps/client/services/client_rest_service.py (subset for lists) - # 4. get_client_for_xero() in this file (Xero API format) - # 5. update_client_from_raw_json() in apps/workflow/api/xero/reprocess_xero.py (Xero-sourced fields only) - # 6. _update_client_in_xero() in apps/client/services/client_rest_service.py (Xero API format) - # 7. ClientDetailResponseSerializer in apps/client/serializers.py - # 8. ClientSearchResultSerializer in apps/client/serializers.py (subset for lists) +class Company(models.Model): + # CHECKLIST - when adding a new field or property to Company, check these locations: + # 1. COMPANY_DIRECT_FIELDS below (if it's a model field) + # 2. _format_company_detail() in apps/company/services/company_rest_service.py + # 3. _format_company_summary() in apps/company/services/company_rest_service.py (subset for lists) + # 4. get_company_for_xero() in this file (Xero API format) + # 5. update_company_from_raw_json() in apps/workflow/api/xero/reprocess_xero.py (Xero-sourced fields only) + # 6. _update_company_in_xero() in apps/company/services/company_rest_service.py (Xero API format) + # 7. CompanyDetailResponseSerializer in apps/company/serializers.py + # 8. CompanySearchResultSerializer in apps/company/serializers.py (subset for lists) - objects = ClientQuerySet.as_manager() + objects = CompanyQuerySet.as_manager() # Direct scalar model fields (not related objects, not properties). - CLIENT_DIRECT_FIELDS = [ + COMPANY_DIRECT_FIELDS = [ "name", "email", "address", @@ -107,14 +107,14 @@ class Client(models.Model): ) # Account vs cash customer flag is_supplier = models.BooleanField( default=False - ) # Indicates if this client is also a supplier + ) # Indicates if this company is also a supplier allow_jobs = models.BooleanField( default=True, help_text=( - "If False, this client cannot be selected as the client on a Job. " + "If False, this company cannot be selected as the company on a Job. " "Use for Xero contacts that must exist (tax authorities, internal " "accounts, etc.) but should never appear on a job. Automatically " - "set to False when a client is archived or merged in Xero." + "set to False when a company is archived or merged in Xero." ), ) xero_last_modified = models.DateTimeField(null=False, blank=False) @@ -135,24 +135,24 @@ class Client(models.Model): xero_last_synced = models.DateTimeField(null=True, blank=True, default=timezone.now) - # Fields to track merged clients in Xero + # Fields to track merged companies in Xero xero_archived = models.BooleanField( default=False, - help_text="Indicates if this client has been archived/merged in Xero", + help_text="Indicates if this company has been archived/merged in Xero", ) xero_merged_into_id = models.CharField( max_length=255, null=True, blank=True, - help_text="The Xero contact ID this client was merged into (temporary storage)", + help_text="The Xero contact ID this company was merged into (temporary storage)", ) merged_into = models.ForeignKey( "self", on_delete=models.SET_NULL, null=True, blank=True, - related_name="merged_from_clients", - help_text="The client this was merged into", + related_name="merged_from_companies", + help_text="The company this was merged into", ) class Meta: @@ -160,11 +160,11 @@ class Meta: indexes = [ GinIndex( SearchVector("name", config="english"), - name="client_name_fts_idx", + name="company_name_fts_idx", ), GinIndex( fields=["name"], - name="client_name_trgm_idx", + name="company_name_trgm_idx", opclasses=["gin_trgm_ops"], ), ] @@ -178,11 +178,11 @@ def save(self, *args, **kwargs): def validate_for_xero(self): """ - Validate if the client data is sufficient to sync to Xero. + Validate if the company data is sufficient to sync to Xero. Only name is required by Xero. """ if not self.name: - logger.error(f"Client {self.id} does not have a valid name.") + logger.error(f"Company {self.id} does not have a valid name.") return False return True @@ -190,8 +190,8 @@ def validate_for_xero(self): def last_invoice_date(self): if "_last_invoice_date" not in self.__dict__: raise RuntimeError( - "Client.last_invoice_date requires " - "Client.objects.with_invoice_summary()." + "Company.last_invoice_date requires " + "Company.objects.with_invoice_summary()." ) return self.__dict__["_last_invoice_date"] @@ -203,7 +203,7 @@ def last_invoice_date(self, value): def total_spend(self): if "_total_spend" not in self.__dict__: raise RuntimeError( - "Client.total_spend requires Client.objects.with_invoice_summary()." + "Company.total_spend requires Company.objects.with_invoice_summary()." ) return self.__dict__["_total_spend"] @@ -211,7 +211,7 @@ def total_spend(self): def total_spend(self, value): self.__dict__["_total_spend"] = value - def get_client_for_xero(self): + def get_company_for_xero(self): """ Build a xero_python.accounting.models.Contact for syncing to Xero. @@ -221,7 +221,7 @@ def get_client_for_xero(self): """ if not self.name: raise ValueError( - f"Client {self.id} is missing a name, which is required for Xero." + f"Company {self.id} is missing a name, which is required for Xero." ) primary_phone = self.primary_phone_value() @@ -247,7 +247,7 @@ def get_client_for_xero(self): ) def primary_phone_value(self) -> str: - """The client's own primary phone number, or "" when it has none. + """The company's own primary phone number, or "" when it has none. Uses the same ordering as every other primary-phone consumer: primary first, then label/value as a stable tie-break. @@ -261,10 +261,10 @@ def primary_phone_value(self) -> str: ) return method.value if method else "" - def get_final_client(self): + def get_final_company(self): """ - Follow the merge chain to get the final client. - If this client was merged into another, return that client + Follow the merge chain to get the final company. + If this company was merged into another, return that company (following the chain). Otherwise return self. """ @@ -273,7 +273,7 @@ def get_final_client(self): while current.merged_into: if current.merged_into.id in seen: - logger.warning(f"Circular merge chain detected for client {self.id}") + logger.warning(f"Circular merge chain detected for company {self.id}") break seen.add(current.merged_into.id) current = current.merged_into @@ -283,24 +283,24 @@ def get_final_client(self): class ClientContact(models.Model): """ - Represents a contact person for a client. + Represents a contact person for a company. This model stores contact information that was previously synced with Xero but is now managed entirely within our application. """ # CHECKLIST - when adding a new field or property to ClientContact, check these locations: # 1. CLIENTCONTACT_API_FIELDS or CLIENTCONTACT_INTERNAL_FIELDS below (if it's a model field) - # 2. ClientContactSerializer in apps/client/serializers.py (uses CLIENTCONTACT_API_FIELDS) + # 2. ClientContactSerializer in apps/company/serializers.py (uses CLIENTCONTACT_API_FIELDS) # 3. ClientContactSerializer.to_internal_value() nullable_fields list (converts "" → None) - # 4. JobContactResponseSerializer in apps/client/serializers.py (subset for job context) - # 5. ClientContactViewSet in apps/client/views/client_contact_viewset.py (CRUD operations) + # 4. JobContactResponseSerializer in apps/company/serializers.py (subset for job context) + # 5. ClientContactViewSet in apps/company/views/contact_viewset.py (CRUD operations) # 6. Job.contact FK in apps/job/models/job.py (relationship to ClientContact) # 7. reprocess_xero.py in apps/workflow/api/xero/ (Xero sync creates contacts) # # Database fields exposed via API serializers CLIENTCONTACT_API_FIELDS = [ "id", - "client", + "company", "name", "email", "position", @@ -318,11 +318,11 @@ class ClientContact(models.Model): CLIENTCONTACT_ALL_FIELDS = CLIENTCONTACT_API_FIELDS + CLIENTCONTACT_INTERNAL_FIELDS id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) - client = models.ForeignKey( - Client, + company = models.ForeignKey( + Company, on_delete=models.CASCADE, related_name="contacts", - help_text="The client this contact belongs to", + help_text="The company this contact belongs to", ) name = models.CharField(max_length=255, help_text="Full name of the contact person") email = models.EmailField( @@ -336,7 +336,7 @@ class ClientContact(models.Model): ) is_primary = models.BooleanField( default=False, - help_text="Indicates if this is the primary contact for the client", + help_text="Indicates if this is the primary contact for the company", ) notes = models.TextField( null=True, blank=True, help_text="Additional notes about this contact" @@ -354,13 +354,13 @@ class Meta: verbose_name_plural = "Client Contacts" constraints = [ models.UniqueConstraint( - fields=["client", "name"], - name="unique_client_contact_name", + fields=["company", "name"], + name="unique_company_contact_name", ), ] def __str__(self): - return f"{self.name} ({self.client.name})" + return f"{self.name} ({self.company.name})" def save( self, @@ -373,9 +373,9 @@ def save( update_fields = _augment_update_fields(update_fields, "updated_at") # If this contact is being set as primary, ensure no other contacts - # for this client are marked as primary + # for this company are marked as primary if self.is_primary: - ClientContact.objects.filter(client=self.client, is_primary=True).exclude( + ClientContact.objects.filter(company=self.company, is_primary=True).exclude( id=self.id ).update(is_primary=False) super().save( @@ -387,10 +387,10 @@ def save( class PhoneAssignmentConflictError(Exception): - """A phone number cannot be assigned to the proposed client/contact owner. + """A phone number cannot be assigned to the proposed company/contact owner. ``conflict`` is the existing :class:`ClientContactMethod` owned by a - different effective client, or ``None`` when the number is an active + different effective company, or ``None`` when the number is an active internal :class:`~apps.crm.models.PhoneEndpoint`. """ @@ -404,7 +404,7 @@ def __init__(self, conflict: "ClientContactMethod | None") -> None: class ClientContactMethod(models.Model): - """Phone or email address owned by a client or one of its contacts.""" + """Phone or email address owned by a company or one of its contacts.""" class MethodType(models.TextChoices): PHONE = "phone", "Phone" @@ -415,8 +415,8 @@ class Source(models.TextChoices): LOCAL = "local", "Local" id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) - client = models.ForeignKey( - Client, + company = models.ForeignKey( + Company, on_delete=models.CASCADE, null=True, blank=True, @@ -449,34 +449,34 @@ class Meta: constraints = [ models.CheckConstraint( condition=( - models.Q(client__isnull=False, contact__isnull=True) - | models.Q(client__isnull=True, contact__isnull=False) + models.Q(company__isnull=False, contact__isnull=True) + | models.Q(company__isnull=True, contact__isnull=False) ), - name="client_contact_method_one_owner", + name="contact_method_one_owner", ), models.UniqueConstraint( - fields=["client", "method_type", "normalized_value"], - condition=models.Q(client__isnull=False, contact__isnull=True), - name="unique_client_contact_method_value", + fields=["company", "method_type", "normalized_value"], + condition=models.Q(company__isnull=False, contact__isnull=True), + name="unique_company_contact_method_value", ), models.UniqueConstraint( fields=["contact", "method_type", "normalized_value"], - condition=models.Q(client__isnull=True, contact__isnull=False), + condition=models.Q(company__isnull=True, contact__isnull=False), name="unique_contact_contact_method_value", ), models.UniqueConstraint( - fields=["client", "method_type"], + fields=["company", "method_type"], condition=models.Q( - client__isnull=False, + company__isnull=False, contact__isnull=True, is_primary=True, ), - name="unique_client_primary_contact_method", + name="unique_company_primary_contact_method", ), models.UniqueConstraint( fields=["contact", "method_type"], condition=models.Q( - client__isnull=True, + company__isnull=True, contact__isnull=False, is_primary=True, ), @@ -485,7 +485,7 @@ class Meta: ] def __str__(self) -> str: - owner = self.contact or self.client + owner = self.contact or self.company return f"{self.method_type}: {self.value} ({owner})" def save( @@ -509,7 +509,7 @@ def save( try: type(self).check_phone_assignment( self.normalized_value, - client_id=self.client_id, + company_id=self.company_id, contact=self.contact, instance=self, using=db, @@ -518,7 +518,7 @@ def save( if exc.conflict is None: raise ValidationError( "internal phone endpoint cannot be saved as a " - "client contact method" + "company contact method" ) from exc raise ValidationError( "phone number already belongs to " @@ -538,7 +538,7 @@ def save( queryset = queryset.filter(contact_id=self.contact_id) else: queryset = queryset.filter( - client_id=self.client_id, contact__isnull=True + company_id=self.company_id, contact__isnull=True ) queryset.update(is_primary=False) @@ -568,31 +568,31 @@ def normalize_phone(value: str | None) -> str: return f"+64{digits[1:]}" return f"+{digits}" - def owner_client_id(self) -> uuid.UUID | None: - """The effective client that owns this method (directly or via its contact).""" - if self.client_id: - return self.client_id + def owner_company_id(self) -> uuid.UUID | None: + """The effective company that owns this method (directly or via its contact).""" + if self.company_id: + return self.company_id if self.contact is not None: - return self.contact.client_id + return self.contact.company_id return None @classmethod - def conflicting_client( + def conflicting_company( cls, normalized_value: str, - effective_client_id: uuid.UUID | None, + effective_company_id: uuid.UUID | None, exclude_id: uuid.UUID | None = None, using: str = DEFAULT_DB_ALIAS, ) -> "ClientContactMethod | None": - """A phone method for this number owned by a *different* effective client. + """A phone method for this number owned by a *different* effective company. - The single source of truth for the "one number, one client" rule, shared by + The single source of truth for the "one number, one company" rule, shared by the save() guard, the serializer, assign_phone_number, and the Data Quality report. Phones only; emails are unaffected. """ queryset = ( cls.objects.using(using) - .select_related("client", "contact") + .select_related("company", "contact") .filter( method_type=cls.MethodType.PHONE, normalized_value=normalized_value, @@ -604,7 +604,7 @@ def conflicting_client( ( other for other in queryset - if other.owner_client_id() != effective_client_id + if other.owner_company_id() != effective_company_id ), None, ) @@ -614,23 +614,23 @@ def check_phone_assignment( cls, normalized_value: str, *, - client_id: uuid.UUID | None, + company_id: uuid.UUID | None, contact: "ClientContact | None", instance: "ClientContactMethod | None" = None, using: str = DEFAULT_DB_ALIAS, ) -> None: - """Enforce the one-number-one-client rule for a proposed phone owner. + """Enforce the one-number-one-company rule for a proposed phone owner. Shared by :meth:`save` and the API serializer so both surfaces apply identical semantics: - Grandfathering: when ``instance`` is an existing row whose stored number and owner match the proposal, no check runs, so legacy - cross-client numbers can be re-saved (label/primary edits, re-sync) + cross-company numbers can be re-saved (label/primary edits, re-sync) without raising. - An active internal :class:`~apps.crm.models.PhoneEndpoint` can never - be a client number. - - A number owned by a different effective client is rejected. + be a company number. + - A number owned by a different effective company is rejected. Raises: PhoneAssignmentConflictError: carrying the conflicting method, or @@ -641,13 +641,13 @@ def check_phone_assignment( stored = ( cls.objects.using(using) .filter(pk=instance.pk) - .values("normalized_value", "client_id", "contact_id") + .values("normalized_value", "company_id", "contact_id") .first() ) if ( stored is not None and stored["normalized_value"] == normalized_value - and stored["client_id"] == client_id + and stored["company_id"] == company_id and stored["contact_id"] == contact_id ): return # unchanged association -> grandfathered @@ -661,15 +661,15 @@ def check_phone_assignment( ): raise PhoneAssignmentConflictError(None) - if client_id is not None: - effective_client_id = client_id + if company_id is not None: + effective_company_id = company_id elif contact is not None: - effective_client_id = contact.client_id + effective_company_id = contact.company_id else: - effective_client_id = None - conflict = cls.conflicting_client( + effective_company_id = None + conflict = cls.conflicting_company( normalized_value, - effective_client_id, + effective_company_id, exclude_id=instance.pk if instance is not None else None, using=using, ) @@ -678,28 +678,27 @@ def check_phone_assignment( def owner_display_name(self) -> str: if self.contact: - return f"contact {self.contact.name} at {self.contact.client.name}" - if self.client: - return f"client {self.client.name}" + return f"contact {self.contact.name} at {self.contact.company.name}" + if self.company: + return f"company {self.company.name}" return "another CRM owner" -class Supplier(Client): +class Supplier(Company): """ - A Supplier is simply a Client with additional semantics. + A Supplier is simply a Company with additional semantics. """ class Meta: proxy = True - db_table = "client_client" class SupplierSearchAlias(models.Model): - """Editable search alias attached to a client/supplier contact.""" + """Editable search alias attached to a company/supplier contact.""" id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) - client = models.ForeignKey( - Client, + company = models.ForeignKey( + Company, on_delete=models.CASCADE, related_name="supplier_search_aliases", ) @@ -712,20 +711,20 @@ class Meta: ordering = ["alias"] constraints = [ models.UniqueConstraint( - fields=["client", "alias"], - name="unique_supplier_search_alias_per_client", + fields=["company", "alias"], + name="unique_supplier_search_alias_per_company", ), ] def __str__(self): - return f"{self.alias} ({self.client.name})" + return f"{self.alias} ({self.company.name})" class SupplierPickupAddress(models.Model): """ - Represents a pickup/delivery address for a supplier or client. + Represents a pickup/delivery address for a supplier or company. - Despite the name, this model can be used for any Client - not just those + Despite the name, this model can be used for any Company - not just those marked as suppliers. Suppliers can have multiple pickup addresses, with one marked as primary. Useful for tracking warehouse locations, branch offices, or delivery points. @@ -733,9 +732,9 @@ class SupplierPickupAddress(models.Model): # CHECKLIST - when adding a new field or property to SupplierPickupAddress, check: # 1. SUPPLIERPICKUPADDRESS_API_FIELDS below (if it's a model field) - # 2. SupplierPickupAddressSerializer in apps/client/serializers.py + # 2. SupplierPickupAddressSerializer in apps/company/serializers.py # 3. SupplierPickupAddressSerializer.to_internal_value() nullable_fields list - # 4. SupplierPickupAddressViewSet in apps/client/views/supplier_pickup_address_viewset.py + # 4. SupplierPickupAddressViewSet in apps/company/views/supplier_pickup_address_viewset.py # 5. PurchaseOrder.pickup_address FK in apps/purchasing/models.py # 6. PurchaseOrderDetailSerializer in apps/purchasing/serializers.py # 7. PurchaseOrderPDFGenerator in apps/purchasing/services/purchase_order_pdf_service.py @@ -743,7 +742,7 @@ class SupplierPickupAddress(models.Model): # Database fields exposed via API serializers SUPPLIERPICKUPADDRESS_API_FIELDS = [ "id", - "client", + "company", "name", "street", "suburb", @@ -770,8 +769,8 @@ class SupplierPickupAddress(models.Model): ) id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) - client = models.ForeignKey( - Client, + company = models.ForeignKey( + Company, on_delete=models.CASCADE, related_name="pickup_addresses", help_text="The supplier this pickup address belongs to", @@ -852,13 +851,13 @@ class Meta: verbose_name_plural = "Supplier Pickup Addresses" constraints = [ models.UniqueConstraint( - fields=["client", "name"], + fields=["company", "name"], name="unique_supplier_pickup_address_name", ), ] def __str__(self): - return f"{self.name} - {self.city} ({self.client.name})" + return f"{self.name} - {self.city} ({self.company.name})" @property def formatted_address(self) -> str: @@ -877,13 +876,9 @@ def formatted_address(self) -> str: def save(self, *args, **kwargs): # If this address is being set as primary, ensure no other addresses - # for this client are marked as primary + # for this company are marked as primary if self.is_primary: SupplierPickupAddress.objects.filter( - client=self.client, is_primary=True + company=self.company, is_primary=True ).exclude(id=self.id).update(is_primary=False) super().save(*args, **kwargs) - - -# Alias for SupplierPickupAddress - can be used for any client, not just suppliers -ClientDeliveryAddress = SupplierPickupAddress diff --git a/apps/client/serializers.py b/apps/company/serializers.py similarity index 74% rename from apps/client/serializers.py rename to apps/company/serializers.py index 4f71f7310..d1de36d92 100644 --- a/apps/client/serializers.py +++ b/apps/company/serializers.py @@ -1,9 +1,9 @@ from rest_framework import serializers -from apps.client.models import ( - Client, +from apps.company.models import ( ClientContact, ClientContactMethod, + Company, PhoneAssignmentConflictError, SupplierPickupAddress, SupplierSearchAlias, @@ -31,19 +31,19 @@ def to_internal_value(self, data): class ClientContactMethodSerializer(serializers.ModelSerializer): - """Serializer for canonical client/contact phone and email methods.""" + """Serializer for canonical company/contact phone and email methods.""" - owner_client = serializers.SerializerMethodField() - client_name = serializers.SerializerMethodField() + owner_company = serializers.SerializerMethodField() + company_name = serializers.SerializerMethodField() contact_name = serializers.SerializerMethodField() class Meta: model = ClientContactMethod fields = [ "id", - "client", - "owner_client", - "client_name", + "company", + "owner_company", + "company_name", "contact", "contact_name", "method_type", @@ -57,33 +57,33 @@ class Meta: ] read_only_fields = [ "id", - "owner_client", - "client_name", + "owner_company", + "company_name", "contact_name", "normalized_value", "created_at", "updated_at", ] - def get_owner_client(self, obj: ClientContactMethod) -> str: - if obj.client_id: - return str(obj.client_id) - return str(obj.contact.client_id) if obj.contact else "" + def get_owner_company(self, obj: ClientContactMethod) -> str: + if obj.company_id: + return str(obj.company_id) + return str(obj.contact.company_id) if obj.contact else "" - def get_client_name(self, obj: ClientContactMethod) -> str: - if obj.client: - return obj.client.name - return obj.contact.client.name if obj.contact else "" + def get_company_name(self, obj: ClientContactMethod) -> str: + if obj.company: + return obj.company.name + return obj.contact.company.name if obj.contact else "" def get_contact_name(self, obj: ClientContactMethod) -> str: return obj.contact.name if obj.contact else "" def validate(self, attrs): - client = attrs.get("client", getattr(self.instance, "client", None)) + company = attrs.get("company", getattr(self.instance, "company", None)) contact = attrs.get("contact", getattr(self.instance, "contact", None)) - if bool(client) == bool(contact): + if bool(company) == bool(contact): raise serializers.ValidationError( - "Exactly one of client or contact is required" + "Exactly one of company or contact is required" ) method_type = attrs.get( "method_type", getattr(self.instance, "method_type", None) @@ -94,7 +94,7 @@ def validate(self, attrs): try: ClientContactMethod.check_phone_assignment( normalized, - client_id=client.id if client else None, + company_id=company.id if company else None, contact=contact, instance=self.instance, ) @@ -103,7 +103,7 @@ def validate(self, attrs): raise serializers.ValidationError( { "value": "Internal phone endpoint cannot be assigned " - "to a client." + "to a company." } ) from exc raise serializers.ValidationError( @@ -154,16 +154,16 @@ def to_internal_value(self, data): return super().to_internal_value(data) -class ClientSerializer(serializers.ModelSerializer): +class CompanySerializer(serializers.ModelSerializer): contacts = ClientContactSerializer(many=True, read_only=True) class Meta: - model = Client + model = Company fields = ( ["id"] - + Client.CLIENT_DIRECT_FIELDS + + Company.COMPANY_DIRECT_FIELDS + [ - # Excluded from CLIENT_DIRECT_FIELDS: + # Excluded from COMPANY_DIRECT_FIELDS: "raw_json", # debugging blob, not business data "django_created_at", # auto timestamp "django_updated_at", # auto timestamp @@ -173,9 +173,9 @@ class Meta: ) -class ClientNameOnlySerializer(serializers.ModelSerializer): +class CompanyNameOnlySerializer(serializers.ModelSerializer): class Meta: - model = Client + model = Company fields = ["id", "name"] read_only_fields = ["id", "name"] @@ -187,15 +187,15 @@ class StandardErrorSerializer(serializers.Serializer): details = serializers.JSONField(required=False) -class ClientListResponseSerializer(serializers.Serializer): - """Serializer for client list response""" +class CompanyListResponseSerializer(serializers.Serializer): + """Serializer for company list response""" id = serializers.CharField() name = serializers.CharField() -class ClientSearchResultSerializer(serializers.Serializer): - """Serializer for individual client search result""" +class CompanySearchResultSerializer(serializers.Serializer): + """Serializer for individual company search result""" id = serializers.CharField() name = serializers.CharField() @@ -209,10 +209,10 @@ class ClientSearchResultSerializer(serializers.Serializer): total_spend = serializers.CharField() -class ClientSearchResponseSerializer(serializers.Serializer): - """Serializer for paginated client search response""" +class CompanySearchResponseSerializer(serializers.Serializer): + """Serializer for paginated company search response""" - results = ClientSearchResultSerializer(many=True) + results = CompanySearchResultSerializer(many=True) count = serializers.IntegerField() page = serializers.IntegerField() page_size = serializers.IntegerField() @@ -220,12 +220,12 @@ class ClientSearchResponseSerializer(serializers.Serializer): class SupplierSearchAliasSerializer(serializers.ModelSerializer): - """Supplier search alias attached to a client/contact.""" + """Supplier search alias attached to a company/contact.""" class Meta: model = SupplierSearchAlias - fields = ["id", "client", "alias", "is_active", "created_at", "updated_at"] - read_only_fields = ["id", "client", "is_active", "created_at", "updated_at"] + fields = ["id", "company", "alias", "is_active", "created_at", "updated_at"] + read_only_fields = ["id", "company", "is_active", "created_at", "updated_at"] class SupplierSearchAliasCreateSerializer(serializers.Serializer): @@ -240,8 +240,8 @@ def validate_alias(self, value: str) -> str: return alias -class ClientCreateSerializer(serializers.Serializer): - """Serializer for client creation request""" +class CompanyCreateSerializer(serializers.Serializer): + """Serializer for company creation request""" name = serializers.CharField(max_length=255) email = serializers.EmailField(required=False, allow_blank=True, allow_null=True) @@ -250,32 +250,32 @@ class ClientCreateSerializer(serializers.Serializer): allow_jobs = serializers.BooleanField(default=True) -class ClientCreateResponseSerializer(serializers.Serializer): - """Serializer for client creation response""" +class CompanyCreateResponseSerializer(serializers.Serializer): + """Serializer for company creation response""" success = serializers.BooleanField() - client = ClientSearchResultSerializer() + company = CompanySearchResultSerializer() message = serializers.CharField() -class ClientErrorResponseSerializer(serializers.Serializer): - """Serializer for client error responses""" +class CompanyErrorResponseSerializer(serializers.Serializer): + """Serializer for company error responses""" success = serializers.BooleanField(default=False) error = serializers.CharField() details = serializers.CharField(required=False) -class ClientDuplicateErrorResponseSerializer(serializers.Serializer): - """Serializer for client duplicate error response""" +class CompanyDuplicateErrorResponseSerializer(serializers.Serializer): + """Serializer for company duplicate error response""" success = serializers.BooleanField(default=False) error = serializers.CharField() - existing_client = serializers.DictField() + existing_company = serializers.DictField() -class ClientDetailResponseSerializer(serializers.Serializer): - """Serializer for client detail response""" +class CompanyDetailResponseSerializer(serializers.Serializer): + """Serializer for company detail response""" id = serializers.CharField() name = serializers.CharField() @@ -300,8 +300,8 @@ class ClientDetailResponseSerializer(serializers.Serializer): total_spend = serializers.CharField() -class ClientUpdateSerializer(serializers.Serializer): - """Serializer for client update request""" +class CompanyUpdateSerializer(serializers.Serializer): + """Serializer for company update request""" name = serializers.CharField(max_length=255, required=False) email = serializers.EmailField(required=False, allow_blank=True) @@ -310,11 +310,11 @@ class ClientUpdateSerializer(serializers.Serializer): allow_jobs = serializers.BooleanField(required=False) -class ClientUpdateResponseSerializer(serializers.Serializer): - """Serializer for client update response""" +class CompanyUpdateResponseSerializer(serializers.Serializer): + """Serializer for company update response""" success = serializers.BooleanField() - client = ClientDetailResponseSerializer() + company = CompanyDetailResponseSerializer() message = serializers.CharField() @@ -333,13 +333,13 @@ class JobContactUpdateSerializer(JobContactResponseSerializer): """Serializer for job contact update request""" -class ClientJobHeaderSerializer(serializers.Serializer): - """Serializer for job header in client jobs list.""" +class CompanyJobHeaderSerializer(serializers.Serializer): + """Serializer for job header in company jobs list.""" job_id = serializers.UUIDField() job_number = serializers.IntegerField() name = serializers.CharField() - client = serializers.DictField(allow_null=True) + company = serializers.DictField(allow_null=True) status = serializers.CharField() pricing_methodology = serializers.CharField(allow_null=True) speed_quality_tradeoff = serializers.CharField() @@ -353,7 +353,7 @@ class ClientJobHeaderSerializer(serializers.Serializer): max_people = serializers.IntegerField() -class ClientJobsResponseSerializer(serializers.Serializer): - """Serializer for client jobs list response""" +class CompanyJobsResponseSerializer(serializers.Serializer): + """Serializer for company jobs list response""" - results = ClientJobHeaderSerializer(many=True) + results = CompanyJobHeaderSerializer(many=True) diff --git a/apps/client/services/__init__.py b/apps/company/services/__init__.py similarity index 83% rename from apps/client/services/__init__.py rename to apps/company/services/__init__.py index 71d83f928..27b868e89 100644 --- a/apps/client/services/__init__.py +++ b/apps/company/services/__init__.py @@ -5,8 +5,8 @@ from django.apps import apps if apps.ready: - from .client_merge_service import reassign_client_fk_records - from .client_rest_service import ClientRestService + from .company_merge_service import reassign_company_fk_records + from .company_rest_service import CompanyRestService from .duplicate_phone_report import ( DuplicatePhoneIssue, DuplicatePhoneOwner, @@ -26,7 +26,7 @@ pass __all__ = [ - "ClientRestService", + "CompanyRestService", "DuplicatePhoneIssue", "DuplicatePhoneOwner", "DuplicatePhoneReportService", @@ -37,5 +37,5 @@ "GeocodingResult", "geocode_address", "get_api_key", - "reassign_client_fk_records", + "reassign_company_fk_records", ] diff --git a/apps/client/services/client_merge_service.py b/apps/company/services/company_merge_service.py similarity index 69% rename from apps/client/services/client_merge_service.py rename to apps/company/services/company_merge_service.py index c98d782ed..bff80d6ca 100644 --- a/apps/client/services/client_merge_service.py +++ b/apps/company/services/company_merge_service.py @@ -1,15 +1,15 @@ """ -Reassign FK records from one Client (source) to another (destination). +Reassign FK records from one Company (source) to another (destination). -The Client.merged_into pointer alone leaves historical records stranded on -the merged-from row — queries filtered by the merged-into client miss the -absorbed history. This service moves the 8 client-referencing FK fields in +The Company.merged_into pointer alone leaves historical records stranded on +the merged-from row — queries filtered by the merged-into company miss the +absorbed history. This service moves the 8 company-referencing FK fields in a single atomic block. Callers pick the destination explicitly: -- Xero sync paths typically pass ``source.get_final_client()`` so absorbed - history lands on the terminal client in a merge chain (A -> B -> C). -- The local dedup command passes a hand-picked primary client. +- Xero sync paths typically pass ``source.get_final_company()`` so absorbed + history lands on the terminal company in a merge chain (A -> B -> C). +- The local dedup command passes a hand-picked primary company. """ import logging @@ -17,7 +17,7 @@ from django.db import transaction from django.utils import timezone -from apps.client.models import Client, ClientContact, ClientContactMethod +from apps.company.models import ClientContact, ClientContactMethod, Company from apps.workflow.services.error_persistence import persist_app_error logger = logging.getLogger("xero") @@ -26,17 +26,17 @@ def _move_contact_methods( *, source_contact: ClientContact | None, - source_client: Client | None, + source_company: Company | None, destination_contact: ClientContact | None, - destination_client: Client | None, + destination_company: Company | None, ) -> int: if source_contact is None: source_queryset = ClientContactMethod.objects.filter( - client=source_client, + company=source_company, contact__isnull=True, ) destination_queryset = ClientContactMethod.objects.filter( - client=destination_client, + company=destination_company, contact__isnull=True, ) else: @@ -77,30 +77,30 @@ def _move_contact_methods( ).update(is_primary=False) # queryset.update() bypasses ClientContactMethod.save()'s - # one-number-one-client guard DELIBERATELY: a merge moves ALL of the - # source's methods to the destination, so any cross-client sharing the - # guard would flag (e.g. the client-level/contact-level twins migration + # one-number-one-company guard DELIBERATELY: a merge moves ALL of the + # source's methods to the destination, so any cross-company sharing the + # guard would flag (e.g. the company-level/contact-level twins migration # 0023 created) already existed before the merge — the move creates no # new conflicting ownership. if destination_contact is None: affected += source_queryset.update( - client=destination_client, + company=destination_company, contact=None, updated_at=timezone.now(), ) else: affected += source_queryset.update( - client=None, + company=None, contact=destination_contact, updated_at=timezone.now(), ) return affected -def _move_client_contacts_and_methods( - source: Client, destination: Client +def _move_company_contacts_and_methods( + source: Company, destination: Company ) -> dict[str, int]: - """Move CRM contact ownership before the source client is deleted.""" + """Move CRM contact ownership before the source company is deleted.""" from apps.crm.models import PhoneCallRecord @@ -112,53 +112,53 @@ def _move_client_contacts_and_methods( counts["contact_methods"] += _move_contact_methods( source_contact=None, - source_client=source, + source_company=source, destination_contact=None, - destination_client=destination, + destination_company=destination, ) destination_contacts_by_name = { existing.name: existing - for existing in ClientContact.objects.filter(client=destination) + for existing in ClientContact.objects.filter(company=destination) } - for contact in ClientContact.objects.filter(client=source).iterator(): + for contact in ClientContact.objects.filter(company=source).iterator(): destination_contact = destination_contacts_by_name.get(contact.name) if destination_contact is None: - contact.client = destination - contact.save(update_fields=["client", "updated_at"]) + contact.company = destination + contact.save(update_fields=["company", "updated_at"]) destination_contact = contact else: counts["contact_methods"] += _move_contact_methods( source_contact=contact, - source_client=None, + source_company=None, destination_contact=destination_contact, - destination_client=None, + destination_company=None, ) counts["phone_calls"] += PhoneCallRecord.objects.filter( contact=contact ).update( - client=destination, + company=destination, contact=destination_contact, ) contact.delete() counts["contacts"] += 1 - counts["phone_calls"] += PhoneCallRecord.objects.filter(client=source).update( - client=destination + counts["phone_calls"] += PhoneCallRecord.objects.filter(company=source).update( + company=destination ) return counts -def reassign_client_fk_records( - source: Client, - destination: Client, +def reassign_company_fk_records( + source: Company, + destination: Company, staff, *, logger_prefix: str = "", ) -> dict[str, int]: """ - Move every client-referencing FK record from ``source`` to ``destination``. + Move every company-referencing FK record from ``source`` to ``destination``. Returns a dict of per-model rowcounts, e.g. ``{"jobs": 3, ...}``. @@ -176,8 +176,8 @@ def reassign_client_fk_records( """ if destination.id == source.id: raise ValueError( - f"reassign_client_fk_records: source and destination are the " - f"same client ({source.id}); refusing to run" + f"reassign_company_fk_records: source and destination are the " + f"same company ({source.id}); refusing to run" ) # Late imports to avoid circular-import at Django app-loading time. @@ -189,26 +189,28 @@ def reassign_client_fk_records( try: with transaction.atomic(): jobs_moved = 0 - for job in Job.objects.filter(client=source): - job.client = destination - job.save(staff=staff, update_fields=["client"]) + for job in Job.objects.filter(company=source): + job.company = destination + job.save(staff=staff, update_fields=["company"]) jobs_moved += 1 - crm_counts = _move_client_contacts_and_methods(source, destination) + crm_counts = _move_company_contacts_and_methods(source, destination) counts = { "jobs": jobs_moved, "contacts": crm_counts["contacts"], "contact_methods": crm_counts["contact_methods"], "phone_calls": crm_counts["phone_calls"], - "invoices": Invoice.objects.filter(client=source).update( - client=destination + "invoices": Invoice.objects.filter(company=source).update( + company=destination ), - "bills": Bill.objects.filter(client=source).update(client=destination), - "credit_notes": CreditNote.objects.filter(client=source).update( - client=destination + "bills": Bill.objects.filter(company=source).update( + company=destination ), - "quotes": Quote.objects.filter(client=source).update( - client=destination + "credit_notes": CreditNote.objects.filter(company=source).update( + company=destination + ), + "quotes": Quote.objects.filter(company=source).update( + company=destination ), "purchase_orders": PurchaseOrder.objects.filter(supplier=source).update( supplier=destination @@ -225,7 +227,7 @@ def reassign_client_fk_records( } logger.info( - "%sReassigned client %s -> %s: jobs=%d contacts=%d " + "%sReassigned company %s -> %s: jobs=%d contacts=%d " "contact_methods=%d phone_calls=%d invoices=%d bills=%d " "credit_notes=%d quotes=%d purchase_orders=%d supplier_products=%d " "supplier_price_lists=%d scrape_jobs=%d", diff --git a/apps/client/services/client_rest_service.py b/apps/company/services/company_rest_service.py similarity index 56% rename from apps/client/services/client_rest_service.py rename to apps/company/services/company_rest_service.py index deae39594..3d848dac9 100644 --- a/apps/client/services/client_rest_service.py +++ b/apps/company/services/company_rest_service.py @@ -1,8 +1,8 @@ """ -Client REST Service Layer +Company REST Service Layer Following SRP (Single Responsibility Principle) and clean code guidelines. -All business logic for Client REST operations should be implemented here. +All business logic for Company REST operations should be implemented here. """ import json @@ -20,9 +20,9 @@ from django.shortcuts import get_object_or_404 from django.utils import timezone -from apps.client.models import Client, ClientContact -from apps.client.serializers import ClientCreateSerializer, ClientUpdateSerializer -from apps.client.utils import date_to_datetime +from apps.company.models import ClientContact, Company +from apps.company.serializers import CompanyCreateSerializer, CompanyUpdateSerializer +from apps.company.utils import date_to_datetime from apps.workflow.accounting.registry import get_provider from apps.workflow.exceptions import AlreadyLoggedException from apps.workflow.services.error_persistence import ( @@ -31,34 +31,34 @@ ) from apps.workflow.services.search_telemetry import SearchTelemetryService -CLIENT_SEARCH_TOKEN_RE = re.compile(r"[a-z0-9]+") +COMPANY_SEARCH_TOKEN_RE = re.compile(r"[a-z0-9]+") logger = logging.getLogger(__name__) -client_search_logger = logging.getLogger("client_search") +company_search_logger = logging.getLogger("company_search") -class ClientRestService: +class CompanyRestService: """ - Service layer for Client REST operations. - Implements all business rules related to Client manipulation via REST API. + Service layer for Company REST operations. + Implements all business rules related to Company manipulation via REST API. """ @staticmethod - def get_all_clients() -> List[Dict[str, Any]]: + def get_all_companies() -> List[Dict[str, Any]]: """ - Retrieves all clients with basic information for dropdowns. + Retrieves all companies with basic information for dropdowns. Returns: - List of client dictionaries with id and name + List of company dictionaries with id and name """ try: - clients = Client.objects.all().order_by("name") + companies = Company.objects.all().order_by("name") return [ { - "id": str(client.id), - "name": client.name, + "id": str(company.id), + "name": company.name, } - for client in clients + for company in companies ] except AlreadyLoggedException: raise @@ -66,16 +66,16 @@ def get_all_clients() -> List[Dict[str, Any]]: persist_and_raise(exc) @staticmethod - def search_clients(query: str, limit: int = 10) -> List[Dict[str, Any]]: + def search_companies(query: str, limit: int = 10) -> List[Dict[str, Any]]: """ - Searches clients by name with enhanced data. + Searches companies by name with enhanced data. Args: query: Search query (minimum 3 characters) limit: Maximum results to return (capped at 50) Returns: - List of client dictionaries with detailed information + List of company dictionaries with detailed information Raises: ValueError: If query is too short @@ -90,8 +90,8 @@ def search_clients(query: str, limit: int = 10) -> List[Dict[str, Any]]: limit = max(1, min(limit, 50)) # Execute optimized search - clients = ClientRestService._execute_client_search(query, limit) - return ClientRestService._format_client_search_results(clients) + companies = CompanyRestService._execute_company_search(query, limit) + return CompanyRestService._format_company_search_results(companies) except AlreadyLoggedException: raise @@ -99,7 +99,7 @@ def search_clients(query: str, limit: int = 10) -> List[Dict[str, Any]]: persist_and_raise(exc, additional_context={"query": query, "limit": limit}) @staticmethod - def list_clients( + def list_companies( query: str | None = None, page: int = 1, page_size: int = 50, @@ -107,7 +107,7 @@ def list_clients( sort_dir: str = "asc", ) -> Dict[str, Any]: """ - Lists clients with pagination, sorting, and optional search. + Lists companies with pagination, sorting, and optional search. Args: query: Optional search query (min 3 chars for filtering) @@ -135,17 +135,17 @@ def list_clients( sort_field = f"-{sort_field}" # Annotate computed fields for sorting capability - queryset = Client.objects.with_invoice_summary().defer("raw_json") + queryset = Company.objects.with_invoice_summary().defer("raw_json") # Apply search filter if query provided if query: - ranked_ids = ClientRestService._rank_matching_client_ids( - Client.objects.all(), query + ranked_ids = CompanyRestService._rank_matching_company_ids( + Company.objects.all(), query ) total_count = len(ranked_ids) offset = (page - 1) * page_size page_ids = ranked_ids[offset : offset + page_size] - clients = ClientRestService._hydrate_client_search_results(page_ids) + companies = CompanyRestService._hydrate_company_search_results(page_ids) else: ordering = (sort_field,) # Get total count before pagination @@ -153,13 +153,13 @@ def list_clients( # Apply sorting and pagination offset = (page - 1) * page_size - clients = queryset.order_by(*ordering)[offset : offset + page_size] + companies = queryset.order_by(*ordering)[offset : offset + page_size] # Calculate total pages total_pages = (total_count + page_size - 1) // page_size return { - "results": ClientRestService._format_client_search_results(clients), + "results": CompanyRestService._format_company_search_results(companies), "count": total_count, "page": page, "page_size": page_size, @@ -179,52 +179,52 @@ def list_clients( ) @staticmethod - def get_client_by_id(client_id: UUID) -> Dict[str, Any]: + def get_company_by_id(company_id: UUID) -> Dict[str, Any]: """ - Retrieves a specific client by ID with full details. + Retrieves a specific company by ID with full details. Args: - client_id: Client UUID + company_id: Company UUID Returns: - Dict with complete client information + Dict with complete company information Raises: - ValueError: If client not found + ValueError: If company not found """ try: - client = Client.objects.with_invoice_summary().get(id=client_id) - return ClientRestService._format_client_detail(client) - except Client.DoesNotExist: - raise ValueError(f"Client with id {client_id} not found") + company = Company.objects.with_invoice_summary().get(id=company_id) + return CompanyRestService._format_company_detail(company) + except Company.DoesNotExist: + raise ValueError(f"Company with id {company_id} not found") except AlreadyLoggedException: raise except Exception as exc: persist_and_raise( exc, additional_context={ - "operation": "get_client_by_id", - "client_id": str(client_id), + "operation": "get_company_by_id", + "company_id": str(company_id), }, ) @staticmethod - def create_client(data: Dict[str, Any]) -> Client: + def create_company(data: Dict[str, Any]) -> Company: """ - Creates a new client locally and in the accounting provider. + Creates a new company locally and in the accounting provider. Args: - data: Client creation data + data: Company creation data Returns: - Created Client instance + Created Company instance Raises: ValueError: If validation fails or accounting provider sync fails """ try: # Validate using DRF serializer - serializer = ClientCreateSerializer(data=data) + serializer = CompanyCreateSerializer(data=data) if not serializer.is_valid(): error_messages = [] for field, errors in serializer.errors.items(): @@ -238,8 +238,10 @@ def create_client(data: Dict[str, Any]) -> Client: raise ValueError("Accounting provider authentication required") # Create in Xero first - client = ClientRestService._create_client_in_xero(serializer.validated_data) - return client + company = CompanyRestService._create_company_in_xero( + serializer.validated_data + ) + return company except AlreadyLoggedException: raise @@ -247,35 +249,35 @@ def create_client(data: Dict[str, Any]) -> Client: persist_and_raise( exc, additional_context={ - "operation": "create_client", + "operation": "create_company", "payload_keys": list(data.keys()), }, ) @staticmethod - def update_client(client_id: UUID, data: Dict[str, Any]) -> Client: + def update_company(company_id: UUID, data: Dict[str, Any]) -> Company: """ - Updates an existing client. - If client is synced with Xero, updates Xero first then syncs locally. + Updates an existing company. + If company is synced with Xero, updates Xero first then syncs locally. Args: - client_id: Client UUID - data: Updated client data + company_id: Company UUID + data: Updated company data Returns: - Updated Client instance + Updated Company instance Raises: - ValueError: If client not found or validation fails + ValueError: If company not found or validation fails """ try: - client = get_object_or_404(Client, id=client_id) + company = get_object_or_404(Company, id=company_id) # Store xero_contact_id before validation - original_xero_contact_id = client.xero_contact_id + original_xero_contact_id = company.xero_contact_id # Validate using DRF serializer - serializer = ClientUpdateSerializer(data=data) + serializer = CompanyUpdateSerializer(data=data) if not serializer.is_valid(): error_messages = [] for field, errors in serializer.errors.items(): @@ -285,51 +287,53 @@ def update_client(client_id: UUID, data: Dict[str, Any]) -> Client: validated_data = serializer.validated_data # Guard clause - validate required fields - if not validated_data.get("name") and not client.name: - raise ValueError("Client name is required") + if not validated_data.get("name") and not company.name: + raise ValueError("Company name is required") - # DEBUG: Log client state after validation + # DEBUG: Log company state after validation logger.info( - f"Client data after validation: xero_contact_id={original_xero_contact_id}", + f"Company data after validation: xero_contact_id={original_xero_contact_id}", extra={ - "client_id": str(client.id), + "company_id": str(company.id), "original_xero_contact_id": original_xero_contact_id, - "operation": "update_client_debug_after_validation", + "operation": "update_company_debug_after_validation", }, ) - # Check if client is synced with Xero + # Check if company is synced with Xero if original_xero_contact_id: # Update in Xero first, then sync locally - updated_client = ClientRestService._update_client_in_xero(client, data) + updated_company = CompanyRestService._update_company_in_xero( + company, data + ) logger.info( - f"Client {updated_client.id} updated in Xero and synced locally", + f"Company {updated_company.id} updated in Xero and synced locally", extra={ - "client_id": str(updated_client.id), - "client_name": updated_client.name, - "xero_contact_id": updated_client.xero_contact_id, - "operation": "update_client_xero_sync", + "company_id": str(updated_company.id), + "company_name": updated_company.name, + "xero_contact_id": updated_company.xero_contact_id, + "operation": "update_company_xero_sync", }, ) - return updated_client + return updated_company else: - # Local-only update for clients not synced with Xero + # Local-only update for companies not synced with Xero with transaction.atomic(): for field, value in validated_data.items(): - setattr(client, field, value) - client.xero_last_modified = timezone.now() - client.save() + setattr(company, field, value) + company.xero_last_modified = timezone.now() + company.save() logger.info( - f"Client {client.id} updated locally (no Xero sync)", + f"Company {company.id} updated locally (no Xero sync)", extra={ - "client_id": str(client.id), - "client_name": client.name, - "operation": "update_client_local_only", + "company_id": str(company.id), + "company_name": company.name, + "operation": "update_company_local_only", }, ) - return client + return company except AlreadyLoggedException: raise @@ -337,29 +341,29 @@ def update_client(client_id: UUID, data: Dict[str, Any]) -> Client: persist_and_raise( exc, additional_context={ - "operation": "update_client", - "client_id": str(client_id), + "operation": "update_company", + "company_id": str(company_id), "payload_keys": list(data.keys()), }, ) @staticmethod - def get_client_contacts(client_id: UUID) -> List[Dict[str, Any]]: + def get_company_contacts(company_id: UUID) -> List[Dict[str, Any]]: """ - Retrieves all contacts for a specific client. + Retrieves all contacts for a specific company. Args: - client_id: Client UUID + company_id: Company UUID Returns: List of contact dictionaries Raises: - ValueError: If client not found + ValueError: If company not found """ try: - client = get_object_or_404(Client, id=client_id) - contacts = client.contacts.all().order_by("name") + company = get_object_or_404(Company, id=company_id) + contacts = company.contacts.all().order_by("name") return [ { @@ -378,8 +382,8 @@ def get_client_contacts(client_id: UUID) -> List[Dict[str, Any]]: persist_and_raise( exc, additional_context={ - "operation": "get_client_contacts", - "client_id": str(client_id), + "operation": "get_company_contacts", + "company_id": str(company_id), }, ) @@ -463,11 +467,11 @@ def update_job_contact( from apps.job.models import Job try: - job = Job.objects.select_related("client", "contact").get(id=job_id) + job = Job.objects.select_related("company", "contact").get(id=job_id) except Job.DoesNotExist: raise ValueError(f"Job with id {job_id} not found") - # Validate contact exists and belongs to the same client + # Validate contact exists and belongs to the same company contact_id = contact_data.get("id") if not contact_id: raise ValueError("Contact ID is required") @@ -477,9 +481,9 @@ def update_job_contact( except ClientContact.DoesNotExist: raise ValueError(f"Contact with id {contact_id} not found") - # Validate contact belongs to the job's client - if contact.client_id != job.client_id: - raise ValueError("Contact does not belong to the job's client") + # Validate contact belongs to the job's company + if contact.company_id != job.company_id: + raise ValueError("Contact does not belong to the job's company") # Update job's contact job.contact = contact @@ -490,7 +494,7 @@ def update_job_contact( extra={ "job_id": str(job_id), "contact_id": str(contact_id), - "client_id": str(job.client_id), + "company_id": str(job.company_id), "operation": "update_job_contact", }, ) @@ -517,29 +521,29 @@ def update_job_contact( ) @staticmethod - def _execute_client_search(query: str, limit: int): + def _execute_company_search(query: str, limit: int): """ - Executes client search with appropriate filters and annotations. + Executes company search with appropriate filters and annotations. """ - ranked_ids = ClientRestService._rank_matching_client_ids( - Client.objects.filter(allow_jobs=True), query + ranked_ids = CompanyRestService._rank_matching_company_ids( + Company.objects.filter(allow_jobs=True), query ) - return ClientRestService._hydrate_client_search_results(ranked_ids[:limit]) + return CompanyRestService._hydrate_company_search_results(ranked_ids[:limit]) @staticmethod - def _hydrate_client_search_results(client_ids): - if not client_ids: + def _hydrate_company_search_results(company_ids): + if not company_ids: return [] ordering = Case( *[ - When(id=client_id, then=position) - for position, client_id in enumerate(client_ids) + When(id=company_id, then=position) + for position, company_id in enumerate(company_ids) ], output_field=IntegerField(), ) return list( - Client.objects.with_invoice_summary() + Company.objects.with_invoice_summary() .defer("raw_json") # Not needed for search results .only( "id", @@ -551,58 +555,58 @@ def _hydrate_client_search_results(client_ids): "allow_jobs", "xero_contact_id", ) - .filter(id__in=client_ids) + .filter(id__in=company_ids) .order_by(ordering) ) @staticmethod - def _rank_matching_client_ids(queryset, query: str): - tokens = ClientRestService._client_search_tokens(query) + def _rank_matching_company_ids(queryset, query: str): + tokens = CompanyRestService._company_search_tokens(query) if not tokens: return [] - candidate_filter = ClientRestService._client_name_candidate_filter(tokens) + candidate_filter = CompanyRestService._company_name_candidate_filter(tokens) candidates = queryset.filter(candidate_filter).values_list("id", "name") ranked = [ ( - ClientRestService._client_name_score(name, query, tokens), - client_id, + CompanyRestService._company_name_score(name, query, tokens), + company_id, ) - for client_id, name in candidates.iterator() - if ClientRestService._client_name_matches(name, tokens) + for company_id, name in candidates.iterator() + if CompanyRestService._company_name_matches(name, tokens) ] ranked.sort(key=lambda item: item[0]) - return [client_id for _, client_id in ranked] + return [company_id for _, company_id in ranked] @staticmethod - def _client_search_tokens(query: str) -> list[str]: - return CLIENT_SEARCH_TOKEN_RE.findall(query.lower()) + def _company_search_tokens(query: str) -> list[str]: + return COMPANY_SEARCH_TOKEN_RE.findall(query.lower()) @staticmethod - def _normalized_client_search_text(value: str) -> str: - return " ".join(CLIENT_SEARCH_TOKEN_RE.findall(value.lower())) + def _normalized_company_search_text(value: str) -> str: + return " ".join(COMPANY_SEARCH_TOKEN_RE.findall(value.lower())) @staticmethod - def _client_name_candidate_filter(tokens: list[str]): + def _company_name_candidate_filter(tokens: list[str]): candidate_filter = Q() for token in tokens: candidate_filter &= Q(name__icontains=token) return candidate_filter @staticmethod - def _client_name_matches(name: str, tokens: list[str]) -> bool: - name_tokens = ClientRestService._client_search_tokens(name) + def _company_name_matches(name: str, tokens: list[str]) -> bool: + name_tokens = CompanyRestService._company_search_tokens(name) return all( any(name_token.startswith(query_token) for name_token in name_tokens) for query_token in tokens ) @staticmethod - def _client_name_score(name: str, query: str, tokens: list[str]): - normalized_name = ClientRestService._normalized_client_search_text(name) - normalized_query = ClientRestService._normalized_client_search_text(query) - name_tokens = ClientRestService._client_search_tokens(name) + def _company_name_score(name: str, query: str, tokens: list[str]): + normalized_name = CompanyRestService._normalized_company_search_text(name) + normalized_query = CompanyRestService._normalized_company_search_text(query) + name_tokens = CompanyRestService._company_search_tokens(name) if normalized_name == normalized_query: tier = 0 @@ -614,7 +618,7 @@ def _client_name_score(name: str, query: str, tokens: list[str]): tier = 3 token_scores = [ - ClientRestService._client_token_match_score(token, name_tokens) + CompanyRestService._company_token_match_score(token, name_tokens) for token in tokens ] positions = [normalized_name.find(token) for token in tokens] @@ -630,7 +634,7 @@ def _client_name_score(name: str, query: str, tokens: list[str]): ) @staticmethod - def _client_token_match_score(query_token: str, name_tokens: list[str]) -> int: + def _company_token_match_score(query_token: str, name_tokens: list[str]) -> int: if query_token in name_tokens: return 0 if any(token.startswith(query_token) for token in name_tokens): @@ -638,38 +642,40 @@ def _client_token_match_score(query_token: str, name_tokens: list[str]) -> int: return 99 @staticmethod - def explain_client_search(query: str, limit: int = 20) -> List[Dict[str, Any]]: - ranked_ids = ClientRestService._rank_matching_client_ids( - Client.objects.all(), query + def explain_company_search(query: str, limit: int = 20) -> List[Dict[str, Any]]: + ranked_ids = CompanyRestService._rank_matching_company_ids( + Company.objects.all(), query + ) + companies = CompanyRestService._hydrate_company_search_results( + ranked_ids[:limit] ) - clients = ClientRestService._hydrate_client_search_results(ranked_ids[:limit]) - tokens = ClientRestService._client_search_tokens(query) + tokens = CompanyRestService._company_search_tokens(query) return [ - ClientRestService._client_search_log_result( + CompanyRestService._company_search_log_result( rank=index + 1, - result=client, + result=company, query=query, tokens=tokens, ) - for index, client in enumerate(clients) + for index, company in enumerate(companies) ] @staticmethod - def log_client_search_results( + def log_company_search_results( *, request: Optional[HttpRequest], source: str, query: str, - clients, + companies, total_count: int, ) -> None: if len(query.strip()) < 3: return - tokens = ClientRestService._client_search_tokens(query) + tokens = CompanyRestService._company_search_tokens(query) user = getattr(request, "user", None) if request else None payload = { - "event": "client_search_results", + "event": "company_search_results", "search_id": str(uuid4()), "source": source, "query": query, @@ -680,18 +686,18 @@ def log_client_search_results( "user_id": str(getattr(user, "id", "")) if user else None, "user_email": getattr(user, "email", None) if user else None, "result_count": total_count, - "returned_count": len(clients), + "returned_count": len(companies), "results": [ - ClientRestService._client_search_log_result( + CompanyRestService._company_search_log_result( rank=index + 1, - result=client, + result=company, query=query, tokens=tokens, ) - for index, client in enumerate(clients) + for index, company in enumerate(companies) ], } - client_search_logger.info(json.dumps(payload, sort_keys=True, default=str)) + company_search_logger.info(json.dumps(payload, sort_keys=True, default=str)) SearchTelemetryService.log_search( request=request, domain="client", @@ -700,24 +706,24 @@ def log_client_search_results( result_count=total_count, returned_result_ids=[ result["id"] if isinstance(result, dict) else result.id - for result in clients + for result in companies ], metadata={"results": payload["results"][:100]}, ) @staticmethod - def log_client_search_click( + def log_company_search_click( *, request: Optional[HttpRequest], source: str, query: str, - client_id, + company_id, rank: Optional[int], ) -> None: - client = Client.objects.only("id", "name").get(id=client_id) + company = Company.objects.only("id", "name").get(id=company_id) user = getattr(request, "user", None) if request else None payload = { - "event": "client_search_click", + "event": "company_search_click", "search_id": str(uuid4()), "source": source, "query": query, @@ -727,52 +733,52 @@ def log_client_search_click( ), "user_id": str(getattr(user, "id", "")) if user else None, "user_email": getattr(user, "email", None) if user else None, - "client_id": str(client.id), - "client_name": client.name, + "company_id": str(company.id), + "company_name": company.name, "rank": rank, } - client_search_logger.info(json.dumps(payload, sort_keys=True, default=str)) + company_search_logger.info(json.dumps(payload, sort_keys=True, default=str)) SearchTelemetryService.log_click( request=request, domain="client", source=source, query=query, - selected_result_id=str(client.id), - selected_label=client.name, + selected_result_id=str(company.id), + selected_label=company.name, selected_rank=rank, - metadata={"client_name": client.name}, + metadata={"company_name": company.name}, ) @staticmethod - def _client_search_log_result( + def _company_search_log_result( *, rank: int, - result: Client | Dict[str, Any], + result: Company | Dict[str, Any], query: str, tokens: list[str], ) -> Dict[str, Any]: if isinstance(result, dict): - client_id = result["id"] - client_name = result["name"] + company_id = result["id"] + company_name = result["name"] else: - client_id = str(result.id) - client_name = result.name + company_id = str(result.id) + company_name = result.name - name_tokens = ClientRestService._client_search_tokens(client_name) + name_tokens = CompanyRestService._company_search_tokens(company_name) return { "rank": rank, - "client_id": client_id, - "client_name": client_name, - "search_score": ClientRestService._client_name_score( - client_name, query, tokens + "company_id": company_id, + "company_name": company_name, + "search_score": CompanyRestService._company_name_score( + company_name, query, tokens ), "search_reasons": [ { "token": token, - "reason": ClientRestService._client_token_match_reason( + "reason": CompanyRestService._company_token_match_reason( token, name_tokens ), - "score": ClientRestService._client_token_match_score( + "score": CompanyRestService._company_token_match_score( token, name_tokens ), } @@ -781,7 +787,7 @@ def _client_search_log_result( } @staticmethod - def _client_token_match_reason(query_token: str, name_tokens: list[str]) -> str: + def _company_token_match_reason(query_token: str, name_tokens: list[str]) -> str: if query_token in name_tokens: return "token_exact" if any(token.startswith(query_token) for token in name_tokens): @@ -789,118 +795,120 @@ def _client_token_match_reason(query_token: str, name_tokens: list[str]) -> str: return "no_match" @staticmethod - def _format_client_summary(client: Client) -> Dict[str, Any]: + def _format_company_summary(company: Company) -> Dict[str, Any]: """ - Formats a single client summary for list/search responses. + Formats a single company summary for list/search responses. """ return { - "id": str(client.id), - "name": client.name, - "email": client.email or "", - "address": client.address or "", - "is_account_customer": client.is_account_customer, - "is_supplier": client.is_supplier, - "allow_jobs": client.allow_jobs, - "xero_contact_id": client.xero_contact_id or "", - "last_invoice_date": date_to_datetime(client.last_invoice_date), - "total_spend": f"${client.total_spend:,.2f}", + "id": str(company.id), + "name": company.name, + "email": company.email or "", + "address": company.address or "", + "is_account_customer": company.is_account_customer, + "is_supplier": company.is_supplier, + "allow_jobs": company.allow_jobs, + "xero_contact_id": company.xero_contact_id or "", + "last_invoice_date": date_to_datetime(company.last_invoice_date), + "total_spend": f"${company.total_spend:,.2f}", } @staticmethod - def _format_client_search_results(clients) -> List[Dict[str, Any]]: + def _format_company_search_results(companies) -> List[Dict[str, Any]]: """ - Formats client search results for API response. + Formats company search results for API response. """ - return [ClientRestService._format_client_summary(client) for client in clients] + return [ + CompanyRestService._format_company_summary(company) for company in companies + ] @staticmethod - def _format_client_detail(client: Client) -> Dict[str, Any]: + def _format_company_detail(company: Company) -> Dict[str, Any]: """ - Formats complete client details for API response. + Formats complete company details for API response. """ return { - "id": str(client.id), - "name": client.name, - "email": client.email or "", - "address": client.address or "", - "is_account_customer": client.is_account_customer, - "is_supplier": client.is_supplier, - "allow_jobs": client.allow_jobs, - "xero_contact_id": client.xero_contact_id or "", - "xero_tenant_id": client.xero_tenant_id or "", - "primary_contact_name": client.primary_contact_name or "", - "primary_contact_email": client.primary_contact_email or "", - "additional_contact_persons": client.additional_contact_persons or [], - "xero_last_modified": client.xero_last_modified, - "xero_last_synced": client.xero_last_synced, - "xero_archived": client.xero_archived, - "xero_merged_into_id": client.xero_merged_into_id or "", - "merged_into": str(client.merged_into.id) if client.merged_into else None, - "django_created_at": client.django_created_at, - "django_updated_at": client.django_updated_at, - "last_invoice_date": date_to_datetime(client.last_invoice_date), - "total_spend": f"${client.total_spend:,.2f}", + "id": str(company.id), + "name": company.name, + "email": company.email or "", + "address": company.address or "", + "is_account_customer": company.is_account_customer, + "is_supplier": company.is_supplier, + "allow_jobs": company.allow_jobs, + "xero_contact_id": company.xero_contact_id or "", + "xero_tenant_id": company.xero_tenant_id or "", + "primary_contact_name": company.primary_contact_name or "", + "primary_contact_email": company.primary_contact_email or "", + "additional_contact_persons": company.additional_contact_persons or [], + "xero_last_modified": company.xero_last_modified, + "xero_last_synced": company.xero_last_synced, + "xero_archived": company.xero_archived, + "xero_merged_into_id": company.xero_merged_into_id or "", + "merged_into": str(company.merged_into.id) if company.merged_into else None, + "django_created_at": company.django_created_at, + "django_updated_at": company.django_updated_at, + "last_invoice_date": date_to_datetime(company.last_invoice_date), + "total_spend": f"${company.total_spend:,.2f}", } @staticmethod - def _create_client_in_xero(client_data: Dict[str, Any]) -> Client: + def _create_company_in_xero(company_data: Dict[str, Any]) -> Company: """ - Creates client in the accounting provider and locally. + Creates company in the accounting provider and locally. """ provider = get_provider() - name = client_data["name"] + name = company_data["name"] # Check for duplicates in accounting provider existing = provider.search_contact_by_name(name) if existing is not None: raise ValueError( - f"Client '{name}' already exists in {provider.provider_name}" + f"Company '{name}' already exists in {provider.provider_name}" f" with ID: {existing.external_id}" ) - # Create local client first + # Create local company first with transaction.atomic(): - client = Client.objects.create( + company = Company.objects.create( name=name, - email=client_data.get("email") or "", - address=client_data.get("address") or "", - is_account_customer=client_data.get("is_account_customer", True), + email=company_data.get("email") or "", + address=company_data.get("address") or "", + is_account_customer=company_data.get("is_account_customer", True), xero_last_modified=timezone.now(), ) - # Push to accounting provider (persists xero_contact_id on the client object) - result = provider.create_contact(client) + # Push to accounting provider (persists xero_contact_id on the company object) + result = provider.create_contact(company) if not result.success: - client_id = client.id - client.delete() + company_id = company.id + company.delete() logger.warning( - "Deleted local client after accounting provider create failure", + "Deleted local company after accounting provider create failure", extra={ - "client_id": str(client_id), - "client_name": name, + "company_id": str(company_id), + "company_name": name, "provider": provider.provider_name, - "operation": "create_client_in_xero_cleanup", + "operation": "create_company_in_xero_cleanup", }, ) raise ValueError( - f"Failed to create client in {provider.provider_name}: {result.error}" + f"Failed to create company in {provider.provider_name}: {result.error}" ) logger.info( - f"Client {client.id} created locally and in {provider.provider_name}", + f"Company {company.id} created locally and in {provider.provider_name}", extra={ - "client_id": str(client.id), - "client_name": client.name, - "xero_contact_id": client.xero_contact_id, - "operation": "create_client_in_xero", + "company_id": str(company.id), + "company_name": company.name, + "xero_contact_id": company.xero_contact_id, + "operation": "create_company_in_xero", }, ) - return client + return company @staticmethod - def _update_client_in_xero(client: Client, data: Dict[str, Any]) -> Client: + def _update_company_in_xero(company: Company, data: Dict[str, Any]) -> Company: """ - Updates client locally and in the accounting provider. + Updates company locally and in the accounting provider. """ provider = get_provider() @@ -911,16 +919,16 @@ def _update_client_in_xero(client: Client, data: Dict[str, Any]) -> Client: # Update local fields first with transaction.atomic(): - client.name = data.get("name", client.name) - client.email = data.get("email", client.email) - client.address = data.get("address", client.address) - client.is_account_customer = data.get( - "is_account_customer", client.is_account_customer + company.name = data.get("name", company.name) + company.email = data.get("email", company.email) + company.address = data.get("address", company.address) + company.is_account_customer = data.get( + "is_account_customer", company.is_account_customer ) if "allow_jobs" in data: - client.allow_jobs = data["allow_jobs"] - client.xero_last_modified = timezone.now() - client.save() + company.allow_jobs = data["allow_jobs"] + company.xero_last_modified = timezone.now() + company.save() # FIXME: `allow_jobs` is a local-only field (not synced to Xero) but # toggling it still routes through this method, which unconditionally @@ -928,56 +936,56 @@ def _update_client_in_xero(client: Client, data: Dict[str, Any]) -> Client: # Xero API quota and -- more concerning -- can fool the next sync # into thinking local state is newer than remote, potentially # clobbering a genuine Xero-side change. Fix: either split into a - # local-only `_update_client_locally` path for flags like + # local-only `_update_company_locally` path for flags like # `allow_jobs`, or detect when `data` contains only local-only keys # and skip the push + timestamp bump. - # Push updated client to accounting provider - result = provider.update_contact(client) + # Push updated company to accounting provider + result = provider.update_contact(company) if not result.success: raise ValueError( - f"Failed to update client in {provider.provider_name}: {result.error}" + f"Failed to update company in {provider.provider_name}: {result.error}" ) logger.info( - f"Client {client.id} updated locally and in {provider.provider_name}", + f"Company {company.id} updated locally and in {provider.provider_name}", extra={ - "client_id": str(client.id), - "client_name": client.name, - "xero_contact_id": client.xero_contact_id, - "operation": "_update_client_in_xero", + "company_id": str(company.id), + "company_name": company.name, + "xero_contact_id": company.xero_contact_id, + "operation": "_update_company_in_xero", }, ) - return client + return company @staticmethod - def get_client_jobs(client_id: UUID) -> List[Dict[str, Any]]: + def get_company_jobs(company_id: UUID) -> List[Dict[str, Any]]: """ - Retrieves all jobs for a specific client. + Retrieves all jobs for a specific company. Args: - client_id: Client UUID + company_id: Company UUID Returns: List of job header dictionaries Raises: - ValueError: If client not found + ValueError: If company not found """ try: - # Guard clause - verify client exists - if not Client.objects.filter(id=client_id).exists(): - raise ValueError(f"Client with id {client_id} not found") + # Guard clause - verify company exists + if not Company.objects.filter(id=company_id).exists(): + raise ValueError(f"Company with id {company_id} not found") # Import here to avoid circular imports from apps.job.models import Job - # Get all jobs for this client using JOB_DIRECT_FIELDS as source of truth - query_fields = ["id", "client_id"] + Job.JOB_DIRECT_FIELDS + # Get all jobs for this company using JOB_DIRECT_FIELDS as source of truth + query_fields = ["id", "company_id"] + Job.JOB_DIRECT_FIELDS jobs = ( - Job.objects.filter(client_id=client_id) + Job.objects.filter(company_id=company_id) # quote joined in because job.quoted reads it per job below - .select_related("client", "quote") + .select_related("company", "quote") .only(*query_fields, "quote__id") .order_by("-job_number") ) @@ -988,9 +996,9 @@ def get_client_jobs(client_id: UUID) -> List[Dict[str, Any]]: "job_id": str(job.id), "job_number": job.job_number, "name": job.name, - "client": ( - {"id": str(job.client.id), "name": job.client.name} - if job.client + "company": ( + {"id": str(job.company.id), "name": job.company.name} + if job.company else None ), "status": job.status, diff --git a/apps/client/services/duplicate_phone_report.py b/apps/company/services/duplicate_phone_report.py similarity index 76% rename from apps/client/services/duplicate_phone_report.py rename to apps/company/services/duplicate_phone_report.py index 656cde169..71af7d791 100644 --- a/apps/client/services/duplicate_phone_report.py +++ b/apps/company/services/duplicate_phone_report.py @@ -1,9 +1,9 @@ -"""Data Quality report: phone numbers that break the one-number-one-client rule. +"""Data Quality report: phone numbers that break the one-number-one-company rule. Surfaces the two ways a phone number can be mis-owned, for manual clean-up: -- ``cross_client`` — a number whose effective clients (``COALESCE(client_id, - contact.client_id)``) number more than one (the grandfathered pre-existing links). -- ``internal_line`` — a client/contact phone method whose number is one of the +- ``cross_client`` — a number whose effective companies (``COALESCE(company_id, + contact.company_id)``) number more than one (the grandfathered pre-existing links). +- ``internal_line`` — a company/contact phone method whose number is one of the company's own internal ``PhoneEndpoint`` lines (e.g. a staff line mis-filed as a customer contact). """ @@ -15,7 +15,7 @@ from django.db.models.functions import Coalesce from django.utils import timezone -from apps.client.models import ClientContactMethod +from apps.company.models import ClientContactMethod from apps.crm.models import PhoneEndpoint @@ -48,18 +48,18 @@ class DuplicatePhoneReportService: """Builds the "Duplicate phones" data-quality report.""" def get_report(self) -> DuplicatePhonesReport: - cross_client = self._cross_client_conflicts() + cross_company = self._cross_company_conflicts() internal_line = self._internal_line_collisions() return { - "duplicate_phones": cross_client + internal_line, + "duplicate_phones": cross_company + internal_line, "summary": { - "cross_client": len(cross_client), + "cross_client": len(cross_company), "internal_line": len(internal_line), }, "checked_at": timezone.now(), } - def _cross_client_conflicts(self) -> list[DuplicatePhoneIssue]: + def _cross_company_conflicts(self) -> list[DuplicatePhoneIssue]: phones = ClientContactMethod.objects.filter( method_type=ClientContactMethod.MethodType.PHONE ) @@ -67,18 +67,18 @@ def _cross_client_conflicts(self) -> list[DuplicatePhoneIssue]: row["normalized_value"] for row in phones.values("normalized_value") .annotate( - clients=Count( - Coalesce("client_id", "contact__client_id"), distinct=True + companies=Count( + Coalesce("company_id", "contact__company_id"), distinct=True ) ) - .filter(clients__gt=1) + .filter(companies__gt=1) ] if not conflict_numbers: return [] grouped: dict[str, list[DuplicatePhoneOwner]] = {} for method in ( phones.filter(normalized_value__in=conflict_numbers) - .select_related("client", "contact", "contact__client") + .select_related("company", "contact", "contact__company") .order_by("normalized_value", "id") ): grouped.setdefault(method.normalized_value, []).append(self._owner(method)) @@ -112,19 +112,19 @@ def _internal_line_collisions(self) -> list[DuplicatePhoneIssue]: method_type=ClientContactMethod.MethodType.PHONE, normalized_value__in=list(endpoint_labels), ) - .select_related("client", "contact", "contact__client") + .select_related("company", "contact", "contact__company") .order_by("normalized_value", "id") ) ] @staticmethod def _owner(method: ClientContactMethod) -> DuplicatePhoneOwner: - effective_client_id = method.owner_client_id() + effective_company_id = method.owner_company_id() return { "method_id": str(method.id), - "owner_kind": "client" if method.client_id else "contact", + "owner_kind": "company" if method.company_id else "contact", "owner_name": method.owner_display_name(), "effective_client_id": ( - str(effective_client_id) if effective_client_id else None + str(effective_company_id) if effective_company_id else None ), } diff --git a/apps/client/services/geocoding_service.py b/apps/company/services/geocoding_service.py similarity index 100% rename from apps/client/services/geocoding_service.py rename to apps/company/services/geocoding_service.py diff --git a/apps/client/tests/__init__.py b/apps/company/tests/__init__.py similarity index 100% rename from apps/client/tests/__init__.py rename to apps/company/tests/__init__.py diff --git a/apps/client/tests/test_client_fts_search.py b/apps/company/tests/test_company_fts_search.py similarity index 57% rename from apps/client/tests/test_client_fts_search.py rename to apps/company/tests/test_company_fts_search.py index 1abb00afc..2a0d08fdd 100644 --- a/apps/client/tests/test_client_fts_search.py +++ b/apps/company/tests/test_company_fts_search.py @@ -1,4 +1,4 @@ -"""Python-owned client-name search regression coverage. +"""Python-owned company-name search regression coverage. Postgres may narrow candidates for performance, but Python owns final matching and ordering. These tests pin the user-facing behavior, including prefix @@ -12,80 +12,82 @@ from django.test import RequestFactory from django.utils import timezone -from apps.client.models import Client -from apps.client.services.client_rest_service import ClientRestService +from apps.company.models import Company +from apps.company.services.company_rest_service import CompanyRestService from apps.workflow.models import SearchTelemetryEvent -def _make_client(name: str, **overrides): +def _make_company(name: str, **overrides): defaults = { "name": name, "xero_last_modified": timezone.now(), "allow_jobs": True, } defaults.update(overrides) - return Client.objects.create(**defaults) + return Company.objects.create(**defaults) -def _make_search_noise_clients(count: int = 2000): +def _make_search_noise_companies(count: int = 2000): """ - Client search bugs hide in tiny fixtures: a candidate filter that admits + Company search bugs hide in tiny fixtures: a candidate filter that admits substring false positives can still look correct until real matches are buried under production-sized noise. """ now = timezone.now() - clients = [] + companies = [] for index in range(count): if index % 5 == 0: - name = f"AlphaFume Noise Client {index:04d}" + name = f"AlphaFume Noise Company {index:04d}" elif index % 5 == 1: - name = f"Smartin Redwood Client {index:04d}" + name = f"Smartin Redwood Company {index:04d}" else: - name = f"Noise Client {index:04d}" + name = f"Noise Company {index:04d}" - clients.append( - Client( + companies.append( + Company( name=name, xero_last_modified=now, allow_jobs=True, ) ) - Client.objects.bulk_create(clients) + Company.objects.bulk_create(companies) @pytest.fixture def hynds(db): - return _make_client("Hynds Pipe Systems") + return _make_company("Hynds Pipe Systems") @pytest.fixture def dog_food(db): - return _make_client("Acme Dog Food Ltd") + return _make_company("Acme Dog Food Ltd") @pytest.fixture def food_dog(db): - return _make_client("Food Dog Imports") + return _make_company("Food Dog Imports") def test_multi_token_query_matches_in_any_order(hynds): """The Hynds-Pipe-Systems case: tokens in any order must match.""" - result = ClientRestService.list_clients(query="Hynds Systems", page=1, page_size=10) + result = CompanyRestService.list_companies( + query="Hynds Systems", page=1, page_size=10 + ) names = [c["name"] for c in result["results"]] assert "Hynds Pipe Systems" in names def test_quoted_phrase_outranks_scattered_tokens(dog_food, food_dog): """Quoted "Dog Food" must rank the contiguous-phrase row first.""" - result = ClientRestService.list_clients(query='"Dog Food"', page=1, page_size=10) + result = CompanyRestService.list_companies(query='"Dog Food"', page=1, page_size=10) names = [c["name"] for c in result["results"]] assert names[0] == "Acme Dog Food Ltd" def test_unquoted_tokens_match_both_orders(dog_food, food_dog): """Unquoted `dog food` matches both orderings (AND semantics).""" - result = ClientRestService.list_clients(query="dog food", page=1, page_size=10) + result = CompanyRestService.list_companies(query="dog food", page=1, page_size=10) names = {c["name"] for c in result["results"]} assert names == {"Acme Dog Food Ltd", "Food Dog Imports"} @@ -95,18 +97,18 @@ def test_single_token_query_matches(hynds): one word into the search box) silently returns nothing — every user experiences a broken search. """ - result = ClientRestService.list_clients(query="Hynds", page=1, page_size=10) + result = CompanyRestService.list_companies(query="Hynds", page=1, page_size=10) names = [c["name"] for c in result["results"]] assert "Hynds Pipe Systems" in names def test_partial_name_query_matches_business_name_prefix(db): """Production regression: `FUME` must find `Fumecare Ltd`.""" - _make_search_noise_clients() - _make_client("Fumecare Ltd") - _make_client("Acme Co") + _make_search_noise_companies() + _make_company("Fumecare Ltd") + _make_company("Acme Co") - result = ClientRestService.list_clients(query="FUME", page=1, page_size=10) + result = CompanyRestService.list_companies(query="FUME", page=1, page_size=10) names = [c["name"] for c in result["results"]] assert names == ["Fumecare Ltd"] @@ -115,9 +117,9 @@ def test_partial_name_query_matches_business_name_prefix(db): def test_full_name_token_query_still_matches_business_name(db): """Catches the direct-name branch breaking normal full-token lookup.""" - _make_client("Fumecare Ltd") + _make_company("Fumecare Ltd") - result = ClientRestService.list_clients(query="Fumecare", page=1, page_size=10) + result = CompanyRestService.list_companies(query="Fumecare", page=1, page_size=10) names = [c["name"] for c in result["results"]] assert names == ["Fumecare Ltd"] @@ -125,21 +127,21 @@ def test_full_name_token_query_still_matches_business_name(db): def test_prefix_token_score_beats_internal_substring_score(db): """`FUME` is a stronger signal for Fumecare than internal `umec`.""" - tokens = ClientRestService._client_search_tokens("FUME") - internal_tokens = ClientRestService._client_search_tokens("umec") + tokens = CompanyRestService._company_search_tokens("FUME") + internal_tokens = CompanyRestService._company_search_tokens("umec") - assert ClientRestService._client_name_score( + assert CompanyRestService._company_name_score( "Fumecare Ltd", "FUME", tokens - ) < ClientRestService._client_name_score("Fumecare Ltd", "umec", internal_tokens) + ) < CompanyRestService._company_name_score("Fumecare Ltd", "umec", internal_tokens) def test_prefix_name_match_ranks_above_internal_substring_match(db): """A name-leading token prefix outranks a later token prefix.""" - _make_client("Fumecare Ltd") - _make_client("Safe Fume Handling") - _make_client("Acme Co") + _make_company("Fumecare Ltd") + _make_company("Safe Fume Handling") + _make_company("Acme Co") - result = ClientRestService.list_clients(query="FUME", page=1, page_size=10) + result = CompanyRestService.list_companies(query="FUME", page=1, page_size=10) names = [c["name"] for c in result["results"]] assert names == ["Fumecare Ltd", "Safe Fume Handling"] @@ -147,10 +149,10 @@ def test_prefix_name_match_ranks_above_internal_substring_match(db): def test_internal_substring_does_not_match_business_name(db): """`car` inside `Fumecare` is not a token-prefix business-name match.""" - _make_client("Fumecare Ltd") - _make_client("Carters Tools") + _make_company("Fumecare Ltd") + _make_company("Carters Tools") - result = ClientRestService.list_clients(query="car", page=1, page_size=10) + result = CompanyRestService.list_companies(query="car", page=1, page_size=10) names = [c["name"] for c in result["results"]] assert names == ["Carters Tools"] @@ -158,9 +160,9 @@ def test_internal_substring_does_not_match_business_name(db): def test_internal_fumecare_substring_does_not_match(db): """`umec` should score worse than `FUME` and should not be included.""" - _make_client("Fumecare Ltd") + _make_company("Fumecare Ltd") - result = ClientRestService.list_clients(query="umec", page=1, page_size=10) + result = CompanyRestService.list_companies(query="umec", page=1, page_size=10) assert result["results"] == [] assert result["count"] == 0 @@ -175,8 +177,8 @@ def test_no_match_returns_empty(db): but corrupts ranking in production by burying real matches under thousands of fake ones. """ - _make_client("Acme Co") - result = ClientRestService.list_clients( + _make_company("Acme Co") + result = CompanyRestService.list_companies( query="zzzqqxnonsense", page=1, page_size=10 ) assert result["results"] == [] @@ -192,13 +194,15 @@ def test_martin_wood_against_compound_name(db): `search_rank__gt=0` filter is reintroduced (because junk-rank rows sort above genuine matches once results are paginated). """ - _make_search_noise_clients() - _make_client("Martin, Price and Wood") - _make_client("Wood, Martin and Calhoun") - _make_client("Smith Co") - _make_client("Walton and Sons") - - result = ClientRestService.list_clients(query="Martin Wood", page=1, page_size=10) + _make_search_noise_companies() + _make_company("Martin, Price and Wood") + _make_company("Wood, Martin and Calhoun") + _make_company("Smith Co") + _make_company("Walton and Sons") + + result = CompanyRestService.list_companies( + query="Martin Wood", page=1, page_size=10 + ) names = [c["name"] for c in result["results"]] assert "Martin, Price and Wood" in names assert "Wood, Martin and Calhoun" in names @@ -206,23 +210,23 @@ def test_martin_wood_against_compound_name(db): assert "Walton and Sons" not in names -def test_search_clients_top_n_respects_allow_jobs(db): - """search_clients() requires allow_jobs=True (job-eligible only).""" - _make_client("Acme Foo", allow_jobs=True) - _make_client("Acme Bar", allow_jobs=False) +def test_search_companies_top_n_respects_allow_jobs(db): + """search_companies() requires allow_jobs=True (job-eligible only).""" + _make_company("Acme Foo", allow_jobs=True) + _make_company("Acme Bar", allow_jobs=False) - results = ClientRestService.search_clients("Acme", limit=10) + results = CompanyRestService.search_companies("Acme", limit=10) names = [r["name"] for r in results] assert "Acme Foo" in names assert "Acme Bar" not in names -def test_search_clients_matches_partial_business_name(db): - """Top-N client lookup uses the same Python-owned name search.""" - _make_client("Fumecare Ltd") - _make_client("Acme Co") +def test_search_companies_matches_partial_business_name(db): + """Top-N company lookup uses the same Python-owned name search.""" + _make_company("Fumecare Ltd") + _make_company("Acme Co") - results = ClientRestService.search_clients("FUME", limit=10) + results = CompanyRestService.search_companies("FUME", limit=10) names = [r["name"] for r in results] assert names == ["Fumecare Ltd"] @@ -230,57 +234,57 @@ def test_search_clients_matches_partial_business_name(db): def test_candidate_filter_is_superset_of_python_name_matching(db): """Postgres candidate filtering must not decide final search semantics.""" - clients = [ - _make_client("Fumecare Ltd"), - _make_client("Hynds Pipe Systems"), - _make_client("Pipe Hynds Systems"), - _make_client("Acme Co"), + companies = [ + _make_company("Fumecare Ltd"), + _make_company("Hynds Pipe Systems"), + _make_company("Pipe Hynds Systems"), + _make_company("Acme Co"), ] - tokens = ClientRestService._client_search_tokens("Hynds Systems") + tokens = CompanyRestService._company_search_tokens("Hynds Systems") candidate_ids = set( - Client.objects.filter( - ClientRestService._client_name_candidate_filter(tokens) + Company.objects.filter( + CompanyRestService._company_name_candidate_filter(tokens) ).values_list("id", flat=True) ) python_ids = { - client.id - for client in clients - if ClientRestService._client_name_matches(client.name, tokens) + company.id + for company in companies + if CompanyRestService._company_name_matches(company.name, tokens) } assert python_ids <= candidate_ids -def test_client_search_logging_emits_structured_json(db, caplog): +def test_company_search_logging_emits_structured_json(db, caplog): """Catches search telemetry drifting from the Kanban logging style.""" - client = _make_client("Fumecare Ltd") - result = ClientRestService.list_clients(query="FUME", page=1, page_size=10) - request = RequestFactory().get("/api/clients/search/", {"q": "FUME"}) + company = _make_company("Fumecare Ltd") + result = CompanyRestService.list_companies(query="FUME", page=1, page_size=10) + request = RequestFactory().get("/api/companies/search/", {"q": "FUME"}) request.user = None assert result["count"] == 1 - assert result["results"][0]["id"] == str(client.id) + assert result["results"][0]["id"] == str(company.id) - with caplog.at_level(logging.INFO, logger="client_search"): - ClientRestService.log_client_search_results( + with caplog.at_level(logging.INFO, logger="company_search"): + CompanyRestService.log_company_search_results( request=request, - source="client_search", + source="company_search", query="FUME", - clients=result["results"], + companies=result["results"], total_count=result["count"], ) payload = json.loads(caplog.records[0].message) - assert payload["event"] == "client_search_results" + assert payload["event"] == "company_search_results" assert payload["query"] == "FUME" assert payload["query_string"] == "q=FUME" assert payload["result_count"] == 1 assert payload["returned_count"] == 1 assert payload["results"][0]["rank"] == 1 - assert payload["results"][0]["client_id"] == str(client.id) - assert payload["results"][0]["client_name"] == "Fumecare Ltd" + assert payload["results"][0]["company_id"] == str(company.id) + assert payload["results"][0]["company_name"] == "Fumecare Ltd" assert payload["results"][0]["search_reasons"][0]["reason"] == "token_prefix" event = SearchTelemetryEvent.objects.get( @@ -290,10 +294,10 @@ def test_client_search_logging_emits_structured_json(db, caplog): assert event.query == "FUME" assert event.normalized_query == "fume" assert event.result_count == 1 - assert event.returned_result_ids == [str(client.id)] + assert event.returned_result_ids == [str(company.id)] -def test_search_clients_short_query_is_rejected(db): +def test_search_companies_short_query_is_rejected(db): """The 3-character minimum-query guard returns [] without raising.""" - assert ClientRestService.search_clients("ab", limit=10) == [] - assert ClientRestService.search_clients("", limit=10) == [] + assert CompanyRestService.search_companies("ab", limit=10) == [] + assert CompanyRestService.search_companies("", limit=10) == [] diff --git a/apps/client/tests/test_client_invoice_summary.py b/apps/company/tests/test_company_invoice_summary.py similarity index 63% rename from apps/client/tests/test_client_invoice_summary.py rename to apps/company/tests/test_company_invoice_summary.py index 3e4dbe6ec..c91e2b055 100644 --- a/apps/client/tests/test_client_invoice_summary.py +++ b/apps/company/tests/test_company_invoice_summary.py @@ -10,24 +10,24 @@ from rest_framework.test import APIRequestFactory, force_authenticate from apps.accounting.models import Invoice -from apps.client.models import Client -from apps.client.services.client_rest_service import ClientRestService -from apps.client.utils import date_to_datetime -from apps.client.views.client_rest_views import ClientCreateRestView +from apps.company.models import Company +from apps.company.services.company_rest_service import CompanyRestService +from apps.company.utils import date_to_datetime +from apps.company.views.company_rest_views import CompanyCreateRestView from apps.testing import BaseTestCase from apps.workflow.accounting.types import ContactResult from apps.workflow.exceptions import AlreadyLoggedException -def _make_client(name: str) -> Client: - return Client.objects.create(name=name, xero_last_modified=timezone.now()) +def _make_company(name: str) -> Company: + return Company.objects.create(name=name, xero_last_modified=timezone.now()) -def _make_invoice(client: Client, invoice_date: date, amount: Decimal) -> Invoice: +def _make_invoice(company: Company, invoice_date: date, amount: Decimal) -> Invoice: return Invoice.objects.create( xero_id=uuid.uuid4(), number=f"INV-{uuid.uuid4().hex[:8]}", - client=client, + company=company, date=invoice_date, total_excl_tax=amount, tax=Decimal("0.00"), @@ -39,30 +39,30 @@ def _make_invoice(client: Client, invoice_date: date, amount: Decimal) -> Invoic def test_with_invoice_summary_sets_latest_invoice_date_and_total_spend(db): - """Client search can mislead credit decisions if invoice rollups drift. + """Company search can mislead credit decisions if invoice rollups drift. This catches aggregation changes that sum only one invoice or choose the wrong date by using two invoices where the latest date and total differ. """ - client = _make_client("Acme") - _make_invoice(client, date(2024, 1, 10), Decimal("100.00")) - _make_invoice(client, date(2024, 2, 20), Decimal("25.50")) + company = _make_company("Acme") + _make_invoice(company, date(2024, 1, 10), Decimal("100.00")) + _make_invoice(company, date(2024, 2, 20), Decimal("25.50")) - annotated = Client.objects.with_invoice_summary().get(id=client.id) + annotated = Company.objects.with_invoice_summary().get(id=company.id) assert annotated.last_invoice_date == date(2024, 2, 20) assert annotated.total_spend == Decimal("125.50") -def test_with_invoice_summary_handles_clients_without_invoices(db): - """New clients must render as zero-spend, not error or stale data. +def test_with_invoice_summary_handles_companies_without_invoices(db): + """New companies must render as zero-spend, not error or stale data. This catches annotation changes that leave null totals for the frontend to - format or accidentally join another client's invoice summary. + format or accidentally join another company's invoice summary. """ - client = _make_client("No Invoices") + company = _make_company("No Invoices") - annotated = Client.objects.with_invoice_summary().get(id=client.id) + annotated = Company.objects.with_invoice_summary().get(id=company.id) assert annotated.last_invoice_date is None assert annotated.total_spend == Decimal("0.00") @@ -72,36 +72,36 @@ def test_invoice_summary_properties_require_annotation(db): """Unannotated access must fail loudly instead of issuing hidden queries. This catches callers bypassing ``with_invoice_summary`` and reintroducing - per-client invoice lookups in list/search responses. + per-company invoice lookups in list/search responses. """ - client = _make_client("Unannotated") + company = _make_company("Unannotated") with pytest.raises(RuntimeError, match="with_invoice_summary"): - _ = client.last_invoice_date + _ = company.last_invoice_date with pytest.raises(RuntimeError, match="with_invoice_summary"): - _ = client.total_spend + _ = company.total_spend -def test_formatting_annotated_clients_does_not_query_invoice_metrics(db): +def test_formatting_annotated_companies_does_not_query_invoice_metrics(db): """Formatting search results must not reintroduce invoice N+1 queries. This catches a formatter refactor that reads invoice relations/properties per row by asserting all invoice-derived values come from annotations. """ - with_invoices = _make_client("With Invoices") - without_invoices = _make_client("Without Invoices") + with_invoices = _make_company("With Invoices") + without_invoices = _make_company("Without Invoices") _make_invoice(with_invoices, date(2024, 1, 1), Decimal("10.00")) _make_invoice(with_invoices, date(2024, 1, 2), Decimal("5.25")) - clients = list( - Client.objects.with_invoice_summary() + companies = list( + Company.objects.with_invoice_summary() .filter(id__in=[with_invoices.id, without_invoices.id]) .order_by("name") ) with CaptureQueriesContext(connection) as captured: - formatted = ClientRestService._format_client_search_results(clients) + formatted = CompanyRestService._format_company_search_results(companies) assert len(captured) == 0 assert formatted == [ @@ -132,12 +132,12 @@ def test_formatting_annotated_clients_does_not_query_invoice_metrics(db): ] -class ClientCreateInvoiceSummaryTests(BaseTestCase): - def test_create_client_response_formats_annotated_invoice_summary(self): - """Create responses must match the client-search summary contract. +class CompanyCreateInvoiceSummaryTests(BaseTestCase): + def test_create_company_response_formats_annotated_invoice_summary(self): + """Create responses must match the company-search summary contract. - This catches a view refactor that returns a raw Client payload without - annotated invoice defaults, which would break newly-created client rows + This catches a view refactor that returns a raw Company payload without + annotated invoice defaults, which would break newly-created company rows in the frontend. """ provider = MagicMock() @@ -146,13 +146,13 @@ def test_create_client_response_formats_annotated_invoice_summary(self): provider.create_contact.return_value = ContactResult( success=True, external_id="xero-contact-id", - name="New Client", + name="New Company", ) request = APIRequestFactory().post( - "/api/clients/create/", + "/api/companies/create/", { - "name": "New Client", + "name": "New Company", "email": "new@example.test", "address": "", "is_account_customer": True, @@ -162,18 +162,18 @@ def test_create_client_response_formats_annotated_invoice_summary(self): force_authenticate(request, user=self.test_staff) with patch( - "apps.client.services.client_rest_service.get_provider", + "apps.company.services.company_rest_service.get_provider", return_value=provider, ): - response = ClientCreateRestView.as_view()(request) + response = CompanyCreateRestView.as_view()(request) assert response.status_code == 201 - assert response.data["client"]["name"] == "New Client" - assert response.data["client"]["last_invoice_date"] is None - assert response.data["client"]["total_spend"] == "$0.00" + assert response.data["company"]["name"] == "New Company" + assert response.data["company"]["last_invoice_date"] is None + assert response.data["company"]["total_spend"] == "$0.00" - def test_create_client_cleans_up_local_row_when_xero_create_fails(self): - """Failed Xero contact creation must not leave a half-created client.""" + def test_create_company_cleans_up_local_row_when_xero_create_fails(self): + """Failed Xero contact creation must not leave a half-created company.""" provider = MagicMock() provider.provider_name = "Xero" provider.get_valid_token.return_value = {"access_token": "token"} @@ -184,17 +184,17 @@ def test_create_client_cleans_up_local_row_when_xero_create_fails(self): ) with patch( - "apps.client.services.client_rest_service.get_provider", + "apps.company.services.company_rest_service.get_provider", return_value=provider, ): with pytest.raises(AlreadyLoggedException, match="RemoteDisconnected"): - ClientRestService.create_client( + CompanyRestService.create_company( { - "name": "Failed Xero Client", + "name": "Failed Xero Company", "email": "failed@example.test", "address": "", "is_account_customer": True, } ) - assert not Client.objects.filter(name="Failed Xero Client").exists() + assert not Company.objects.filter(name="Failed Xero Company").exists() diff --git a/apps/company/tests/test_company_jobs_queries.py b/apps/company/tests/test_company_jobs_queries.py new file mode 100644 index 000000000..97903e545 --- /dev/null +++ b/apps/company/tests/test_company_jobs_queries.py @@ -0,0 +1,31 @@ +from __future__ import annotations + +from django.db import connection +from django.test.utils import CaptureQueriesContext + +from apps.company.models import Company +from apps.company.services.company_rest_service import CompanyRestService +from apps.job.models import Job +from apps.testing import BaseTestCase + + +class CompanyJobsQueryTests(BaseTestCase): + """get_company_jobs reads job.quoted per job; without select_related on + quote that lazy-loads once per job and the dev/E2E n+1 guard raises.""" + + def test_get_company_jobs_does_not_lazy_load_quotes(self) -> None: + company_obj = Company.objects.create( + name="Company Jobs Query Company", + email="company-jobs-queries@example.com", + xero_last_modified="2024-01-01T00:00:00Z", + ) + for name in ("Company Jobs Query Job 1", "Company Jobs Query Job 2"): + job = Job(name=name, company=company_obj) + job.save(staff=self.test_staff) + + with CaptureQueriesContext(connection) as ctx: + jobs = CompanyRestService.get_company_jobs(company_obj.id) + + self.assertEqual(len(jobs), 2) + # exists() guard + the jobs query (quote joined in, not lazy-loaded) + self.assertLessEqual(len(ctx.captured_queries), 2) diff --git a/apps/client/tests/test_client_merge_service.py b/apps/company/tests/test_company_merge_service.py similarity index 74% rename from apps/client/tests/test_client_merge_service.py rename to apps/company/tests/test_company_merge_service.py index 4035412de..413af31b5 100644 --- a/apps/client/tests/test_client_merge_service.py +++ b/apps/company/tests/test_company_merge_service.py @@ -1,7 +1,7 @@ """ -Tests for apps.client.services.client_merge_service. +Tests for apps.company.services.company_merge_service. -Covers the 8 FK fields that point at Client, plus chain walking, circular +Covers the 8 FK fields that point at Company, plus chain walking, circular chains, idempotency, the source==destination guard, atomic rollback on failure, and the JobEvent audit trail on Job reassignment. """ @@ -15,8 +15,8 @@ from django.utils import timezone from apps.accounting.models import Bill, CreditNote, Invoice, Quote -from apps.client.models import Client, ClientContact, ClientContactMethod -from apps.client.services.client_merge_service import reassign_client_fk_records +from apps.company.models import ClientContact, ClientContactMethod, Company +from apps.company.services.company_merge_service import reassign_company_fk_records from apps.crm.models import PhoneCallRecord from apps.job.models import Job, JobEvent from apps.purchasing.models import PurchaseOrder @@ -28,8 +28,8 @@ # --------------------------------------------------------------------------- -def make_client(name: str) -> Client: - return Client.objects.create( +def make_company(name: str) -> Company: + return Company.objects.create( name=name, xero_last_modified=timezone.now(), ) @@ -38,22 +38,22 @@ def make_client(name: str) -> Client: _next_job_number = {"n": 90000} -def make_job(client: Client, staff, *, name: str = "Test Job") -> Job: +def make_job(company: Company, staff, *, name: str = "Test Job") -> Job: _next_job_number["n"] += 1 job = Job( name=name, job_number=_next_job_number["n"], - client=client, + company=company, ) job.save(staff=staff) return job -def _invoice_fields(client: Client) -> dict: +def _invoice_fields(company: Company) -> dict: return { "xero_id": uuid.uuid4(), "number": f"TEST-{uuid.uuid4().hex[:8]}", - "client": client, + "company": company, "date": date.today(), "total_excl_tax": Decimal("100.00"), "tax": Decimal("15.00"), @@ -64,36 +64,36 @@ def _invoice_fields(client: Client) -> dict: } -def make_invoice(client: Client) -> Invoice: - return Invoice.objects.create(**_invoice_fields(client)) +def make_invoice(company: Company) -> Invoice: + return Invoice.objects.create(**_invoice_fields(company)) -def make_bill(client: Client) -> Bill: - return Bill.objects.create(**_invoice_fields(client)) +def make_bill(company: Company) -> Bill: + return Bill.objects.create(**_invoice_fields(company)) -def make_credit_note(client: Client) -> CreditNote: - return CreditNote.objects.create(**_invoice_fields(client)) +def make_credit_note(company: Company) -> CreditNote: + return CreditNote.objects.create(**_invoice_fields(company)) -def make_quote(client: Client) -> Quote: +def make_quote(company: Company) -> Quote: return Quote.objects.create( xero_id=uuid.uuid4(), - client=client, + company=company, date=date.today(), total_excl_tax=Decimal("100.00"), total_incl_tax=Decimal("115.00"), ) -def make_purchase_order(supplier: Client) -> PurchaseOrder: +def make_purchase_order(supplier: Company) -> PurchaseOrder: return PurchaseOrder.objects.create( supplier=supplier, po_number=f"PO-{uuid.uuid4().hex[:8]}", ) -def make_supplier_price_list(supplier: Client) -> SupplierPriceList: +def make_supplier_price_list(supplier: Company) -> SupplierPriceList: return SupplierPriceList.objects.create( supplier=supplier, file_name="test.csv", @@ -101,7 +101,7 @@ def make_supplier_price_list(supplier: Client) -> SupplierPriceList: def make_supplier_product( - supplier: Client, price_list: SupplierPriceList + supplier: Company, price_list: SupplierPriceList ) -> SupplierProduct: return SupplierProduct.objects.create( supplier=supplier, @@ -113,12 +113,12 @@ def make_supplier_product( ) -def make_scrape_job(supplier: Client) -> ScrapeJob: +def make_scrape_job(supplier: Company) -> ScrapeJob: return ScrapeJob.objects.create(supplier=supplier) def make_phone_call( - client: Client, *, contact: ClientContact | None = None + company: Company, *, contact: ClientContact | None = None ) -> PhoneCallRecord: call_datetime = timezone.now() return PhoneCallRecord.objects.create( @@ -129,7 +129,7 @@ def make_phone_call( call_time=call_datetime.time(), origin="+6421555123", destination="+6496365131", - client=client, + company=company, contact=contact, raw_json={}, ) @@ -144,20 +144,20 @@ def make_phone_call( class ReassignFKBaseCase(BaseTestCase): def setUp(self) -> None: super().setUp() - self.source = make_client("Source Client") - self.destination = make_client("Destination Client") + self.source = make_company("Source Company") + self.destination = make_company("Destination Company") class ReassignJobTests(ReassignFKBaseCase): def test_job_moves_to_destination(self) -> None: job = make_job(self.source, self.test_staff) - counts = reassign_client_fk_records( + counts = reassign_company_fk_records( self.source, self.destination, self.test_staff ) job.refresh_from_db() - self.assertEqual(job.client_id, self.destination.id) + self.assertEqual(job.company_id, self.destination.id) self.assertEqual(counts["jobs"], 1) @@ -165,12 +165,12 @@ class ReassignInvoiceTests(ReassignFKBaseCase): def test_invoice_moves_to_destination(self) -> None: invoice = make_invoice(self.source) - counts = reassign_client_fk_records( + counts = reassign_company_fk_records( self.source, self.destination, self.test_staff ) invoice.refresh_from_db() - self.assertEqual(invoice.client_id, self.destination.id) + self.assertEqual(invoice.company_id, self.destination.id) self.assertEqual(counts["invoices"], 1) @@ -178,12 +178,12 @@ class ReassignBillTests(ReassignFKBaseCase): def test_bill_moves_to_destination(self) -> None: bill = make_bill(self.source) - counts = reassign_client_fk_records( + counts = reassign_company_fk_records( self.source, self.destination, self.test_staff ) bill.refresh_from_db() - self.assertEqual(bill.client_id, self.destination.id) + self.assertEqual(bill.company_id, self.destination.id) self.assertEqual(counts["bills"], 1) @@ -191,12 +191,12 @@ class ReassignCreditNoteTests(ReassignFKBaseCase): def test_credit_note_moves_to_destination(self) -> None: cn = make_credit_note(self.source) - counts = reassign_client_fk_records( + counts = reassign_company_fk_records( self.source, self.destination, self.test_staff ) cn.refresh_from_db() - self.assertEqual(cn.client_id, self.destination.id) + self.assertEqual(cn.company_id, self.destination.id) self.assertEqual(counts["credit_notes"], 1) @@ -204,12 +204,12 @@ class ReassignQuoteTests(ReassignFKBaseCase): def test_quote_moves_to_destination(self) -> None: quote = make_quote(self.source) - counts = reassign_client_fk_records( + counts = reassign_company_fk_records( self.source, self.destination, self.test_staff ) quote.refresh_from_db() - self.assertEqual(quote.client_id, self.destination.id) + self.assertEqual(quote.company_id, self.destination.id) self.assertEqual(counts["quotes"], 1) @@ -217,7 +217,7 @@ class ReassignPurchaseOrderTests(ReassignFKBaseCase): def test_purchase_order_supplier_moves_to_destination(self) -> None: po = make_purchase_order(self.source) - counts = reassign_client_fk_records( + counts = reassign_company_fk_records( self.source, self.destination, self.test_staff ) @@ -231,7 +231,7 @@ def test_supplier_product_moves_to_destination(self) -> None: price_list = make_supplier_price_list(self.source) product = make_supplier_product(self.source, price_list) - counts = reassign_client_fk_records( + counts = reassign_company_fk_records( self.source, self.destination, self.test_staff ) @@ -244,7 +244,7 @@ class ReassignSupplierPriceListTests(ReassignFKBaseCase): def test_supplier_price_list_moves_to_destination(self) -> None: price_list = make_supplier_price_list(self.source) - counts = reassign_client_fk_records( + counts = reassign_company_fk_records( self.source, self.destination, self.test_staff ) @@ -257,7 +257,7 @@ class ReassignScrapeJobTests(ReassignFKBaseCase): def test_scrape_job_moves_to_destination(self) -> None: scrape = make_scrape_job(self.source) - counts = reassign_client_fk_records( + counts = reassign_company_fk_records( self.source, self.destination, self.test_staff ) @@ -283,7 +283,7 @@ def test_all_fk_types_move_in_one_call(self) -> None: product = make_supplier_product(self.source, price_list) scrape = make_scrape_job(self.source) - counts = reassign_client_fk_records( + counts = reassign_company_fk_records( self.source, self.destination, self.test_staff ) @@ -297,11 +297,11 @@ def test_all_fk_types_move_in_one_call(self) -> None: product.refresh_from_db() scrape.refresh_from_db() - self.assertEqual(job.client_id, self.destination.id) - self.assertEqual(invoice.client_id, self.destination.id) - self.assertEqual(bill.client_id, self.destination.id) - self.assertEqual(cn.client_id, self.destination.id) - self.assertEqual(quote.client_id, self.destination.id) + self.assertEqual(job.company_id, self.destination.id) + self.assertEqual(invoice.company_id, self.destination.id) + self.assertEqual(bill.company_id, self.destination.id) + self.assertEqual(cn.company_id, self.destination.id) + self.assertEqual(quote.company_id, self.destination.id) self.assertEqual(po.supplier_id, self.destination.id) self.assertEqual(price_list.supplier_id, self.destination.id) self.assertEqual(product.supplier_id, self.destination.id) @@ -328,21 +328,21 @@ def test_all_fk_types_move_in_one_call(self) -> None: class ReassignCrmHistoryTests(ReassignFKBaseCase): def test_contact_methods_and_phone_calls_move_to_destination(self) -> None: - contact = ClientContact.objects.create(client=self.source, name="Jane Smith") + contact = ClientContact.objects.create(company=self.source, name="Jane Smith") method = ClientContactMethod.objects.create( contact=contact, method_type=ClientContactMethod.MethodType.PHONE, value="021 555 123", ) - client_method = ClientContactMethod.objects.create( - client=self.source, + company_method = ClientContactMethod.objects.create( + company=self.source, method_type=ClientContactMethod.MethodType.PHONE, value="021 555 124", ) contact_call = make_phone_call(self.source, contact=contact) - client_call = make_phone_call(self.source) + company_call = make_phone_call(self.source) - counts = reassign_client_fk_records( + counts = reassign_company_fk_records( self.source, self.destination, self.test_staff, @@ -350,27 +350,27 @@ def test_contact_methods_and_phone_calls_move_to_destination(self) -> None: contact.refresh_from_db() method.refresh_from_db() - client_method.refresh_from_db() + company_method.refresh_from_db() contact_call.refresh_from_db() - client_call.refresh_from_db() + company_call.refresh_from_db() - self.assertEqual(contact.client_id, self.destination.id) + self.assertEqual(contact.company_id, self.destination.id) self.assertEqual(method.contact_id, contact.id) - self.assertEqual(client_method.client_id, self.destination.id) - self.assertEqual(contact_call.client_id, self.destination.id) + self.assertEqual(company_method.company_id, self.destination.id) + self.assertEqual(contact_call.company_id, self.destination.id) self.assertEqual(contact_call.contact_id, contact.id) - self.assertEqual(client_call.client_id, self.destination.id) + self.assertEqual(company_call.company_id, self.destination.id) self.assertEqual(counts["contacts"], 1) self.assertEqual(counts["contact_methods"], 1) self.assertEqual(counts["phone_calls"], 2) def test_exact_name_contact_conflict_merges_methods_and_calls(self) -> None: source_contact = ClientContact.objects.create( - client=self.source, + company=self.source, name="Jane Smith", ) destination_contact = ClientContact.objects.create( - client=self.destination, + company=self.destination, name="Jane Smith", ) method = ClientContactMethod.objects.create( @@ -380,7 +380,7 @@ def test_exact_name_contact_conflict_merges_methods_and_calls(self) -> None: ) call = make_phone_call(self.source, contact=source_contact) - counts = reassign_client_fk_records( + counts = reassign_company_fk_records( self.source, self.destination, self.test_staff, @@ -391,7 +391,7 @@ def test_exact_name_contact_conflict_merges_methods_and_calls(self) -> None: self.assertFalse(ClientContact.objects.filter(id=source_contact.id).exists()) self.assertEqual(method.contact_id, destination_contact.id) - self.assertEqual(call.client_id, self.destination.id) + self.assertEqual(call.company_id, self.destination.id) self.assertEqual(call.contact_id, destination_contact.id) self.assertEqual(counts["contacts"], 1) self.assertEqual(counts["contact_methods"], 1) @@ -399,7 +399,7 @@ def test_exact_name_contact_conflict_merges_methods_and_calls(self) -> None: class ContactMethodMergeGuardTests(ReassignFKBaseCase): - """Merges must never trip the one-number-one-client save() guard. + """Merges must never trip the one-number-one-company save() guard. Merging is the documented remedy the Duplicate Phones report points users at, so it has to succeed for exactly the data shapes the guard flags. @@ -407,11 +407,11 @@ class ContactMethodMergeGuardTests(ReassignFKBaseCase): NUMBER = "021 555 123" - def _grandfathered_method(self, client: Client) -> ClientContactMethod: - """Insert a cross-client duplicate bypassing the save() guard, + def _grandfathered_method(self, company: Company) -> ClientContactMethod: + """Insert a cross-company duplicate bypassing the save() guard, exactly as pre-guard legacy rows (and migration 0023 twins) exist.""" legacy = ClientContactMethod( - client=client, + company=company, method_type=ClientContactMethod.MethodType.PHONE, value=self.NUMBER, ) @@ -419,12 +419,12 @@ def _grandfathered_method(self, client: Client) -> ClientContactMethod: ClientContactMethod.objects.bulk_create([legacy]) return legacy - def test_same_number_on_client_and_own_contact_both_move(self) -> None: - """Migration 0023 creates client-level + contact-level twins; a merge + def test_same_number_on_company_and_own_contact_both_move(self) -> None: + """Migration 0023 creates company-level + contact-level twins; a merge must move both without the guard rejecting the not-yet-moved sibling.""" - contact = ClientContact.objects.create(client=self.source, name="Jane Smith") - client_method = ClientContactMethod.objects.create( - client=self.source, + contact = ClientContact.objects.create(company=self.source, name="Jane Smith") + company_method = ClientContactMethod.objects.create( + company=self.source, method_type=ClientContactMethod.MethodType.PHONE, value=self.NUMBER, ) @@ -434,47 +434,47 @@ def test_same_number_on_client_and_own_contact_both_move(self) -> None: value=self.NUMBER, ) - counts = reassign_client_fk_records( + counts = reassign_company_fk_records( self.source, self.destination, self.test_staff ) - client_method.refresh_from_db() + company_method.refresh_from_db() contact_method.refresh_from_db() contact.refresh_from_db() - self.assertEqual(client_method.client_id, self.destination.id) - self.assertEqual(contact.client_id, self.destination.id) + self.assertEqual(company_method.company_id, self.destination.id) + self.assertEqual(contact.company_id, self.destination.id) self.assertEqual(contact_method.contact_id, contact.id) self.assertEqual(counts["contact_methods"], 1) self.assertEqual(counts["contacts"], 1) - def test_merge_succeeds_when_third_client_owns_same_number(self) -> None: - """A grandfathered duplicate on an unrelated client must not block + def test_merge_succeeds_when_third_company_owns_same_number(self) -> None: + """A grandfathered duplicate on an unrelated company must not block merging A into B.""" source_method = ClientContactMethod.objects.create( - client=self.source, + company=self.source, method_type=ClientContactMethod.MethodType.PHONE, value=self.NUMBER, ) - third = make_client("Third Client") + third = make_company("Third Company") self._grandfathered_method(third) - counts = reassign_client_fk_records( + counts = reassign_company_fk_records( self.source, self.destination, self.test_staff ) source_method.refresh_from_db() - self.assertEqual(source_method.client_id, self.destination.id) + self.assertEqual(source_method.company_id, self.destination.id) self.assertEqual(counts["contact_methods"], 1) def test_duplicate_of_destination_number_is_dropped_not_duplicated(self) -> None: destination_method = ClientContactMethod.objects.create( - client=self.destination, + company=self.destination, method_type=ClientContactMethod.MethodType.PHONE, value=self.NUMBER, ) source_method = self._grandfathered_method(self.source) - counts = reassign_client_fk_records( + counts = reassign_company_fk_records( self.source, self.destination, self.test_staff ) @@ -492,23 +492,23 @@ def test_moving_primary_method_demotes_destination_primary(self) -> None: """The bulk move must not violate the single-primary-per-owner constraint when both sides have a primary phone.""" destination_primary = ClientContactMethod.objects.create( - client=self.destination, + company=self.destination, method_type=ClientContactMethod.MethodType.PHONE, value="021 555 999", is_primary=True, ) source_primary = ClientContactMethod.objects.create( - client=self.source, + company=self.source, method_type=ClientContactMethod.MethodType.PHONE, value=self.NUMBER, is_primary=True, ) - reassign_client_fk_records(self.source, self.destination, self.test_staff) + reassign_company_fk_records(self.source, self.destination, self.test_staff) destination_primary.refresh_from_db() source_primary.refresh_from_db() - self.assertEqual(source_primary.client_id, self.destination.id) + self.assertEqual(source_primary.company_id, self.destination.id) self.assertTrue(source_primary.is_primary) self.assertFalse(destination_primary.is_primary) @@ -520,10 +520,10 @@ def test_moving_primary_method_demotes_destination_primary(self) -> None: class SourceEqualsDestinationGuardTests(BaseTestCase): def test_raises_value_error_when_source_equals_destination(self) -> None: - client = make_client("Only Client") + company = make_company("Only Company") with self.assertRaises(ValueError): - reassign_client_fk_records(client, client, self.test_staff) + reassign_company_fk_records(company, company, self.test_staff) class IdempotencyTests(ReassignFKBaseCase): @@ -531,10 +531,10 @@ def test_second_call_returns_all_zero_counts(self) -> None: make_job(self.source, self.test_staff) make_invoice(self.source) - first_counts = reassign_client_fk_records( + first_counts = reassign_company_fk_records( self.source, self.destination, self.test_staff ) - second_counts = reassign_client_fk_records( + second_counts = reassign_company_fk_records( self.source, self.destination, self.test_staff ) @@ -545,11 +545,11 @@ def test_second_call_returns_all_zero_counts(self) -> None: class JobEventTests(ReassignFKBaseCase): - def test_job_reassignment_creates_client_changed_event(self) -> None: + def test_job_reassignment_creates_company_changed_event(self) -> None: job = make_job(self.source, self.test_staff) events_before = JobEvent.objects.filter(job=job).count() - reassign_client_fk_records(self.source, self.destination, self.test_staff) + reassign_company_fk_records(self.source, self.destination, self.test_staff) job.refresh_from_db() events_after = JobEvent.objects.filter(job=job).count() @@ -560,7 +560,7 @@ def test_job_reassignment_creates_client_changed_event(self) -> None: ) latest = JobEvent.objects.filter(job=job).latest("timestamp") self.assertEqual(latest.event_type, "client_changed") - self.assertEqual(latest.delta_after["client_id"], str(self.destination.id)) + self.assertEqual(latest.delta_after["company_id"], str(self.destination.id)) # --------------------------------------------------------------------------- @@ -570,45 +570,45 @@ def test_job_reassignment_creates_client_changed_event(self) -> None: class ChainWalkingTests(BaseTestCase): def test_caller_can_pass_terminal_of_chain_as_destination(self) -> None: - """A -> B -> C. Caller picks C (via get_final_client) as destination. + """A -> B -> C. Caller picks C (via get_final_company) as destination. Jobs originally on A should land on C.""" - c = make_client("C") - b = make_client("B") - a = make_client("A") + c = make_company("C") + b = make_company("B") + a = make_company("A") b.merged_into = c b.save() a.merged_into = b a.save() - self.assertEqual(a.get_final_client().id, c.id) + self.assertEqual(a.get_final_company().id, c.id) job = make_job(a, self.test_staff) - reassign_client_fk_records(a, a.get_final_client(), self.test_staff) + reassign_company_fk_records(a, a.get_final_company(), self.test_staff) job.refresh_from_db() - self.assertEqual(job.client_id, c.id) + self.assertEqual(job.company_id, c.id) - def test_circular_chain_terminates_at_precycle_client(self) -> None: - """A -> B -> A (circular). get_final_client() stops at pre-cycle + def test_circular_chain_terminates_at_precycle_company(self) -> None: + """A -> B -> A (circular). get_final_company() stops at pre-cycle terminal (A itself here). Caller guards with source != destination — we confirm that path raises rather than loops forever.""" - a = make_client("A") - b = make_client("B") + a = make_company("A") + b = make_company("B") b.merged_into = a b.save() a.merged_into = b a.save() - terminal = a.get_final_client() + terminal = a.get_final_company() # The cycle-guard returns either A or B; caller is responsible for # not passing source as destination. Service must refuse the no-op. if terminal.id == a.id: with self.assertRaises(ValueError): - reassign_client_fk_records(a, terminal, self.test_staff) + reassign_company_fk_records(a, terminal, self.test_staff) else: make_job(a, self.test_staff) - counts = reassign_client_fk_records(a, terminal, self.test_staff) + counts = reassign_company_fk_records(a, terminal, self.test_staff) self.assertEqual(counts["jobs"], 1) @@ -644,10 +644,10 @@ def __getattr__(self, name): with patch.object(Quote.objects, "filter", side_effect=_exploding_filter): with patch( - "apps.client.services.client_merge_service.persist_app_error" + "apps.company.services.company_merge_service.persist_app_error" ) as mock_persist: with self.assertRaises(IntegrityError): - reassign_client_fk_records( + reassign_company_fk_records( self.source, self.destination, self.test_staff ) @@ -655,10 +655,10 @@ def __getattr__(self, name): self.assertTrue(mock_persist.called) # Nothing should have moved — transaction rolled back. - self.assertEqual(Job.objects.filter(client=self.source).count(), 1) - self.assertEqual(Invoice.objects.filter(client=self.source).count(), 1) - self.assertEqual(Job.objects.filter(client=self.destination).count(), 0) - self.assertEqual(Invoice.objects.filter(client=self.destination).count(), 0) + self.assertEqual(Job.objects.filter(company=self.source).count(), 1) + self.assertEqual(Invoice.objects.filter(company=self.source).count(), 1) + self.assertEqual(Job.objects.filter(company=self.destination).count(), 0) + self.assertEqual(Invoice.objects.filter(company=self.destination).count(), 0) # --------------------------------------------------------------------------- @@ -668,7 +668,7 @@ def __getattr__(self, name): class NoRecordsToMoveTests(ReassignFKBaseCase): def test_zero_records_returns_all_zero_counts(self) -> None: - counts = reassign_client_fk_records( + counts = reassign_company_fk_records( self.source, self.destination, self.test_staff ) self.assertEqual( diff --git a/apps/client/tests/test_contact_methods.py b/apps/company/tests/test_contact_methods.py similarity index 71% rename from apps/client/tests/test_contact_methods.py rename to apps/company/tests/test_contact_methods.py index be697b356..025a575f0 100644 --- a/apps/client/tests/test_contact_methods.py +++ b/apps/company/tests/test_contact_methods.py @@ -4,15 +4,15 @@ from django.test import TestCase from django.utils import timezone -from apps.client.models import Client, ClientContact, ClientContactMethod +from apps.company.models import ClientContact, ClientContactMethod, Company from apps.crm.models import PhoneCallRecord from apps.crm.services.phone_call_service import rematch_calls_for_numbers from apps.testing import BaseAPITestCase class ClientContactMethodTests(TestCase): - def _client(self, name: str = "Acme Ltd") -> Client: - return Client.objects.create(name=name, xero_last_modified=timezone.now()) + def _company(self, name: str = "Acme Ltd") -> Company: + return Company.objects.create(name=name, xero_last_modified=timezone.now()) def test_phone_normalization_matches_nz_variants(self) -> None: """Catches call matching failures when NZ local and E.164 numbers diverge.""" @@ -25,17 +25,17 @@ def test_phone_normalization_matches_nz_variants(self) -> None: "+6496365131", ) - def test_primary_phone_is_single_per_client_owner(self) -> None: - """Catches multiple primary phone numbers being left on a client record.""" - client = self._client() + def test_primary_phone_is_single_per_company_owner(self) -> None: + """Catches multiple primary phone numbers being left on a company record.""" + company = self._company() first = ClientContactMethod.objects.create( - client=client, + company=company, method_type=ClientContactMethod.MethodType.PHONE, value="09 111 1111", is_primary=True, ) second = ClientContactMethod.objects.create( - client=client, + company=company, method_type=ClientContactMethod.MethodType.PHONE, value="09 222 2222", is_primary=True, @@ -47,12 +47,12 @@ def test_primary_phone_is_single_per_client_owner(self) -> None: self.assertFalse(first.is_primary) self.assertTrue(second.is_primary) - def test_same_number_allowed_on_client_and_its_own_contact(self) -> None: - """A client and its own contact sharing one line must not be rejected.""" - client = self._client() - contact = ClientContact.objects.create(client=client, name="Jane Smith") - on_client = ClientContactMethod.objects.create( - client=client, + def test_same_number_allowed_on_company_and_its_own_contact(self) -> None: + """A company and its own contact sharing one line must not be rejected.""" + company = self._company() + contact = ClientContact.objects.create(company=company, name="Jane Smith") + on_company = ClientContactMethod.objects.create( + company=company, method_type=ClientContactMethod.MethodType.PHONE, value="021 111 111", ) @@ -62,14 +62,14 @@ def test_same_number_allowed_on_client_and_its_own_contact(self) -> None: value="021 111 111", ) - self.assertEqual(on_client.normalized_value, on_contact.normalized_value) + self.assertEqual(on_company.normalized_value, on_contact.normalized_value) self.assertIsNotNone(on_contact.pk) - def test_same_number_allowed_on_two_contacts_of_same_client(self) -> None: - """Two contacts of one client can share a number (one effective client).""" - client = self._client() - first_contact = ClientContact.objects.create(client=client, name="A") - second_contact = ClientContact.objects.create(client=client, name="B") + def test_same_number_allowed_on_two_contacts_of_same_company(self) -> None: + """Two contacts of one company can share a number (one effective company).""" + company = self._company() + first_contact = ClientContact.objects.create(company=company, name="A") + second_contact = ClientContact.objects.create(company=company, name="B") ClientContactMethod.objects.create( contact=first_contact, method_type=ClientContactMethod.MethodType.PHONE, @@ -83,28 +83,28 @@ def test_same_number_allowed_on_two_contacts_of_same_client(self) -> None: self.assertIsNotNone(on_second.pk) - def test_same_number_rejected_across_different_clients(self) -> None: - """Two different clients cannot own one number; the matcher would be ambiguous.""" - client_a = self._client("Acme Ltd") - client_b = self._client("Beta Ltd") + def test_same_number_rejected_across_different_companies(self) -> None: + """Two different companies cannot own one number; the matcher would be ambiguous.""" + company_a = self._company("Acme Ltd") + company_b = self._company("Beta Ltd") ClientContactMethod.objects.create( - client=client_a, + company=company_a, method_type=ClientContactMethod.MethodType.PHONE, value="021 111 111", ) with self.assertRaises(ValidationError): ClientContactMethod.objects.create( - client=client_b, + company=company_b, method_type=ClientContactMethod.MethodType.PHONE, value="021 111 111", ) - def test_same_number_rejected_across_contacts_of_different_clients(self) -> None: - """Contacts of two different clients cannot share a number.""" - client_a = self._client("Acme Ltd") - client_b = self._client("Beta Ltd") - contact_a = ClientContact.objects.create(client=client_a, name="A") - contact_b = ClientContact.objects.create(client=client_b, name="B") + def test_same_number_rejected_across_contacts_of_different_companies(self) -> None: + """Contacts of two different companies cannot share a number.""" + company_a = self._company("Acme Ltd") + company_b = self._company("Beta Ltd") + contact_a = ClientContact.objects.create(company=company_a, name="A") + contact_b = ClientContact.objects.create(company=company_b, name="B") ClientContactMethod.objects.create( contact=contact_a, method_type=ClientContactMethod.MethodType.PHONE, @@ -117,19 +117,19 @@ def test_same_number_rejected_across_contacts_of_different_clients(self) -> None value="021 111 111", ) - def test_grandfathered_cross_client_number_can_be_resaved(self) -> None: - """A pre-existing cross-client number (legacy data) re-saves unchanged.""" - client_a = self._client("Acme Ltd") - client_b = self._client("Beta Ltd") + def test_grandfathered_cross_company_number_can_be_resaved(self) -> None: + """A pre-existing cross-company number (legacy data) re-saves unchanged.""" + company_a = self._company("Acme Ltd") + company_b = self._company("Beta Ltd") ClientContactMethod.objects.create( - client=client_a, + company=company_a, method_type=ClientContactMethod.MethodType.PHONE, value="021 111 111", ) # Simulate legacy prod data: B already owns the same number, inserted # bypassing the guard (as pre-guard rows were). legacy = ClientContactMethod( - client=client_b, + company=company_b, method_type=ClientContactMethod.MethodType.PHONE, value="021 111 111", ) @@ -143,29 +143,29 @@ def test_grandfathered_cross_client_number_can_be_resaved(self) -> None: legacy.refresh_from_db() self.assertEqual(legacy.label, "Mobile") - def test_changing_number_into_another_clients_ownership_raises(self) -> None: - """Editing a method's number onto another client's number is blocked.""" - client_a = self._client("Acme Ltd") - client_b = self._client("Beta Ltd") + def test_changing_number_into_another_companies_ownership_raises(self) -> None: + """Editing a method's number onto another company's number is blocked.""" + company_a = self._company("Acme Ltd") + company_b = self._company("Beta Ltd") ClientContactMethod.objects.create( - client=client_a, + company=company_a, method_type=ClientContactMethod.MethodType.PHONE, value="021 111 111", ) moving = ClientContactMethod.objects.create( - client=client_b, + company=company_b, method_type=ClientContactMethod.MethodType.PHONE, value="021 222 222", ) - moving.value = "021 111 111" # now collides with client A + moving.value = "021 111 111" # now collides with company A with self.assertRaises(ValidationError): moving.save() def test_primary_phone_is_single_per_contact_owner(self) -> None: """Catches multiple primary phone numbers being left on a contact record.""" - client = self._client() - contact = ClientContact.objects.create(client=client, name="Jane Smith") + company = self._company() + contact = ClientContact.objects.create(company=company, name="Jane Smith") first = ClientContactMethod.objects.create( contact=contact, method_type=ClientContactMethod.MethodType.PHONE, @@ -188,9 +188,9 @@ def test_primary_phone_is_single_per_contact_owner(self) -> None: def test_partial_update_fields_still_persists_normalized_value(self) -> None: """save(update_fields=["value"]) must also persist the recomputed normalized_value, or the matching/uniqueness index goes stale.""" - client = self._client("Acme Ltd") + company = self._company("Acme Ltd") method = ClientContactMethod.objects.create( - client=client, + company=company, method_type=ClientContactMethod.MethodType.PHONE, value="021 111 111", ) @@ -205,57 +205,57 @@ def test_partial_update_fields_still_persists_normalized_value(self) -> None: ) -class ClientPrimaryPhoneValueTests(TestCase): +class CompanyPrimaryPhoneValueTests(TestCase): """Guards the helper PO PDFs and Xero sync use to print a supplier phone.""" def test_returns_primary_phone_first(self) -> None: - client = Client.objects.create( + company = Company.objects.create( name="Acme Ltd", xero_last_modified=timezone.now() ) ClientContactMethod.objects.create( - client=client, + company=company, method_type=ClientContactMethod.MethodType.PHONE, value="09 111 1111", ) ClientContactMethod.objects.create( - client=client, + company=company, method_type=ClientContactMethod.MethodType.PHONE, value="09 222 2222", is_primary=True, ) - self.assertEqual(client.primary_phone_value(), "09 222 2222") + self.assertEqual(company.primary_phone_value(), "09 222 2222") def test_returns_empty_string_when_no_phone_methods(self) -> None: - client = Client.objects.create( + company = Company.objects.create( name="Phoneless Ltd", xero_last_modified=timezone.now() ) ClientContactMethod.objects.create( - client=client, + company=company, method_type=ClientContactMethod.MethodType.EMAIL, value="office@example.com", ) - self.assertEqual(client.primary_phone_value(), "") + self.assertEqual(company.primary_phone_value(), "") class ClientContactMethodApiTests(BaseAPITestCase): - def _client(self, name: str = "Acme Ltd") -> Client: - return Client.objects.create(name=name, xero_last_modified=timezone.now()) + def _company(self, name: str = "Acme Ltd") -> Company: + return Company.objects.create(name=name, xero_last_modified=timezone.now()) def test_list_paginates_phone_contact_methods(self) -> None: """Catches CRM calls page regressions that fetch every phone method.""" self.client.force_authenticate(user=self.test_staff) - client = self._client("Acme Ltd") + company = self._company("Acme Ltd") for index in range(3): ClientContactMethod.objects.create( - client=client, + company=company, method_type=ClientContactMethod.MethodType.PHONE, value=f"021 555 10{index}", ) response = self.client.get( - "/api/clients/contact-methods/", + "/api/companies/contact-methods/", {"method_type": "phone", "page_size": "2"}, ) @@ -270,16 +270,16 @@ def test_list_paginates_phone_contact_methods(self) -> None: def test_list_page_size_is_capped(self) -> None: """Catches accidental oversized contact-method responses.""" self.client.force_authenticate(user=self.test_staff) - client = self._client("Acme Ltd") + company = self._company("Acme Ltd") for index in range(101): ClientContactMethod.objects.create( - client=client, + company=company, method_type=ClientContactMethod.MethodType.PHONE, value=f"021 555 {index:03d}", ) response = self.client.get( - "/api/clients/contact-methods/", + "/api/companies/contact-methods/", {"method_type": "phone", "page_size": "250"}, ) @@ -292,22 +292,22 @@ def test_list_page_size_is_capped(self) -> None: def test_updating_phone_contact_method_rematches_affected_calls(self) -> None: """Catches stale call ownership after a customer phone number changes.""" self.client.force_authenticate(user=self.test_staff) - client = self._client("Acme Ltd") + company = self._company("Acme Ltd") method = ClientContactMethod.objects.create( - client=client, + company=company, method_type=ClientContactMethod.MethodType.PHONE, value="021 555 100", ) - old_call = self._call("old-number", origin="021 555 100", client=client) - new_call = self._call("new-number", origin="021 555 200", client=None) + old_call = self._call("old-number", origin="021 555 100", company=company) + new_call = self._call("new-number", origin="021 555 200", company=None) with patch( - "apps.client.views.client_contact_method_viewset." + "apps.company.views.contact_method_viewset." "rematch_phone_calls_task.delay", side_effect=rematch_calls_for_numbers, ) as rematch: response = self.client.patch( - f"/api/clients/contact-methods/{method.id}/", + f"/api/companies/contact-methods/{method.id}/", {"value": "021 555 200"}, format="json", ) @@ -316,38 +316,40 @@ def test_updating_phone_contact_method_rematches_affected_calls(self) -> None: rematch.assert_called_once_with(["+6421555100", "+6421555200"]) old_call.refresh_from_db() new_call.refresh_from_db() - self.assertIsNone(old_call.client) - self.assertEqual(new_call.client, client) + self.assertIsNone(old_call.company) + self.assertEqual(new_call.company, company) def test_deleting_phone_contact_method_unmatches_affected_calls(self) -> None: """Catches deleted phone numbers continuing to own CRM calls.""" self.client.force_authenticate(user=self.test_staff) - client = self._client("Acme Ltd") + company = self._company("Acme Ltd") method = ClientContactMethod.objects.create( - client=client, + company=company, method_type=ClientContactMethod.MethodType.PHONE, value="021 555 100", ) - call = self._call("deleted-number", origin="021 555 100", client=client) + call = self._call("deleted-number", origin="021 555 100", company=company) with patch( - "apps.client.views.client_contact_method_viewset." + "apps.company.views.contact_method_viewset." "rematch_phone_calls_task.delay", side_effect=rematch_calls_for_numbers, ) as rematch: - response = self.client.delete(f"/api/clients/contact-methods/{method.id}/") + response = self.client.delete( + f"/api/companies/contact-methods/{method.id}/" + ) self.assertEqual(response.status_code, 204) rematch.assert_called_once_with(["+6421555100"]) call.refresh_from_db() - self.assertIsNone(call.client) + self.assertIsNone(call.company) def _call( self, provider_id: str, *, origin: str, - client: Client | None, + company: Company | None, ) -> PhoneCallRecord: call_datetime = timezone.now() return PhoneCallRecord.objects.create( @@ -358,7 +360,7 @@ def _call( call_time=call_datetime.time(), origin=origin, destination="+6496365131", - client=client, + company=company, raw_json={ "id": provider_id, "calldate": timezone.localdate().isoformat(), diff --git a/apps/client/tests/test_duplicate_phone_report.py b/apps/company/tests/test_duplicate_phone_report.py similarity index 74% rename from apps/client/tests/test_duplicate_phone_report.py rename to apps/company/tests/test_duplicate_phone_report.py index 79dc0b4f9..d52eac761 100644 --- a/apps/client/tests/test_duplicate_phone_report.py +++ b/apps/company/tests/test_duplicate_phone_report.py @@ -1,24 +1,24 @@ from django.test import TestCase from django.utils import timezone -from apps.client.models import Client, ClientContact, ClientContactMethod -from apps.client.services.duplicate_phone_report import DuplicatePhoneReportService +from apps.company.models import ClientContact, ClientContactMethod, Company +from apps.company.services.duplicate_phone_report import DuplicatePhoneReportService from apps.crm.models import PhoneEndpoint class DuplicatePhoneReportTests(TestCase): - def _client(self, name: str) -> Client: - return Client.objects.create(name=name, xero_last_modified=timezone.now()) + def _company(self, name: str) -> Company: + return Company.objects.create(name=name, xero_last_modified=timezone.now()) def _phone( self, value: str, - client: Client | None = None, + company: Company | None = None, contact: ClientContact | None = None, ) -> ClientContactMethod: """Insert a phone method bypassing the save() guard (legacy-style data).""" method = ClientContactMethod( - client=client, + company=company, contact=contact, method_type=ClientContactMethod.MethodType.PHONE, value=value, @@ -27,11 +27,11 @@ def _phone( ClientContactMethod.objects.bulk_create([method]) return method - def test_detects_cross_client_number(self) -> None: - acme = self._client("Acme Ltd") - beta = self._client("Beta Ltd") - self._phone("021 111 111", client=acme) - self._phone("021 111 111", client=beta) + def test_detects_cross_company_number(self) -> None: + acme = self._company("Acme Ltd") + beta = self._company("Beta Ltd") + self._phone("021 111 111", company=acme) + self._phone("021 111 111", company=beta) report = DuplicatePhoneReportService().get_report() @@ -45,8 +45,8 @@ def test_detects_cross_client_number(self) -> None: self.assertEqual(report["summary"]["cross_client"], 1) def test_detects_internal_line_collision(self) -> None: - client = self._client("Acme Ltd") - contact = ClientContact.objects.create(client=client, name="Paul Jones") + company = self._company("Acme Ltd") + contact = ClientContact.objects.create(company=company, name="Paul Jones") self._phone("09 636 5131", contact=contact) # Bypass PhoneEndpoint.save()'s collision guard (legacy-style data): # the report exists precisely to surface rows that predate the guard. @@ -69,8 +69,8 @@ def test_detects_internal_line_collision(self) -> None: self.assertEqual(report["summary"]["internal_line"], 1) def test_clean_data_returns_empty(self) -> None: - client = self._client("Acme Ltd") - self._phone("021 111 111", client=client) + company = self._company("Acme Ltd") + self._phone("021 111 111", company=company) report = DuplicatePhoneReportService().get_report() diff --git a/apps/client/tests/test_get_client_for_xero.py b/apps/company/tests/test_get_company_for_xero.py similarity index 75% rename from apps/client/tests/test_get_client_for_xero.py rename to apps/company/tests/test_get_company_for_xero.py index 01a7c96d7..ffa94301e 100644 --- a/apps/client/tests/test_get_client_for_xero.py +++ b/apps/company/tests/test_get_company_for_xero.py @@ -1,4 +1,4 @@ -"""Tests for Client.get_client_for_xero(). +"""Tests for Company.get_company_for_xero(). Regression coverage for Trello #305 — the snake_case dict that this method used to return caused Xero to silently drop every field except Name on the @@ -12,28 +12,28 @@ from xero_python.accounting.models import Address, Contact, Phone from xero_python.api_client.serializer import serialize -from apps.client.models import Client, ClientContactMethod +from apps.company.models import ClientContactMethod, Company -class GetClientForXeroTests(TestCase): +class GetCompanyForXeroTests(TestCase): """Pin the wire-format contract independent of which push function consumes it.""" - def _make_client(self, **overrides): + def _make_company(self, **overrides): defaults = { "name": "Acme Ltd", "xero_last_modified": timezone.now(), } phone = overrides.pop("phone", None) defaults.update(overrides) - client = Client.objects.create(**defaults) + company = Company.objects.create(**defaults) if phone is not None: ClientContactMethod.objects.create( - client=client, + company=company, method_type=ClientContactMethod.MethodType.PHONE, value=phone, is_primary=True, ) - return client + return company def test_returns_sdk_contact_instance(self): """The payload must be a xero_python Contact, not a dict. @@ -42,36 +42,36 @@ def test_returns_sdk_contact_instance(self): attribute_map only translates fields for model instances; raw dicts ship verbatim and Xero drops every non-Name field. """ - client = self._make_client() + company = self._make_company() - payload = client.get_client_for_xero() + payload = company.get_company_for_xero() self.assertIsInstance(payload, Contact) def test_phones_are_sdk_phone_instances(self): - client = self._make_client(phone="027 351 8326") + company = self._make_company(phone="027 351 8326") - payload = client.get_client_for_xero() + payload = company.get_company_for_xero() self.assertIsInstance(payload.phones[0], Phone) def test_addresses_are_sdk_address_instances(self): - client = self._make_client(address="123 Test Street") + company = self._make_company(address="123 Test Street") - payload = client.get_client_for_xero() + payload = company.get_company_for_xero() self.assertIsInstance(payload.addresses[0], Address) - def test_populated_client_carries_all_fields(self): - """Every field a fully-populated Client supplies must reach the Contact.""" - client = self._make_client( + def test_populated_company_carries_all_fields(self): + """Every field a fully-populated Company supplies must reach the Contact.""" + company = self._make_company( email="info@acme.test", phone="027 351 8326", address="123 Test Street", is_account_customer=True, ) - payload = client.get_client_for_xero() + payload = company.get_company_for_xero() self.assertEqual(payload.name, "Acme Ltd") self.assertEqual(payload.email_address, "info@acme.test") @@ -82,16 +82,16 @@ def test_populated_client_carries_all_fields(self): self.assertEqual(payload.addresses[0].address_line1, "123 Test Street") self.assertEqual(payload.addresses[0].attention_to, "Acme Ltd") - def test_existing_client_populates_contact_id_on_instance(self): + def test_existing_company_populates_contact_id_on_instance(self): """contact_id lives on the Contact, not as a caller-side dict mutation. Regression for the dict-mutation pattern at push.py:40 - (`contact_data["ContactID"] = client.xero_contact_id`). + (`contact_data["ContactID"] = company.xero_contact_id`). """ xero_id = "9568adbc-aaaa-bbbb-cccc-000000000001" - client = self._make_client(xero_contact_id=xero_id) + company = self._make_company(xero_contact_id=xero_id) - payload = client.get_client_for_xero() + payload = company.get_company_for_xero() self.assertEqual(payload.contact_id, xero_id) @@ -101,33 +101,33 @@ def test_no_email_emits_none_not_empty_string(self): Empty string overwrites any operator-typed email Xero already holds on a round-trip update. The masked side-effect of #305. """ - client = self._make_client(email=None) + company = self._make_company(email=None) - payload = client.get_client_for_xero() + payload = company.get_company_for_xero() self.assertIsNone(payload.email_address) def test_no_phone_emits_none_not_empty_string(self): - client = self._make_client(phone=None) + company = self._make_company(phone=None) - payload = client.get_client_for_xero() + payload = company.get_company_for_xero() self.assertIsNone(payload.phones[0].phone_number) def test_no_address_emits_none_not_empty_string(self): - client = self._make_client(address=None) + company = self._make_company(address=None) - payload = client.get_client_for_xero() + payload = company.get_company_for_xero() self.assertIsNone(payload.addresses[0].address_line1) def test_missing_name_raises_value_error(self): """The name guard must still trip.""" - client = self._make_client() - client.name = "" + company = self._make_company() + company.name = "" with self.assertRaises(ValueError): - client.get_client_for_xero() + company.get_company_for_xero() def test_serialized_wire_format_is_pascalcase(self): """End-to-end wire format check — the bytes Xero would actually receive. @@ -139,14 +139,14 @@ def test_serialized_wire_format_is_pascalcase(self): the fields that were silently lost so the bug cannot regress at any layer between the model and the HTTP body. """ - client = self._make_client( + company = self._make_company( email="info@acme.test", phone="027 351 8326", address="123 Test Street", is_account_customer=True, ) - wire = serialize(client.get_client_for_xero()) + wire = serialize(company.get_company_for_xero()) self.assertEqual(wire["Name"], "Acme Ltd") self.assertEqual(wire["EmailAddress"], "info@acme.test") diff --git a/apps/company/urls_rest.py b/apps/company/urls_rest.py new file mode 100644 index 000000000..92ef7a5f2 --- /dev/null +++ b/apps/company/urls_rest.py @@ -0,0 +1,108 @@ +""" +Company REST URLs + +REST URLs for Company module following RESTful patterns: +- Clearly defined endpoints +- Appropriate HTTP verbs +- Consistent structure with other REST modules +""" + +from django.urls import include, path +from rest_framework.routers import DefaultRouter + +from apps.company.views.address_views import AddressValidateView +from apps.company.views.company_rest_views import ( + CompanyCreateRestView, + CompanyJobsRestView, + CompanyListAllRestView, + CompanyRetrieveRestView, + CompanySearchRestView, + CompanyUpdateRestView, + JobContactRestView, +) +from apps.company.views.contact_method_viewset import ClientContactMethodViewSet +from apps.company.views.contact_viewset import ClientContactViewSet +from apps.company.views.supplier_pickup_address_viewset import ( + SupplierPickupAddressViewSet, +) +from apps.company.views.supplier_search_alias_views import ( + CompanySupplierAliasListCreateView, + SupplierAliasDetailView, +) + +app_name = "companies_rest" + +# Router for ViewSet-based endpoints +router = DefaultRouter() +router.register( + "contact-methods", + ClientContactMethodViewSet, + basename="contact-method", +) +router.register("contacts", ClientContactViewSet, basename="company-contact") +router.register( + "pickup-addresses", SupplierPickupAddressViewSet, basename="supplier-pickup-address" +) + +urlpatterns = [ + # Company list all REST endpoint + path( + "all/", + CompanyListAllRestView.as_view(), + name="company_list_all_rest", + ), + # Company creation REST endpoint + path( + "create/", + CompanyCreateRestView.as_view(), + name="company_create_rest", + ), + # Company search REST endpoint + path( + "search/", + CompanySearchRestView.as_view(), + name="company_search_rest", + ), + # Company retrieve REST endpoint + path( + "/", + CompanyRetrieveRestView.as_view(), + name="company_retrieve_rest", + ), + # Company update REST endpoint + path( + "/update/", + CompanyUpdateRestView.as_view(), + name="company_update_rest", + ), + # Company jobs REST endpoint + path( + "/jobs/", + CompanyJobsRestView.as_view(), + name="company_jobs_rest", + ), + path( + "/supplier-aliases/", + CompanySupplierAliasListCreateView.as_view(), + name="company_supplier_aliases_rest", + ), + path( + "supplier-aliases//", + SupplierAliasDetailView.as_view(), + name="supplier_alias_detail_rest", + ), + # Job contact REST endpoint + path( + "jobs//contact/", + JobContactRestView.as_view(), + name="job_contact_rest", + ), + # Address validation endpoint + path( + "addresses/validate/", + AddressValidateView.as_view(), + name="address_validate", + ), + # ViewSet routes (contacts CRUD) + path("", include(router.urls)), +] diff --git a/apps/client/utils.py b/apps/company/utils.py similarity index 94% rename from apps/client/utils.py rename to apps/company/utils.py index 63d6671ff..d9d1f0249 100644 --- a/apps/client/utils.py +++ b/apps/company/utils.py @@ -1,5 +1,5 @@ """ -Client utility functions +Company utility functions """ from datetime import datetime, time diff --git a/apps/client/views/__init__.py b/apps/company/views/__init__.py similarity index 54% rename from apps/client/views/__init__.py rename to apps/company/views/__init__.py index 511ab6415..57fd58ebe 100644 --- a/apps/client/views/__init__.py +++ b/apps/company/views/__init__.py @@ -1,17 +1,17 @@ # This file is autogenerated by update_init.py script from .address_views import AddressValidateView -from .client_rest_views import ( - ClientCreateRestView, - ClientJobsRestView, - ClientListAllRestView, - ClientRetrieveRestView, - ClientSearchRestView, - ClientUpdateRestView, +from .company_rest_views import ( + CompanyCreateRestView, + CompanyJobsRestView, + CompanyListAllRestView, + CompanyRetrieveRestView, + CompanySearchRestView, + CompanyUpdateRestView, JobContactRestView, ) from .supplier_search_alias_views import ( - ClientSupplierAliasListCreateView, + CompanySupplierAliasListCreateView, SupplierAliasDetailView, ) @@ -20,8 +20,8 @@ from django.apps import apps if apps.ready: - from .client_contact_method_viewset import ClientContactMethodViewSet - from .client_contact_viewset import ClientContactViewSet + from .contact_method_viewset import ClientContactMethodViewSet + from .contact_viewset import ClientContactViewSet from .supplier_pickup_address_viewset import SupplierPickupAddressViewSet except (ImportError, RuntimeError): # Django not ready or circular import, skip conditional imports @@ -31,13 +31,13 @@ "AddressValidateView", "ClientContactMethodViewSet", "ClientContactViewSet", - "ClientCreateRestView", - "ClientJobsRestView", - "ClientListAllRestView", - "ClientRetrieveRestView", - "ClientSearchRestView", - "ClientSupplierAliasListCreateView", - "ClientUpdateRestView", + "CompanyCreateRestView", + "CompanyJobsRestView", + "CompanyListAllRestView", + "CompanyRetrieveRestView", + "CompanySearchRestView", + "CompanySupplierAliasListCreateView", + "CompanyUpdateRestView", "JobContactRestView", "SupplierAliasDetailView", "SupplierPickupAddressViewSet", diff --git a/apps/client/views/address_views.py b/apps/company/views/address_views.py similarity index 97% rename from apps/client/views/address_views.py rename to apps/company/views/address_views.py index 360c648ca..880f00ed5 100644 --- a/apps/client/views/address_views.py +++ b/apps/company/views/address_views.py @@ -14,7 +14,7 @@ from rest_framework.response import Response from rest_framework.views import APIView -from apps.client.services.geocoding_service import ( +from apps.company.services.geocoding_service import ( GeocodingError, GeocodingNotConfiguredError, geocode_address, @@ -28,7 +28,7 @@ class AddressValidateView(APIView): """ Validate and clean an address using Google Address Validation API. - POST /api/clients/addresses/validate/ + POST /api/companies/addresses/validate/ Body: {"address": "123 Main St Melbourne"} Returns candidate addresses with structured components. diff --git a/apps/client/views/client_rest_views.py b/apps/company/views/company_rest_views.py similarity index 61% rename from apps/client/views/client_rest_views.py rename to apps/company/views/company_rest_views.py index 4341a35eb..4a632be15 100644 --- a/apps/client/views/client_rest_views.py +++ b/apps/company/views/company_rest_views.py @@ -1,7 +1,7 @@ """ -Client REST Views +Company REST Views -REST views for the Client module following clean code principles: +REST views for the Company module following clean code principles: - SRP (Single Responsibility Principle) - Early return and guard clauses - Delegation to service layer @@ -23,23 +23,23 @@ from rest_framework.response import Response from rest_framework.views import APIView -from apps.client.models import Client -from apps.client.serializers import ( - ClientCreateResponseSerializer, - ClientCreateSerializer, - ClientDetailResponseSerializer, - ClientDuplicateErrorResponseSerializer, - ClientErrorResponseSerializer, - ClientJobsResponseSerializer, - ClientListResponseSerializer, - ClientNameOnlySerializer, - ClientSearchResponseSerializer, - ClientUpdateResponseSerializer, - ClientUpdateSerializer, +from apps.company.models import Company +from apps.company.serializers import ( + CompanyCreateResponseSerializer, + CompanyCreateSerializer, + CompanyDetailResponseSerializer, + CompanyDuplicateErrorResponseSerializer, + CompanyErrorResponseSerializer, + CompanyJobsResponseSerializer, + CompanyListResponseSerializer, + CompanyNameOnlySerializer, + CompanySearchResponseSerializer, + CompanyUpdateResponseSerializer, + CompanyUpdateSerializer, JobContactResponseSerializer, JobContactUpdateSerializer, ) -from apps.client.services.client_rest_service import ClientRestService +from apps.company.services.company_rest_service import CompanyRestService from apps.workflow.exceptions import AlreadyLoggedException from apps.workflow.services.error_persistence import persist_app_error @@ -67,53 +67,53 @@ def _build_server_error_response( if error_id: payload["error_id"] = str(error_id) - serializer = ClientErrorResponseSerializer(data=payload) + serializer = CompanyErrorResponseSerializer(data=payload) serializer.is_valid(raise_exception=True) return Response(serializer.data, status=status_code) @extend_schema_view( get=extend_schema( - summary="List all clients", - description="Returns a list of all clients with basic information (id and name) for dropdowns and search.", + summary="List all companies", + description="Returns a list of all companies with basic information (id and name) for dropdowns and search.", responses={ - 200: ClientNameOnlySerializer(many=True), - 500: ClientErrorResponseSerializer, + 200: CompanyNameOnlySerializer(many=True), + 500: CompanyErrorResponseSerializer, }, - tags=["Clients"], + tags=["Companies"], ) ) -class ClientListAllRestView(APIView): +class CompanyListAllRestView(APIView): """ - REST view for listing all clients. + REST view for listing all companies. Used by dropdowns and advanced search. """ permission_classes = [IsAuthenticated] - serializer_class = ClientListResponseSerializer + serializer_class = CompanyListResponseSerializer def get(self, request: Request) -> Response: """ - Lists all clients (only id and name) for fast dropdowns. + Lists all companies (only id and name) for fast dropdowns. """ try: - clients_data = ClientRestService.get_all_clients() - return Response(clients_data) + companies_data = CompanyRestService.get_all_companies() + return Response(companies_data) except Exception as exc: return _build_server_error_response( - message="Error fetching all clients", exc=exc + message="Error fetching all companies", exc=exc ) @extend_schema_view( get=extend_schema( - summary="Search clients", + summary="Search companies", parameters=[ OpenApiParameter( name="q", location=OpenApiParameter.QUERY, required=False, - description="Search query (case-insensitive substring match). If empty, returns all clients.", + description="Search query (case-insensitive substring match). If empty, returns all companies.", type=OpenApiTypes.STR, ), OpenApiParameter( @@ -146,24 +146,24 @@ def get(self, request: Request) -> Response: ), ], responses={ - 200: ClientSearchResponseSerializer, - 500: ClientErrorResponseSerializer, + 200: CompanySearchResponseSerializer, + 500: CompanyErrorResponseSerializer, }, - tags=["Clients"], + tags=["Companies"], ) ) -class ClientSearchRestView(APIView): +class CompanySearchRestView(APIView): """ - REST view for client search with pagination and sorting. - Returns all clients when no search query provided. + REST view for company search with pagination and sorting. + Returns all companies when no search query provided. """ permission_classes = [IsAuthenticated] - serializer_class = ClientSearchResponseSerializer + serializer_class = CompanySearchResponseSerializer def get(self, request: Request) -> Response: """ - Lists/searches clients with pagination and sorting. + Lists/searches companies with pagination and sorting. """ try: query = (request.GET.get("q") or "").strip() @@ -184,7 +184,7 @@ def get(self, request: Request) -> Response: sort_dir = request.GET.get("sort_dir", "asc") # Get paginated results - result = ClientRestService.list_clients( + result = CompanyRestService.list_companies( query=query if len(query) >= 3 else None, page=page, page_size=page_size, @@ -192,153 +192,153 @@ def get(self, request: Request) -> Response: sort_dir=sort_dir, ) - serializer = ClientSearchResponseSerializer(data=result) + serializer = CompanySearchResponseSerializer(data=result) serializer.is_valid(raise_exception=True) - ClientRestService.log_client_search_results( + CompanyRestService.log_company_search_results( request=request, - source="client_search", + source="company_search", query=query, - clients=result["results"], + companies=result["results"], total_count=result["count"], ) return Response(serializer.data) except Exception as exc: return _build_server_error_response( - message="Error searching clients", exc=exc + message="Error searching companies", exc=exc ) @extend_schema_view( get=extend_schema( - summary="Get client details", - description="Retrieve detailed information for a specific client.", + summary="Get company details", + description="Retrieve detailed information for a specific company.", parameters=[ OpenApiParameter( - name="client_id", + name="company_id", location=OpenApiParameter.PATH, - description="UUID of the client", + description="UUID of the company", required=True, type=OpenApiTypes.UUID, ) ], responses={ - 200: ClientDetailResponseSerializer, - 404: ClientErrorResponseSerializer, - 500: ClientErrorResponseSerializer, + 200: CompanyDetailResponseSerializer, + 404: CompanyErrorResponseSerializer, + 500: CompanyErrorResponseSerializer, }, - tags=["Clients"], + tags=["Companies"], ) ) -class ClientRetrieveRestView(APIView): +class CompanyRetrieveRestView(APIView): """ - REST view for retrieving a specific client by ID. + REST view for retrieving a specific company by ID. """ permission_classes = [IsAuthenticated] - serializer_class = ClientDetailResponseSerializer + serializer_class = CompanyDetailResponseSerializer - def get(self, request: Request, client_id: str) -> Response: + def get(self, request: Request, company_id: str) -> Response: """ - Retrieves detailed information for a specific client. + Retrieves detailed information for a specific company. """ try: - client_data = ClientRestService.get_client_by_id(client_id) - return Response(client_data) + company_data = CompanyRestService.get_company_by_id(company_id) + return Response(company_data) except ValueError as e: - error_serializer = ClientErrorResponseSerializer(data={"error": str(e)}) + error_serializer = CompanyErrorResponseSerializer(data={"error": str(e)}) error_serializer.is_valid(raise_exception=True) return Response(error_serializer.data, status=status.HTTP_404_NOT_FOUND) except Exception as exc: return _build_server_error_response( - message="Error retrieving client", exc=exc + message="Error retrieving company", exc=exc ) @extend_schema_view( put=extend_schema( - summary="Update client", - description="Update an existing client's information.", + summary="Update company", + description="Update an existing company's information.", parameters=[ OpenApiParameter( - name="client_id", + name="company_id", location=OpenApiParameter.PATH, - description="UUID of the client", + description="UUID of the company", required=True, type=OpenApiTypes.UUID, ) ], - request=ClientUpdateSerializer, + request=CompanyUpdateSerializer, responses={ - 200: ClientUpdateResponseSerializer, - 400: ClientErrorResponseSerializer, - 404: ClientErrorResponseSerializer, - 500: ClientErrorResponseSerializer, + 200: CompanyUpdateResponseSerializer, + 400: CompanyErrorResponseSerializer, + 404: CompanyErrorResponseSerializer, + 500: CompanyErrorResponseSerializer, }, - tags=["Clients"], + tags=["Companies"], ), patch=extend_schema( - summary="Partially update client", - description="Partially update an existing client's information.", + summary="Partially update company", + description="Partially update an existing company's information.", parameters=[ OpenApiParameter( - name="client_id", + name="company_id", location=OpenApiParameter.PATH, - description="UUID of the client", + description="UUID of the company", required=True, type=OpenApiTypes.UUID, ) ], - request=ClientUpdateSerializer, + request=CompanyUpdateSerializer, responses={ - 200: ClientUpdateResponseSerializer, - 400: ClientErrorResponseSerializer, - 404: ClientErrorResponseSerializer, - 500: ClientErrorResponseSerializer, + 200: CompanyUpdateResponseSerializer, + 400: CompanyErrorResponseSerializer, + 404: CompanyErrorResponseSerializer, + 500: CompanyErrorResponseSerializer, }, - tags=["Clients"], + tags=["Companies"], ), ) -class ClientUpdateRestView(APIView): +class CompanyUpdateRestView(APIView): """ - REST view for updating client information. + REST view for updating company information. Supports both PUT (full update) and PATCH (partial update). """ permission_classes = [IsAuthenticated] - serializer_class = ClientUpdateResponseSerializer + serializer_class = CompanyUpdateResponseSerializer def get_serializer_class(self): """Return the appropriate serializer class based on the request method""" if self.request.method in ["PUT", "PATCH"]: - return ClientUpdateSerializer - return ClientUpdateResponseSerializer + return CompanyUpdateSerializer + return CompanyUpdateResponseSerializer - def put(self, request: Request, client_id: str) -> Response: + def put(self, request: Request, company_id: str) -> Response: """ - Full update of client information. + Full update of company information. """ - return self._update_client(request, client_id, partial=False) + return self._update_company(request, company_id, partial=False) - def patch(self, request: Request, client_id: str) -> Response: + def patch(self, request: Request, company_id: str) -> Response: """ - Partial update of client information. + Partial update of company information. """ - return self._update_client(request, client_id, partial=True) + return self._update_company(request, company_id, partial=True) - def _update_client( - self, request: Request, client_id: str, partial: bool = True + def _update_company( + self, request: Request, company_id: str, partial: bool = True ) -> Response: """ - Common method for handling client updates. + Common method for handling company updates. """ try: # Validate input data - input_serializer = ClientUpdateSerializer( + input_serializer = CompanyUpdateSerializer( data=request.data, partial=partial ) if not input_serializer.is_valid(): - error_serializer = ClientErrorResponseSerializer( + error_serializer = CompanyErrorResponseSerializer( data={"error": f"Invalid input data: {input_serializer.errors}"} ) error_serializer.is_valid(raise_exception=True) @@ -347,78 +347,84 @@ def _update_client( ) validated_data = input_serializer.validated_data - updated_client = ClientRestService.update_client(client_id, validated_data) + updated_company = CompanyRestService.update_company( + company_id, validated_data + ) # Format response using the service method - client_data = ClientRestService._format_client_detail(updated_client) + company_data = CompanyRestService._format_company_detail(updated_company) response_data = { "success": True, - "client": client_data, - "message": f'Client "{updated_client.name}" updated successfully', + "company": company_data, + "message": f'Company "{updated_company.name}" updated successfully', } - response_serializer = ClientUpdateResponseSerializer(data=response_data) + response_serializer = CompanyUpdateResponseSerializer(data=response_data) response_serializer.is_valid(raise_exception=True) return Response(response_serializer.data) except ValueError as e: # Handle not found and validation errors if "not found" in str(e).lower(): - error_serializer = ClientErrorResponseSerializer(data={"error": str(e)}) + error_serializer = CompanyErrorResponseSerializer( + data={"error": str(e)} + ) error_serializer.is_valid(raise_exception=True) return Response(error_serializer.data, status=status.HTTP_404_NOT_FOUND) else: - error_serializer = ClientErrorResponseSerializer(data={"error": str(e)}) + error_serializer = CompanyErrorResponseSerializer( + data={"error": str(e)} + ) error_serializer.is_valid(raise_exception=True) return Response( error_serializer.data, status=status.HTTP_400_BAD_REQUEST ) except Exception as exc: return _build_server_error_response( - message="Error updating client", exc=exc + message="Error updating company", exc=exc ) @extend_schema_view( post=extend_schema( - summary="Create a new client", - description="Creates a new client in Xero first, then syncs locally. Requires valid Xero authentication.", - request=ClientCreateSerializer, + summary="Create a new company", + description="Creates a new company in Xero first, then syncs locally. Requires valid Xero authentication.", + request=CompanyCreateSerializer, responses={ - 201: ClientCreateResponseSerializer, - 400: ClientErrorResponseSerializer, - 401: ClientErrorResponseSerializer, - 409: ClientDuplicateErrorResponseSerializer, - 500: ClientErrorResponseSerializer, + 201: CompanyCreateResponseSerializer, + 400: CompanyErrorResponseSerializer, + 401: CompanyErrorResponseSerializer, + 409: CompanyDuplicateErrorResponseSerializer, + 500: CompanyErrorResponseSerializer, }, - tags=["Clients"], + tags=["Companies"], ) ) -class ClientCreateRestView(APIView): +class CompanyCreateRestView(APIView): """ - REST view for creating new clients. + REST view for creating new companies. Follows clean code principles and delegates to service layer. - Creates client in Xero first, then syncs locally. + Creates company in Xero first, then syncs locally. """ permission_classes = [IsAuthenticated] - serializer_class = ClientCreateResponseSerializer + serializer_class = CompanyCreateResponseSerializer def get_serializer_class(self): """Return the appropriate serializer class based on the request method""" if self.request.method == "POST": - return ClientCreateSerializer - return ClientCreateResponseSerializer + return CompanyCreateSerializer + return CompanyCreateResponseSerializer def post(self, request: Request) -> Response: """ - Create a new client, first in Xero, then sync locally. + Create a new company, first in Xero, then sync locally. """ try: # Validate input data - input_serializer = ClientCreateSerializer(data=request.data) + input_serializer = CompanyCreateSerializer(data=request.data) if not input_serializer.is_valid(): - error_serializer = ClientErrorResponseSerializer( + error_serializer = CompanyErrorResponseSerializer( data={"error": f"Invalid input data: {input_serializer.errors}"} ) error_serializer.is_valid(raise_exception=True) @@ -427,34 +433,34 @@ def post(self, request: Request) -> Response: ) validated_data = input_serializer.validated_data - created_client = ClientRestService.create_client(validated_data) - created_client = Client.objects.with_invoice_summary().get( - id=created_client.id + created_company = CompanyRestService.create_company(validated_data) + created_company = Company.objects.with_invoice_summary().get( + id=created_company.id ) response_data = { "success": True, - "client": ClientRestService._format_client_summary(created_client), - "message": f'Client "{created_client.name}" created successfully', + "company": CompanyRestService._format_company_summary(created_company), + "message": f'Company "{created_company.name}" created successfully', } - response_serializer = ClientCreateResponseSerializer(data=response_data) + response_serializer = CompanyCreateResponseSerializer(data=response_data) response_serializer.is_valid(raise_exception=True) return Response(response_serializer.data, status=status.HTTP_201_CREATED) except ValueError as e: logger.error( - f"Error during client creation: {e} | Request data: {request.data}" + f"Error during company creation: {e} | Request data: {request.data}" ) - # Handle duplicate client error + # Handle duplicate company error if "already exists in Xero" in str(e): - # Extract client name from error message + # Extract company name from error message error_msg = str(e) - if "Client '" in error_msg and "' already exists" in error_msg: - name = error_msg.split("Client '")[1].split("' already exists")[0] + if "Company '" in error_msg and "' already exists" in error_msg: + name = error_msg.split("Company '")[1].split("' already exists")[0] duplicate_error_data = { "error": error_msg, - "existing_client": { + "existing_company": { "name": name, "xero_contact_id": ( error_msg.split("ID: ")[-1] @@ -463,7 +469,7 @@ def post(self, request: Request) -> Response: ), }, } - error_serializer = ClientDuplicateErrorResponseSerializer( + error_serializer = CompanyDuplicateErrorResponseSerializer( data=duplicate_error_data ) error_serializer.is_valid(raise_exception=True) @@ -473,19 +479,21 @@ def post(self, request: Request) -> Response: # Handle validation errors if "authentication required" in str(e).lower(): - error_serializer = ClientErrorResponseSerializer(data={"error": str(e)}) + error_serializer = CompanyErrorResponseSerializer( + data={"error": str(e)} + ) error_serializer.is_valid(raise_exception=True) return Response( error_serializer.data, status=status.HTTP_401_UNAUTHORIZED ) # Other validation errors - error_serializer = ClientErrorResponseSerializer(data={"error": str(e)}) + error_serializer = CompanyErrorResponseSerializer(data={"error": str(e)}) error_serializer.is_valid(raise_exception=True) return Response(error_serializer.data, status=status.HTTP_400_BAD_REQUEST) except Exception as exc: return _build_server_error_response( - message="Error creating client", exc=exc + message="Error creating company", exc=exc ) @@ -504,10 +512,10 @@ def post(self, request: Request) -> Response: ], responses={ 200: JobContactResponseSerializer, - 404: ClientErrorResponseSerializer, - 500: ClientErrorResponseSerializer, + 404: CompanyErrorResponseSerializer, + 500: CompanyErrorResponseSerializer, }, - tags=["Clients"], + tags=["Companies"], ), put=extend_schema( summary="Update job contact", @@ -524,12 +532,12 @@ def post(self, request: Request) -> Response: request=JobContactUpdateSerializer, responses={ 200: JobContactResponseSerializer, - 400: ClientErrorResponseSerializer, - 404: ClientErrorResponseSerializer, - 500: ClientErrorResponseSerializer, + 400: CompanyErrorResponseSerializer, + 404: CompanyErrorResponseSerializer, + 500: CompanyErrorResponseSerializer, }, - tags=["Clients"], - operation_id="clients_jobs_contact_update", + tags=["Companies"], + operation_id="companies_jobs_contact_update", ), ) class JobContactRestView(APIView): @@ -554,7 +562,7 @@ def get(self, request: Request, job_id: str) -> Response: try: # Guard clause: validate job_id if not job_id: - error_serializer = ClientErrorResponseSerializer( + error_serializer = CompanyErrorResponseSerializer( data={"error": "Job ID is required"} ) error_serializer.is_valid(raise_exception=True) @@ -562,13 +570,13 @@ def get(self, request: Request, job_id: str) -> Response: error_serializer.data, status=status.HTTP_400_BAD_REQUEST ) - contact_data = ClientRestService.get_job_contact(job_id) + contact_data = CompanyRestService.get_job_contact(job_id) serializer = JobContactResponseSerializer(data=contact_data) serializer.is_valid(raise_exception=True) return Response(serializer.data) except ValueError as e: - error_serializer = ClientErrorResponseSerializer(data={"error": str(e)}) + error_serializer = CompanyErrorResponseSerializer(data={"error": str(e)}) error_serializer.is_valid(raise_exception=True) return Response(error_serializer.data, status=status.HTTP_404_NOT_FOUND) except Exception as exc: @@ -583,7 +591,7 @@ def put(self, request: Request, job_id: str) -> Response: try: # Guard clause: validate job_id if not job_id: - error_serializer = ClientErrorResponseSerializer( + error_serializer = CompanyErrorResponseSerializer( data={"error": "Job ID is required"} ) error_serializer.is_valid(raise_exception=True) @@ -594,7 +602,7 @@ def put(self, request: Request, job_id: str) -> Response: # Validate input data input_serializer = JobContactUpdateSerializer(data=request.data) if not input_serializer.is_valid(): - error_serializer = ClientErrorResponseSerializer( + error_serializer = CompanyErrorResponseSerializer( data={"error": f"Invalid input data: {input_serializer.errors}"} ) error_serializer.is_valid(raise_exception=True) @@ -603,7 +611,7 @@ def put(self, request: Request, job_id: str) -> Response: ) contact_data = input_serializer.validated_data - updated_contact = ClientRestService.update_job_contact( + updated_contact = CompanyRestService.update_job_contact( job_id, contact_data, request.user ) serializer = JobContactResponseSerializer(data=updated_contact) @@ -611,7 +619,7 @@ def put(self, request: Request, job_id: str) -> Response: return Response(serializer.data) except ValueError as e: - error_serializer = ClientErrorResponseSerializer(data={"error": str(e)}) + error_serializer = CompanyErrorResponseSerializer(data={"error": str(e)}) error_serializer.is_valid(raise_exception=True) return Response(error_serializer.data, status=status.HTTP_404_NOT_FOUND) except Exception as exc: @@ -622,63 +630,63 @@ def put(self, request: Request, job_id: str) -> Response: @extend_schema_view( get=extend_schema( - summary="Get client jobs", - description="Retrieve all jobs for a specific client.", + summary="Get company jobs", + description="Retrieve all jobs for a specific company.", parameters=[ OpenApiParameter( - name="client_id", + name="company_id", location=OpenApiParameter.PATH, - description="UUID of the client", + description="UUID of the company", required=True, type=OpenApiTypes.UUID, ) ], responses={ - 200: ClientJobsResponseSerializer, - 404: ClientErrorResponseSerializer, - 500: ClientErrorResponseSerializer, + 200: CompanyJobsResponseSerializer, + 404: CompanyErrorResponseSerializer, + 500: CompanyErrorResponseSerializer, }, - tags=["Clients"], + tags=["Companies"], ) ) -class ClientJobsRestView(APIView): +class CompanyJobsRestView(APIView): """ - REST view for fetching all jobs for a specific client. + REST view for fetching all jobs for a specific company. Returns job header information for fast loading. """ permission_classes = [IsAuthenticated] - serializer_class = ClientJobsResponseSerializer + serializer_class = CompanyJobsResponseSerializer - def get(self, request: Request, client_id: str) -> Response: + def get(self, request: Request, company_id: str) -> Response: """ - Retrieves all jobs for a specific client. + Retrieves all jobs for a specific company. """ try: - # Guard clause: validate client_id - if not client_id: - error_serializer = ClientErrorResponseSerializer( - data={"error": "Client ID is required"} + # Guard clause: validate company_id + if not company_id: + error_serializer = CompanyErrorResponseSerializer( + data={"error": "Company ID is required"} ) error_serializer.is_valid(raise_exception=True) return Response( error_serializer.data, status=status.HTTP_400_BAD_REQUEST ) - jobs = ClientRestService.get_client_jobs(client_id) + jobs = CompanyRestService.get_company_jobs(company_id) response_data = {"results": jobs} - serializer = ClientJobsResponseSerializer(data=response_data) + serializer = CompanyJobsResponseSerializer(data=response_data) serializer.is_valid(raise_exception=True) return Response(serializer.data) except ValueError as e: - error_serializer = ClientErrorResponseSerializer(data={"error": str(e)}) + error_serializer = CompanyErrorResponseSerializer(data={"error": str(e)}) error_serializer.is_valid(raise_exception=True) return Response(error_serializer.data, status=status.HTTP_404_NOT_FOUND) except Exception as e: - logger.error(f"Error fetching jobs for client {client_id}: {str(e)}") - error_serializer = ClientErrorResponseSerializer( - data={"error": "Error fetching client jobs", "details": str(e)} + logger.error(f"Error fetching jobs for company {company_id}: {str(e)}") + error_serializer = CompanyErrorResponseSerializer( + data={"error": "Error fetching company jobs", "details": str(e)} ) error_serializer.is_valid(raise_exception=True) return Response( diff --git a/apps/client/views/client_contact_method_viewset.py b/apps/company/views/contact_method_viewset.py similarity index 86% rename from apps/client/views/client_contact_method_viewset.py rename to apps/company/views/contact_method_viewset.py index ec30e4d8d..c273fc800 100644 --- a/apps/client/views/client_contact_method_viewset.py +++ b/apps/company/views/contact_method_viewset.py @@ -1,4 +1,4 @@ -"""Client/contact contact method ViewSet.""" +"""Company/contact contact method ViewSet.""" from typing import cast @@ -7,8 +7,8 @@ from rest_framework import permissions, viewsets from rest_framework.serializers import BaseSerializer -from apps.client.models import ClientContactMethod -from apps.client.serializers import ClientContactMethodSerializer +from apps.company.models import ClientContactMethod +from apps.company.serializers import ClientContactMethodSerializer from apps.crm.tasks import rematch_phone_calls_task from apps.workflow.api.pagination import PageSizePagination @@ -25,7 +25,7 @@ def _phone_number_for_rematch(method: ClientContactMethod | None) -> str | None: class ClientContactMethodViewSet(viewsets.ModelViewSet): - """CRUD API for canonical client/contact phone and email methods.""" + """CRUD API for canonical company/contact phone and email methods.""" serializer_class = ClientContactMethodSerializer permission_classes = [permissions.IsAuthenticated] @@ -34,7 +34,7 @@ class ClientContactMethodViewSet(viewsets.ModelViewSet): @extend_schema( parameters=[ OpenApiParameter( - name="client_id", + name="company_id", type=OpenApiTypes.UUID, location=OpenApiParameter.QUERY, required=False, @@ -70,14 +70,14 @@ def list(self, request, *args, **kwargs): def get_queryset(self): queryset = ClientContactMethod.objects.select_related( - "client", + "company", "contact", - "contact__client", + "contact__company", ) - client_id = self.request.query_params.get("client_id") - if client_id: - queryset = queryset.filter(client_id=client_id) | queryset.filter( - contact__client_id=client_id + company_id = self.request.query_params.get("company_id") + if company_id: + queryset = queryset.filter(company_id=company_id) | queryset.filter( + contact__company_id=company_id ) contact_id = self.request.query_params.get("contact_id") diff --git a/apps/client/views/client_contact_viewset.py b/apps/company/views/contact_viewset.py similarity index 61% rename from apps/client/views/client_contact_viewset.py rename to apps/company/views/contact_viewset.py index c7c98a2cf..9d149954d 100644 --- a/apps/client/views/client_contact_viewset.py +++ b/apps/company/views/contact_viewset.py @@ -9,8 +9,8 @@ from drf_spectacular.utils import OpenApiParameter, extend_schema from rest_framework import permissions, viewsets -from apps.client.models import ClientContact -from apps.client.serializers import ClientContactSerializer +from apps.company.models import ClientContact +from apps.company.serializers import ClientContactSerializer class ClientContactViewSet(viewsets.ModelViewSet): @@ -18,15 +18,15 @@ class ClientContactViewSet(viewsets.ModelViewSet): ViewSet for ClientContact CRUD operations. Endpoints: - - GET /api/clients/contacts/ - list all contacts - - POST /api/clients/contacts/ - create contact - - GET /api/clients/contacts// - retrieve contact - - PUT /api/clients/contacts// - full update - - PATCH /api/clients/contacts// - partial update - - DELETE /api/clients/contacts// - soft delete (sets is_active=False) + - GET /api/companies/contacts/ - list all contacts + - POST /api/companies/contacts/ - create contact + - GET /api/companies/contacts// - retrieve contact + - PUT /api/companies/contacts// - full update + - PATCH /api/companies/contacts// - partial update + - DELETE /api/companies/contacts// - soft delete (sets is_active=False) Query Parameters: - - client_id: Filter contacts by client UUID + - company_id: Filter contacts by company UUID """ queryset = ClientContact.objects.filter(is_active=True) @@ -36,26 +36,26 @@ class ClientContactViewSet(viewsets.ModelViewSet): @extend_schema( parameters=[ OpenApiParameter( - name="client_id", + name="company_id", type=OpenApiTypes.UUID, location=OpenApiParameter.QUERY, - description="Filter contacts by client UUID", + description="Filter contacts by company UUID", required=False, ), ] ) def list(self, request, *args, **kwargs): - """List all contacts, optionally filtered by client_id.""" + """List all contacts, optionally filtered by company_id.""" return super().list(request, *args, **kwargs) def get_queryset(self): """ - Filter to only active contacts, optionally filtered by client_id. + Filter to only active contacts, optionally filtered by company_id. """ queryset = ClientContact.objects.filter(is_active=True) - client_id = self.request.query_params.get("client_id") - if client_id: - queryset = queryset.filter(client_id=client_id) + company_id = self.request.query_params.get("company_id") + if company_id: + queryset = queryset.filter(company_id=company_id) return queryset.order_by("-is_primary", "name") def perform_destroy(self, instance): diff --git a/apps/client/views/supplier_pickup_address_viewset.py b/apps/company/views/supplier_pickup_address_viewset.py similarity index 67% rename from apps/client/views/supplier_pickup_address_viewset.py rename to apps/company/views/supplier_pickup_address_viewset.py index ecf5408d0..e8b41350c 100644 --- a/apps/client/views/supplier_pickup_address_viewset.py +++ b/apps/company/views/supplier_pickup_address_viewset.py @@ -4,16 +4,16 @@ ViewSet for SupplierPickupAddress CRUD operations using DRF's ModelViewSet. Provides list, create, retrieve, update, partial_update, and destroy actions. -These are delivery/pickup locations for suppliers (or any client). -Despite the name, addresses can be created for any client, not just suppliers. +These are delivery/pickup locations for suppliers (or any company). +Despite the name, addresses can be created for any company, not just suppliers. """ from drf_spectacular.types import OpenApiTypes from drf_spectacular.utils import OpenApiParameter, extend_schema from rest_framework import permissions, viewsets -from apps.client.models import SupplierPickupAddress -from apps.client.serializers import SupplierPickupAddressSerializer +from apps.company.models import SupplierPickupAddress +from apps.company.serializers import SupplierPickupAddressSerializer class SupplierPickupAddressViewSet(viewsets.ModelViewSet): @@ -21,15 +21,15 @@ class SupplierPickupAddressViewSet(viewsets.ModelViewSet): ViewSet for SupplierPickupAddress CRUD operations. Endpoints: - - GET /api/clients/pickup-addresses/ - list all addresses - - POST /api/clients/pickup-addresses/ - create address - - GET /api/clients/pickup-addresses// - retrieve address - - PUT /api/clients/pickup-addresses// - full update - - PATCH /api/clients/pickup-addresses// - partial update - - DELETE /api/clients/pickup-addresses// - soft delete (sets is_active=False) + - GET /api/companies/pickup-addresses/ - list all addresses + - POST /api/companies/pickup-addresses/ - create address + - GET /api/companies/pickup-addresses// - retrieve address + - PUT /api/companies/pickup-addresses// - full update + - PATCH /api/companies/pickup-addresses// - partial update + - DELETE /api/companies/pickup-addresses// - soft delete (sets is_active=False) Query Parameters: - - supplier_id: Filter addresses by supplier (client) UUID + - supplier_id: Filter addresses by supplier (company) UUID """ queryset = SupplierPickupAddress.objects.filter(is_active=True) @@ -58,7 +58,7 @@ def get_queryset(self): queryset = SupplierPickupAddress.objects.filter(is_active=True) supplier_id = self.request.query_params.get("supplier_id") if supplier_id: - queryset = queryset.filter(client_id=supplier_id) + queryset = queryset.filter(company_id=supplier_id) return queryset.order_by("-is_primary", "name") def perform_destroy(self, instance): diff --git a/apps/client/views/supplier_search_alias_views.py b/apps/company/views/supplier_search_alias_views.py similarity index 83% rename from apps/client/views/supplier_search_alias_views.py rename to apps/company/views/supplier_search_alias_views.py index 15a219639..632004646 100644 --- a/apps/client/views/supplier_search_alias_views.py +++ b/apps/company/views/supplier_search_alias_views.py @@ -11,8 +11,8 @@ from rest_framework.response import Response from rest_framework.views import APIView -from apps.client.models import Client, SupplierSearchAlias -from apps.client.serializers import ( +from apps.company.models import Company, SupplierSearchAlias +from apps.company.serializers import ( SupplierSearchAliasCreateSerializer, SupplierSearchAliasSerializer, ) @@ -43,31 +43,31 @@ def _build_server_error_response(*, message: str, exc: Exception) -> Response: get=extend_schema( summary="List supplier search aliases", responses=SupplierSearchAliasSerializer(many=True), - tags=["Clients"], + tags=["Companies"], ), post=extend_schema( summary="Create supplier search alias", request=SupplierSearchAliasCreateSerializer, responses={status.HTTP_201_CREATED: SupplierSearchAliasSerializer}, - tags=["Clients"], + tags=["Companies"], ), ) -class ClientSupplierAliasListCreateView(APIView): - """List and create search aliases for a client/supplier contact.""" +class CompanySupplierAliasListCreateView(APIView): + """List and create search aliases for a company/supplier contact.""" permission_classes = [IsAuthenticated] - def get(self, request: Request, client_id) -> Response: + def get(self, request: Request, company_id) -> Response: try: - client = Client.objects.get(id=client_id) - aliases = client.supplier_search_aliases.filter(is_active=True).order_by( + company = Company.objects.get(id=company_id) + aliases = company.supplier_search_aliases.filter(is_active=True).order_by( "alias" ) serializer = SupplierSearchAliasSerializer(aliases, many=True) return Response(serializer.data) - except Client.DoesNotExist: + except Company.DoesNotExist: return Response( - {"error": "Client not found"}, + {"error": "Company not found"}, status=status.HTTP_404_NOT_FOUND, ) except Exception as exc: @@ -75,15 +75,15 @@ def get(self, request: Request, client_id) -> Response: message="Error listing supplier aliases", exc=exc ) - def post(self, request: Request, client_id) -> Response: + def post(self, request: Request, company_id) -> Response: try: - client = Client.objects.get(id=client_id) + company = Company.objects.get(id=company_id) serializer = SupplierSearchAliasCreateSerializer(data=request.data) serializer.is_valid(raise_exception=True) alias_text = serializer.validated_data["alias"].strip() alias, _ = SupplierSearchAlias.objects.get_or_create( - client=client, + company=company, alias=alias_text, defaults={"is_active": True}, ) @@ -93,9 +93,9 @@ def post(self, request: Request, client_id) -> Response: output = SupplierSearchAliasSerializer(alias) return Response(output.data, status=status.HTTP_201_CREATED) - except Client.DoesNotExist: + except Company.DoesNotExist: return Response( - {"error": "Client not found"}, + {"error": "Company not found"}, status=status.HTTP_404_NOT_FOUND, ) except ValidationError as exc: @@ -113,7 +113,7 @@ def post(self, request: Request, client_id) -> Response: delete=extend_schema( summary="Deactivate supplier search alias", responses={204: None}, - tags=["Clients"], + tags=["Companies"], ) ) class SupplierAliasDetailView(APIView): diff --git a/apps/crm/migrations/0001_baseline.py b/apps/crm/migrations/0001_baseline.py index 99570b94b..71e3b6941 100644 --- a/apps/crm/migrations/0001_baseline.py +++ b/apps/crm/migrations/0001_baseline.py @@ -13,7 +13,7 @@ class Migration(migrations.Migration): initial = True dependencies = [ - ("client", "0001_baseline"), + ("company", "0001_baseline"), ("job", "0001_baseline"), migrations.swappable_dependency(settings.AUTH_USER_MODEL), ] @@ -167,7 +167,7 @@ class Migration(migrations.Migration): null=True, on_delete=django.db.models.deletion.SET_NULL, related_name="phone_calls", - to="client.client", + to="company.client", ), ), ( @@ -177,7 +177,7 @@ class Migration(migrations.Migration): null=True, on_delete=django.db.models.deletion.SET_NULL, related_name="phone_calls", - to="client.clientcontact", + to="company.clientcontact", ), ), ( diff --git a/apps/crm/migrations/0004_rename_client_company.py b/apps/crm/migrations/0004_rename_client_company.py new file mode 100644 index 000000000..c99fa2b8e --- /dev/null +++ b/apps/crm/migrations/0004_rename_client_company.py @@ -0,0 +1,30 @@ +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("crm", "0003_drop_orphaned_phone_mapping_table"), + ] + + operations = [ + # The index is removed BEFORE the field rename and re-added after: + # index ops render their field list against the state they run in, so + # the reverse direction of a RenameField/RemoveIndex/AddIndex ordering + # would re-add the index against the renamed field and fail. + migrations.RemoveIndex( + model_name="phonecallrecord", + name="crm_phone_client_call_idx", + ), + migrations.RenameField( + model_name="phonecallrecord", + old_name="client", + new_name="company", + ), + migrations.AddIndex( + model_name="phonecallrecord", + index=models.Index( + fields=["company", "-call_datetime"], + name="crm_phone_company_call_idx", + ), + ), + ] diff --git a/apps/crm/models.py b/apps/crm/models.py index 40df06676..01e42b576 100644 --- a/apps/crm/models.py +++ b/apps/crm/models.py @@ -61,16 +61,16 @@ def save( using: str | None = None, update_fields: Iterable[str] | None = None, ) -> None: - from apps.client.models import ClientContactMethod + from apps.company.models import ClientContactMethod self.normalized_number = ClientContactMethod.normalize_phone(self.number) if not self.normalized_number: raise ValueError("phone endpoint requires a phone number") if self.is_active and self._active_number_changed(): # Mirror of the ClientContactMethod.save() guard: a number cannot be - # both a client contact method and an active internal endpoint, or - # the client's calls would silently reclassify as INTERNAL. - conflict = ClientContactMethod.conflicting_client( + # both a company contact method and an active internal endpoint, or + # the company's calls would silently reclassify as INTERNAL. + conflict = ClientContactMethod.conflicting_company( self.normalized_number, None ) if conflict: @@ -181,15 +181,15 @@ class Direction(models.TextChoices): ) duration_seconds = models.PositiveIntegerField(default=0) charge = models.DecimalField(max_digits=12, decimal_places=4, null=True, blank=True) - client = models.ForeignKey( - "client.Client", + company = models.ForeignKey( + "company.Company", on_delete=models.SET_NULL, null=True, blank=True, related_name="phone_calls", ) contact = models.ForeignKey( - "client.ClientContact", + "company.ClientContact", on_delete=models.SET_NULL, null=True, blank=True, @@ -222,8 +222,8 @@ class Meta: name="crm_phone_acct_call_idx", ), models.Index( - fields=["client", "-call_datetime"], - name="crm_phone_client_call_idx", + fields=["company", "-call_datetime"], + name="crm_phone_company_call_idx", ), models.Index( fields=["contact", "-call_datetime"], @@ -263,7 +263,7 @@ def save( using: str | None = None, update_fields: Iterable[str] | None = None, ) -> None: - from apps.client.models import ClientContactMethod + from apps.company.models import ClientContactMethod self.normalized_origin = ClientContactMethod.normalize_phone(self.origin) self.normalized_destination = ClientContactMethod.normalize_phone( diff --git a/apps/crm/serializers.py b/apps/crm/serializers.py index 933059ffc..d73e80a3d 100644 --- a/apps/crm/serializers.py +++ b/apps/crm/serializers.py @@ -3,7 +3,7 @@ from drf_spectacular.utils import extend_schema_field from rest_framework import serializers -from apps.client.models import ClientContactMethod +from apps.company.models import ClientContactMethod from apps.crm.models import ( PhoneCallRecord, PhoneCallRecording, @@ -25,9 +25,9 @@ class PhoneCallJobLinkSerializer(serializers.Serializer[None]): class PhoneNumberAssignmentSerializer(serializers.Serializer[None]): - """Request body for assigning a call's external number to a client.""" + """Request body for assigning a call's external number to a company.""" - client = serializers.UUIDField() + company = serializers.UUIDField() contact = serializers.UUIDField(required=False, allow_null=True, default=None) is_primary = serializers.BooleanField(required=False, default=False) @@ -122,7 +122,7 @@ def validate_number(self, value: str) -> str: return value.strip() def validate(self, attrs: "PhoneEndpointAttrs") -> "PhoneEndpointAttrs": - """Reject an active endpoint over a number a client already owns. + """Reject an active endpoint over a number a company already owns. Mirrors PhoneEndpoint.save() so the API returns a clean 400 instead of a 500. Grandfathering symmetry: only enforced on create or when the @@ -150,7 +150,7 @@ def validate(self, attrs: "PhoneEndpointAttrs") -> "PhoneEndpointAttrs": return attrs # association unchanged — grandfathered, like save() if is_active: - conflict = ClientContactMethod.conflicting_client(normalized, None) + conflict = ClientContactMethod.conflicting_company(normalized, None) if conflict: raise serializers.ValidationError( { @@ -242,7 +242,7 @@ def get_has_password(self, obj: PhoneProviderSettings) -> bool: class PhoneCallRecordSerializer(serializers.ModelSerializer[PhoneCallRecord]): recording = serializers.SerializerMethodField() - client_name = serializers.SerializerMethodField() + company_name = serializers.SerializerMethodField() contact_name = serializers.SerializerMethodField() origin_endpoint_label = serializers.SerializerMethodField() destination_endpoint_label = serializers.SerializerMethodField() @@ -273,8 +273,8 @@ class Meta: "destination_endpoint_label", "duration_seconds", "charge", - "client", - "client_name", + "company", + "company_name", "contact", "contact_name", "job", @@ -289,8 +289,8 @@ class Meta: ) read_only_fields = fields - def get_client_name(self, obj: PhoneCallRecord) -> str: - return obj.client.name if obj.client else "" + def get_company_name(self, obj: PhoneCallRecord) -> str: + return obj.company.name if obj.company else "" def get_contact_name(self, obj: PhoneCallRecord) -> str: return obj.contact.name if obj.contact else "" diff --git a/apps/crm/services/phone_call_service.py b/apps/crm/services/phone_call_service.py index 13fa775ab..9c8ed1ef8 100644 --- a/apps/crm/services/phone_call_service.py +++ b/apps/crm/services/phone_call_service.py @@ -16,7 +16,7 @@ from django.db import models, transaction from django.utils import timezone -from apps.client.models import Client, ClientContact, ClientContactMethod +from apps.company.models import ClientContact, ClientContactMethod, Company from apps.crm.models import ( PhoneCallRecord, PhoneCallRecording, @@ -256,9 +256,9 @@ def link_phone_call_to_job( except PhoneCallRecord.DoesNotExist as exc: raise ValueError("Phone call not found") from exc - if not call.client_id: + if not call.company_id: raise ValueError( - "Phone call must be assigned to a client before linking a job" + "Phone call must be assigned to a company before linking a job" ) try: @@ -266,9 +266,9 @@ def link_phone_call_to_job( except Job.DoesNotExist as exc: raise ValueError("Job not found") from exc - if job.client_id != call.client_id: + if job.company_id != call.company_id: raise ValueError( - "Phone call can only be linked to a job for the same client" + "Phone call can only be linked to a job for the same company" ) call.job = job @@ -309,7 +309,7 @@ def unlink_phone_call_job(*, call_id: str) -> PhoneCallRecord: def assign_phone_number_from_call( *, call_id: str, - client_id: str, + company_id: str, contact_id: str | None = None, label: str = "", is_primary: bool = False, @@ -325,7 +325,7 @@ def assign_phone_number_from_call( assign_phone_number( phone_number=call.external_number, - client_id=client_id, + company_id=company_id, contact_id=contact_id, label=label, is_primary=is_primary, @@ -524,7 +524,7 @@ def upsert_call_record( "destination_endpoint": classification.destination_endpoint, "duration_seconds": _positive_int(payload.get("seconds")), "charge": _decimal_or_none(payload.get("charge")), - "client": classification.client, + "company": classification.company, "contact": classification.contact, "raw_json": payload, } @@ -595,12 +595,12 @@ class CallClassification: external_number: str origin_endpoint: PhoneEndpoint | None destination_endpoint: PhoneEndpoint | None - client: Client | None + company: Company | None contact: ClientContact | None class PhoneMatcher: - """Classifies calls against internal endpoints and client phone methods. + """Classifies calls against internal endpoints and company phone methods. With ``numbers=None`` the full phone book is indexed (ingest classification). Passing a set of normalized numbers restricts the index @@ -617,7 +617,7 @@ def __init__(self, numbers: set[str] | None = None): def match_customer( self, *values: str - ) -> tuple[Client | None, ClientContact | None]: + ) -> tuple[Company | None, ClientContact | None]: matches: set[tuple[str, str, str]] = set() for value in values: normalized = normalize_phone(value) @@ -628,20 +628,20 @@ def match_customer( if not matches: return None, None - client_ids = {client_id for _, _, client_id in matches} - if len(client_ids) != 1: + company_ids = {company_id for _, _, company_id in matches} + if len(company_ids) != 1: return None, None if len(matches) != 1: - client = Client.objects.get(id=next(iter(client_ids))) - return client, None + company = Company.objects.get(id=next(iter(company_ids))) + return company, None - kind, object_id, _client_id = next(iter(matches)) + kind, object_id, _company_id = next(iter(matches)) if kind == "contact": - contact = ClientContact.objects.select_related("client").get(id=object_id) - return contact.client, contact - client = Client.objects.get(id=object_id) - return client, None + contact = ClientContact.objects.select_related("company").get(id=object_id) + return contact.company, contact + company = Company.objects.get(id=object_id) + return company, None def classify(self, origin: str, destination: str) -> CallClassification: normalized_origin = normalize_phone(origin) @@ -656,35 +656,37 @@ def classify(self, origin: str, destination: str) -> CallClassification: external_number="", origin_endpoint=origin_endpoint, destination_endpoint=destination_endpoint, - client=None, + company=None, contact=None, ) if origin_endpoint: - client, contact = self.match_customer(normalized_destination) + company, contact = self.match_customer(normalized_destination) return CallClassification( direction=PhoneCallRecord.Direction.OUTBOUND, our_number=normalized_origin, external_number=normalized_destination, origin_endpoint=origin_endpoint, destination_endpoint=None, - client=client, + company=company, contact=contact, ) if destination_endpoint: - client, contact = self.match_customer(normalized_origin) + company, contact = self.match_customer(normalized_origin) return CallClassification( direction=PhoneCallRecord.Direction.INBOUND, our_number=normalized_destination, external_number=normalized_origin, origin_endpoint=None, destination_endpoint=destination_endpoint, - client=client, + company=company, contact=contact, ) - client, contact = self.match_customer(normalized_origin, normalized_destination) + company, contact = self.match_customer( + normalized_origin, normalized_destination + ) external_number = "" if normalized_origin and not normalized_destination: external_number = normalized_origin @@ -698,7 +700,7 @@ def classify(self, origin: str, destination: str) -> CallClassification: external_number=external_number, origin_endpoint=None, destination_endpoint=None, - client=client, + company=company, contact=contact, ) @@ -724,7 +726,7 @@ def configured_own_numbers() -> set[str]: _REMATCH_FK_FIELDS = frozenset( - {"client", "contact", "origin_endpoint", "destination_endpoint"} + {"company", "contact", "origin_endpoint", "destination_endpoint"} ) @@ -760,9 +762,11 @@ def rematch_calls_for_numbers(numbers: list[str]) -> None: matcher = PhoneMatcher(numbers=relevant_numbers) for call in calls: classification = matcher.classify(call.origin, call.destination) - matched_client_id = classification.client.id if classification.client else None + matched_company_id = ( + classification.company.id if classification.company else None + ) new_values: dict[str, object] = { - "client": classification.client, + "company": classification.company, "contact": classification.contact, "direction": classification.direction, "our_number": classification.our_number, @@ -780,14 +784,14 @@ def rematch_calls_for_numbers(numbers: list[str]) -> None: if linked_job is None: should_clear_job_link = False else: - should_clear_job_link = linked_job.client_id != matched_client_id + should_clear_job_link = linked_job.company_id != matched_company_id if should_clear_job_link: call.job = None call.job_linked_by = None call.job_linked_at = None update_fields.extend(["job", "job_linked_by", "job_linked_at"]) else: - pass # No linked job, or the existing job is still on the matched client. + pass # No linked job, or the existing job is still on the matched company. for name, value in new_values.items(): setattr(call, name, value) call.save(update_fields=update_fields) @@ -796,7 +800,7 @@ def rematch_calls_for_numbers(numbers: list[str]) -> None: def assign_phone_number( *, phone_number: str, - client_id: str, + company_id: str, contact_id: str | None = None, label: str = "", is_primary: bool = False, @@ -805,20 +809,20 @@ def assign_phone_number( if not normalized: raise ValueError("phone number is required") if normalized in configured_own_numbers(): - raise ValueError("internal phone endpoint cannot be assigned to a client") + raise ValueError("internal phone endpoint cannot be assigned to a company") - client_uuid = _uuid_or_client_error(client_id, "Client not found") + company_uuid = _uuid_or_client_error(company_id, "Company not found") if contact_id: contact_uuid = _uuid_or_client_error(contact_id, "Contact not found") try: - contact = ClientContact.objects.select_related("client").get( + contact = ClientContact.objects.select_related("company").get( id=contact_uuid, - client_id=client_uuid, + company_id=company_uuid, is_active=True, ) except ClientContact.DoesNotExist as exc: raise ValueError("Contact not found") from exc - owner_filter = {"contact": contact, "client": None} + owner_filter = {"contact": contact, "company": None} existing_primary = ClientContactMethod.objects.filter( contact=contact, method_type=ClientContactMethod.MethodType.PHONE, @@ -826,19 +830,19 @@ def assign_phone_number( ).exists() else: try: - client = Client.objects.get(id=client_uuid) - except Client.DoesNotExist as exc: - raise ValueError("Client not found") from exc - owner_filter = {"client": client, "contact": None} + company = Company.objects.get(id=company_uuid) + except Company.DoesNotExist as exc: + raise ValueError("Company not found") from exc + owner_filter = {"company": company, "contact": None} existing_primary = ClientContactMethod.objects.filter( - client=client, + company=company, contact__isnull=True, method_type=ClientContactMethod.MethodType.PHONE, is_primary=True, ).exists() should_be_primary = is_primary or not existing_primary - conflict = ClientContactMethod.conflicting_client(normalized, client_uuid) + conflict = ClientContactMethod.conflicting_company(normalized, company_uuid) if conflict: raise ValueError( f"phone number already belongs to {conflict.owner_display_name()}" @@ -879,9 +883,9 @@ def _build_contact_method_phone_index( index: dict[str, set[tuple[str, str, str]]] = {} internal_numbers = configured_own_numbers() methods = ClientContactMethod.objects.select_related( - "client", + "company", "contact", - "contact__client", + "contact__company", ).filter(method_type=ClientContactMethod.MethodType.PHONE) if numbers is not None: methods = methods.filter(normalized_value__in=numbers) @@ -893,13 +897,13 @@ def _build_contact_method_phone_index( if not method.contact.is_active: continue index.setdefault(normalized, set()).add( - ("contact", str(method.contact_id), str(method.contact.client_id)) + ("contact", str(method.contact_id), str(method.contact.company_id)) ) else: - if not method.client.allow_jobs: + if not method.company.allow_jobs: continue index.setdefault(normalized, set()).add( - ("client", str(method.client_id), str(method.client_id)) + ("company", str(method.company_id), str(method.company_id)) ) return index diff --git a/apps/crm/tests/test_phone_call_service.py b/apps/crm/tests/test_phone_call_service.py index 54cedc1ef..23b7d2eb5 100644 --- a/apps/crm/tests/test_phone_call_service.py +++ b/apps/crm/tests/test_phone_call_service.py @@ -10,7 +10,7 @@ from rest_framework.test import APIClient from apps.accounts.models import Staff -from apps.client.models import Client, ClientContact, ClientContactMethod +from apps.company.models import ClientContact, ClientContactMethod, Company from apps.crm.models import ( PhoneCallRecord, PhoneCallRecording, @@ -83,58 +83,58 @@ def test_normalize_phone_matches_nz_variants_by_local_tail(self) -> None: class PhoneMatcherDatabaseTests(TestCase): """Business case: call records drive customer history. The matcher should attach calls when ownership is unambiguous, and refuse ambiguous matches so - one client's calls are not shown under another client or contact. + one company's calls are not shown under another company or contact. """ - def _client(self, name: str) -> Client: - return Client.objects.create(name=name, xero_last_modified=timezone.now()) + def _client(self, name: str) -> Company: + return Company.objects.create(name=name, xero_last_modified=timezone.now()) def test_assign_phone_number_creates_primary_contact_method_and_matches_client( self, ) -> None: - client = self._client("Acme Ltd") + company = self._client("Acme Ltd") method = assign_phone_number( phone_number="09 636 5131", - client_id=str(client.id), + company_id=str(company.id), label="Reception", ) - matched_client, matched_contact = PhoneMatcher().match_customer( + matched_company, matched_contact = PhoneMatcher().match_customer( "+6496365131", "+6490000000", ) self.assertEqual(method.normalized_value, "+6496365131") self.assertTrue(method.is_primary) - self.assertEqual(matched_client, client) + self.assertEqual(matched_company, company) self.assertIsNone(matched_contact) def test_assign_number_allowed_on_contact_of_owning_client(self) -> None: - """Assigning a client's number to one of its own contacts is not a conflict.""" - client = self._client("Acme Ltd") - contact = ClientContact.objects.create(client=client, name="Jane Smith") - assign_phone_number(phone_number="021 555 900", client_id=str(client.id)) + """Assigning a company's number to one of its own contacts is not a conflict.""" + company = self._client("Acme Ltd") + contact = ClientContact.objects.create(company=company, name="Jane Smith") + assign_phone_number(phone_number="021 555 900", company_id=str(company.id)) method = assign_phone_number( phone_number="021 555 900", - client_id=str(client.id), + company_id=str(company.id), contact_id=str(contact.id), ) self.assertEqual(method.contact_id, contact.id) def test_assign_number_rejected_for_different_client(self) -> None: - """A number owned by one client cannot be assigned to a different client.""" + """A number owned by one company cannot be assigned to a different company.""" owner = self._client("Acme Ltd") other = self._client("Beta Ltd") - assign_phone_number(phone_number="021 555 901", client_id=str(owner.id)) + assign_phone_number(phone_number="021 555 901", company_id=str(owner.id)) with self.assertRaisesRegex(ValueError, "already belongs"): - assign_phone_number(phone_number="021 555 901", client_id=str(other.id)) + assign_phone_number(phone_number="021 555 901", company_id=str(other.id)) def test_single_contact_method_matches_contact(self) -> None: - client = self._client("Acme Ltd") - contact = ClientContact.objects.create(client=client, name="Jane Smith") + company = self._client("Acme Ltd") + contact = ClientContact.objects.create(company=company, name="Jane Smith") ClientContactMethod.objects.create( contact=contact, method_type=ClientContactMethod.MethodType.PHONE, @@ -142,21 +142,21 @@ def test_single_contact_method_matches_contact(self) -> None: is_primary=True, ) - matched_client, matched_contact = PhoneMatcher().match_customer( + matched_company, matched_contact = PhoneMatcher().match_customer( "+6490000000", "+6421555123", ) - self.assertEqual(matched_client, client) + self.assertEqual(matched_company, company) self.assertEqual(matched_contact, contact) def test_same_number_across_contacts_on_one_client_is_allowed( self, ) -> None: - """Two contacts of one client resolve to a single effective client owner.""" - client = self._client("Acme Ltd") - jane = ClientContact.objects.create(client=client, name="Jane Smith") - john = ClientContact.objects.create(client=client, name="John Smith") + """Two contacts of one company resolve to a single effective company owner.""" + company = self._client("Acme Ltd") + jane = ClientContact.objects.create(company=company, name="Jane Smith") + john = ClientContact.objects.create(company=company, name="John Smith") ClientContactMethod.objects.create( contact=jane, method_type=ClientContactMethod.MethodType.PHONE, @@ -168,24 +168,24 @@ def test_same_number_across_contacts_on_one_client_is_allowed( value="021 555 123", ) - matched_client, matched_contact = PhoneMatcher().match_customer("+6421555123") + matched_company, matched_contact = PhoneMatcher().match_customer("+6421555123") self.assertIsNotNone(on_john.pk) - self.assertEqual(matched_client, client) + self.assertEqual(matched_company, company) self.assertIsNone(matched_contact) def test_same_number_across_clients_is_rejected(self) -> None: first = self._client("Acme Ltd") second = self._client("Beta Ltd") ClientContactMethod.objects.create( - client=first, + company=first, method_type=ClientContactMethod.MethodType.PHONE, value="021 555 123", ) with self.assertRaises(ValidationError): ClientContactMethod.objects.create( - client=second, + company=second, method_type=ClientContactMethod.MethodType.PHONE, value="021 555 123", ) @@ -213,16 +213,16 @@ def test_rematch_clears_job_link_when_number_moves_to_other_client( second = self._client("Beta Ltd") CompanyDefaults.objects.create( company_name="Test Company", - shop_client=first, + shop_company=first, ) staff = Staff.objects.create_user( email="rematch-link@example.com", password="testpass", is_office_staff=True, ) - job = Job.objects.create(client=first, name="Linked Job", staff=staff) + job = Job.objects.create(company=first, name="Linked Job", staff=staff) ClientContactMethod.objects.create( - client=second, + company=second, method_type=ClientContactMethod.MethodType.PHONE, value="+6421555123", normalized_value="+6421555123", @@ -236,7 +236,7 @@ def test_rematch_clears_job_link_when_number_moves_to_other_client( call_time=call_datetime.time(), origin="021 555 123", destination="+6490000000", - client=first, + company=first, job=job, job_linked_by=staff, job_linked_at=call_datetime, @@ -250,7 +250,7 @@ def test_rematch_clears_job_link_when_number_moves_to_other_client( rematch_calls_for_numbers(["+6421555123"]) call.refresh_from_db() - self.assertEqual(call.client, second) + self.assertEqual(call.company, second) self.assertIsNone(call.contact) self.assertIsNone(call.job) self.assertIsNone(call.job_linked_by) @@ -264,13 +264,13 @@ class ProviderRecordingDeletionTests(TestCase): """ def setUp(self) -> None: - shop_client = Client.objects.create( - name="Shop Client", + shop_company = Company.objects.create( + name="Shop Company", xero_last_modified=timezone.now(), ) CompanyDefaults.objects.create( company_name="Test Company", - shop_client=shop_client, + shop_company=shop_company, ) PhoneProviderSettings.objects.update_or_create( pk=1, @@ -380,12 +380,12 @@ def tearDown(self) -> None: super().tearDown() def test_sync_archives_recording_and_is_idempotent(self) -> None: - client = Client.objects.create( - name="Sync Client", + company = Company.objects.create( + name="Sync Company", xero_last_modified=timezone.now(), ) ClientContactMethod.objects.create( - client=client, + company=company, method_type=ClientContactMethod.MethodType.PHONE, value="021 555 123", ) @@ -418,7 +418,7 @@ def test_sync_archives_recording_and_is_idempotent(self) -> None: self.assertEqual(PhoneCallRecording.objects.count(), 1) call = PhoneCallRecord.objects.get() - self.assertEqual(call.client, client) + self.assertEqual(call.company, company) recording = call.recording self.assertEqual(recording.filename, "call.mp3") self.assertEqual(recording.byte_size, len(b"recorded audio")) @@ -468,7 +468,7 @@ def _payload(self, *, recording_id: str = "recording-1") -> dict[str, str]: class PhoneCallJobLinkApiTests(BaseAPITestCase): """Business case: office staff must be able to connect an imported phone - call to the job it was about, while preventing cross-client history leaks. + call to the job it was about, while preventing cross-company history leaks. """ def setUp(self) -> None: @@ -491,25 +491,25 @@ def setUp(self) -> None: ) self.api = APIClient() self.api.force_authenticate(user=self.office_staff) - self.client_obj = Client.objects.create( - name="Phone Link Client", + self.company_obj = Company.objects.create( + name="Phone Link Company", xero_last_modified=timezone.now(), ) - self.other_client = Client.objects.create( - name="Other Phone Link Client", + self.other_company = Company.objects.create( + name="Other Phone Link Company", xero_last_modified=timezone.now(), ) self.job = Job.objects.create( - client=self.client_obj, + company=self.company_obj, name="Phone Link Job", staff=self.test_staff, ) self.other_job = Job.objects.create( - client=self.other_client, + company=self.other_company, name="Other Phone Link Job", staff=self.test_staff, ) - self.call = self._call("call-1", client=self.client_obj) + self.call = self._call("call-1", company=self.company_obj) def tearDown(self) -> None: self.settings_override.disable() @@ -542,8 +542,8 @@ def test_link_call_to_same_client_job_and_filter_by_job(self) -> None: def test_list_paginates_recent_calls(self) -> None: """Catches CRM calls page regressions that fetch the full call archive.""" - self._call("call-2", client=self.client_obj) - self._call("call-3", client=self.client_obj) + self._call("call-2", company=self.company_obj) + self._call("call-3", company=self.company_obj) response = self.api.get("/api/crm/phone-calls/", {"page_size": "2"}) @@ -587,7 +587,7 @@ def test_empty_list_uses_paginator_total_pages(self) -> None: def test_list_page_size_is_capped(self) -> None: """Catches accidental oversized phone-call responses.""" for index in range(101): - self._call(f"call-{index + 2}", client=self.client_obj) + self._call(f"call-{index + 2}", company=self.company_obj) response = self.api.get("/api/crm/phone-calls/", {"page_size": "250"}) @@ -601,8 +601,8 @@ def test_list_filters_unmatched_and_unlinked_calls(self) -> None: linked = self.call linked.job = self.job linked.save(update_fields=["job", "updated_at"]) - unlinked = self._call("unlinked", client=self.client_obj) - unmatched = self._call("unmatched", client=None) + unlinked = self._call("unlinked", company=self.company_obj) + unmatched = self._call("unmatched", company=None) unmatched_response = self.api.get( "/api/crm/phone-calls/", @@ -655,7 +655,7 @@ def test_list_filters_by_direction_recording_date_and_search(self) -> None: ) outbound = self._call( "outbound", - client=self.client_obj, + company=self.company_obj, origin="+6496365131", destination="+6421555999", ) @@ -678,7 +678,7 @@ def test_list_filters_by_direction_recording_date_and_search(self) -> None: "has_recording": "true", "from_date": timezone.localdate().isoformat(), "to_date": timezone.localdate().isoformat(), - "q": "Phone Link Client", + "q": "Phone Link Company", }, ) @@ -687,7 +687,7 @@ def test_list_filters_by_direction_recording_date_and_search(self) -> None: self.assertEqual(response.data["results"][0]["id"], str(recorded_call.id)) def test_bad_call_id_does_not_persist_app_error(self) -> None: - """Catches client typos being treated as server errors.""" + """Catches company typos being treated as server errors.""" before = AppError.objects.count() response = self.api.post( @@ -714,7 +714,7 @@ def test_malformed_call_id_does_not_persist_app_error(self) -> None: self.assertEqual(AppError.objects.count(), before) def test_bad_job_id_does_not_persist_app_error(self) -> None: - """Catches client typos being treated as server errors.""" + """Catches company typos being treated as server errors.""" before = AppError.objects.count() response = self.api.post( @@ -736,7 +736,7 @@ def test_service_rejects_malformed_job_id_as_client_error(self) -> None: ) def test_bad_call_id_on_unlink_does_not_persist_app_error(self) -> None: - """Catches client typos being treated as server errors.""" + """Catches company typos being treated as server errors.""" before = AppError.objects.count() response = self.api.delete(f"/api/crm/phone-calls/{uuid.uuid4()}/job-link/") @@ -762,13 +762,13 @@ def test_assign_number_bad_client_id_does_not_persist_app_error(self) -> None: response = self.api.post( f"/api/crm/phone-calls/{self.call.id}/assign-number/", { - "client": str(uuid.uuid4()), + "company": str(uuid.uuid4()), }, format="json", ) self.assertEqual(response.status_code, 400) - self.assertIn("Client not found", response.data["message"]) + self.assertIn("Company not found", response.data["message"]) self.assertEqual(AppError.objects.count(), before) def test_assign_number_cross_client_contact_does_not_persist_app_error( @@ -777,7 +777,7 @@ def test_assign_number_cross_client_contact_does_not_persist_app_error( self.call.external_number = "+6421555001" self.call.save(update_fields=["external_number", "updated_at"]) contact = ClientContact.objects.create( - client=self.other_client, + company=self.other_company, name="Other Contact", ) before = AppError.objects.count() @@ -785,7 +785,7 @@ def test_assign_number_cross_client_contact_does_not_persist_app_error( response = self.api.post( f"/api/crm/phone-calls/{self.call.id}/assign-number/", { - "client": str(self.client_obj.id), + "company": str(self.company_obj.id), "contact": str(contact.id), }, format="json", @@ -796,7 +796,7 @@ def test_assign_number_cross_client_contact_does_not_persist_app_error( self.assertEqual(AppError.objects.count(), before) def test_link_rejects_unmatched_call(self) -> None: - unmatched = self._call("unmatched", client=None) + unmatched = self._call("unmatched", company=None) response = self.api.post( f"/api/crm/phone-calls/{unmatched.id}/job-link/", @@ -805,7 +805,7 @@ def test_link_rejects_unmatched_call(self) -> None: ) self.assertEqual(response.status_code, 400) - self.assertIn("assigned to a client", response.data["message"]) + self.assertIn("assigned to a company", response.data["message"]) def test_link_rejects_cross_client_job(self) -> None: response = self.api.post( @@ -815,7 +815,7 @@ def test_link_rejects_cross_client_job(self) -> None: ) self.assertEqual(response.status_code, 400) - self.assertIn("same client", response.data["message"]) + self.assertIn("same company", response.data["message"]) def test_unlink_clears_job_metadata(self) -> None: self.call.job = self.job @@ -896,7 +896,7 @@ def _call( self, provider_id: str, *, - client: Client | None, + company: Company | None, origin: str = "+6421555123", destination: str = "+6496365131", ) -> PhoneCallRecord: @@ -910,7 +910,7 @@ def _call( call_time=call_datetime.time(), origin=origin, destination=destination, - client=client, + company=company, raw_json={ "id": provider_id, "calldate": call_date.isoformat(), diff --git a/apps/crm/tests/test_phone_endpoint_guard.py b/apps/crm/tests/test_phone_endpoint_guard.py index 12bf90f11..2a544321a 100644 --- a/apps/crm/tests/test_phone_endpoint_guard.py +++ b/apps/crm/tests/test_phone_endpoint_guard.py @@ -2,7 +2,7 @@ ClientContactMethod.save() refuses numbers held by an active PhoneEndpoint; these tests cover the mirror — an active endpoint cannot claim a number a -client already owns, or that client's calls silently become INTERNAL. +company already owns, or that company's calls silently become INTERNAL. """ from django.core.exceptions import ValidationError @@ -11,18 +11,18 @@ from rest_framework.test import APIClient from apps.accounts.models import Staff -from apps.client.models import Client, ClientContact, ClientContactMethod +from apps.company.models import ClientContact, ClientContactMethod, Company from apps.crm.models import PhoneEndpoint from apps.testing import BaseAPITestCase -def _client(name: str = "Acme Ltd") -> Client: - return Client.objects.create(name=name, xero_last_modified=timezone.now()) +def _client(name: str = "Acme Ltd") -> Company: + return Company.objects.create(name=name, xero_last_modified=timezone.now()) -def _client_phone(client: Client, value: str = "021 555 123") -> ClientContactMethod: +def _client_phone(company: Company, value: str = "021 555 123") -> ClientContactMethod: return ClientContactMethod.objects.create( - client=client, + company=company, method_type=ClientContactMethod.MethodType.PHONE, value=value, ) @@ -43,13 +43,13 @@ def test_create_active_endpoint_over_client_number_raises(self) -> None: _client_phone(_client("Acme Ltd")) with self.assertRaisesRegex( - ValidationError, "already belongs to.*client Acme Ltd" + ValidationError, "already belongs to.*company Acme Ltd" ): _endpoint("021 555 123") def test_error_names_owning_contact(self) -> None: contact = ClientContact.objects.create( - client=_client("Acme Ltd"), name="Jane Smith" + company=_client("Acme Ltd"), name="Jane Smith" ) ClientContactMethod.objects.create( contact=contact, @@ -89,7 +89,7 @@ def test_existing_endpoint_resave_is_grandfathered(self) -> None: # Legacy cross-owned row inserted bypassing the method-side guard, # as pre-guard data was. legacy = ClientContactMethod( - client=_client(), + company=_client(), method_type=ClientContactMethod.MethodType.PHONE, value="021 555 123", ) @@ -137,7 +137,7 @@ def test_create_endpoint_over_client_number_returns_400(self) -> None: def test_update_unrelated_field_on_grandfathered_endpoint_succeeds(self) -> None: endpoint = _endpoint("021 555 123") legacy = ClientContactMethod( - client=_client(), + company=_client(), method_type=ClientContactMethod.MethodType.PHONE, value="021 555 123", ) diff --git a/apps/crm/views/phone_call_views.py b/apps/crm/views/phone_call_views.py index 301836f4f..0185efe34 100644 --- a/apps/crm/views/phone_call_views.py +++ b/apps/crm/views/phone_call_views.py @@ -65,7 +65,7 @@ def _query_uuid(value: str | None, field_name: str) -> UUID | None: def _phone_call_filter_kwargs(request: Request) -> dict[str, UUID]: query_filters = ( - ("client_id", _query_uuid(request.query_params.get("client"), "client")), + ("company_id", _query_uuid(request.query_params.get("company"), "company")), ("contact_id", _query_uuid(request.query_params.get("contact"), "contact")), ("job_id", _query_uuid(request.query_params.get("job"), "job")), ( @@ -122,9 +122,9 @@ def _apply_client_match_filter( client_match: str, ) -> QuerySet[PhoneCallRecord]: if client_match == "matched": - return queryset.filter(client_id__isnull=False) + return queryset.filter(company_id__isnull=False) if client_match == "unmatched": - return queryset.filter(client_id__isnull=True) + return queryset.filter(company_id__isnull=True) return queryset @@ -184,7 +184,7 @@ def _apply_search_filter( normalized_phone = normalize_phone(search) filters = ( - Q(client__name__icontains=search) + Q(company__name__icontains=search) | Q(contact__name__icontains=search) | Q(origin_endpoint__label__icontains=search) | Q(destination_endpoint__label__icontains=search) @@ -240,7 +240,7 @@ class PhoneCallRecordViewSet(viewsets.ReadOnlyModelViewSet[PhoneCallRecord]): @extend_schema( parameters=[ - OpenApiParameter("client", OpenApiTypes.UUID, OpenApiParameter.QUERY), + OpenApiParameter("company", OpenApiTypes.UUID, OpenApiParameter.QUERY), OpenApiParameter("contact", OpenApiTypes.UUID, OpenApiParameter.QUERY), OpenApiParameter("job", OpenApiTypes.UUID, OpenApiParameter.QUERY), OpenApiParameter( @@ -282,7 +282,7 @@ def list(self, request: Request, *args: str, **kwargs: str) -> Response: def get_queryset(self) -> QuerySet[PhoneCallRecord]: queryset = ( PhoneCallRecord.objects.select_related( - "client", + "company", "contact", "job", "job_linked_by", @@ -301,7 +301,7 @@ def _call_operation_response( ) -> Response: """Run a phone-call service operation and serialize the updated call. - ValueError is the services' client-error contract (400, no AppError); + ValueError is the services' company-error contract (400, no AppError); anything else follows the mandatory two-arm persistence pattern. """ try: @@ -377,7 +377,7 @@ def assign_number( return self._call_operation_response( lambda: assign_phone_number_from_call( call_id=str(pk), - client_id=str(payload.validated_data["client"]), + company_id=str(payload.validated_data["company"]), contact_id=str(contact) if contact else None, label=payload.validated_data["label"], is_primary=payload.validated_data["is_primary"], diff --git a/apps/job/management/commands/create_shop_jobs.py b/apps/job/management/commands/create_shop_jobs.py index 809226514..b325751ca 100644 --- a/apps/job/management/commands/create_shop_jobs.py +++ b/apps/job/management/commands/create_shop_jobs.py @@ -13,12 +13,12 @@ def handle(self, *args, **kwargs): shop_jobs = [ { "name": "Business Development", - "description": "Sales without a specific client", + "description": "Sales without a specific company", }, { "name": "Bench - busy work", "description": ( - "Busy work not directly tied to client jobs. " + "Busy work not directly tied to company jobs. " "Could slip without significant issues" ), }, @@ -47,7 +47,7 @@ def handle(self, *args, **kwargs): ] company_defaults = CompanyDefaults.get_solo() - shop_client = company_defaults.shop_client + shop_company = company_defaults.shop_company # Iterate through the shop jobs and create them automation_user = Staff.get_automation_user() @@ -55,7 +55,7 @@ def handle(self, *args, **kwargs): # Create the job instance job = Job( name=job_details["name"], - client=shop_client, + company=shop_company, description="", status="special", shop_job=True, # Changed from shop_job to is_shop_job diff --git a/apps/job/migrations/0001_baseline.py b/apps/job/migrations/0001_baseline.py index 37a56db51..73c801867 100644 --- a/apps/job/migrations/0001_baseline.py +++ b/apps/job/migrations/0001_baseline.py @@ -16,7 +16,7 @@ class Migration(migrations.Migration): initial = True dependencies = [ - ("client", "0001_baseline"), + ("company", "0001_baseline"), ("workflow", "0001_baseline"), migrations.swappable_dependency(settings.AUTH_USER_MODEL), ] @@ -245,7 +245,7 @@ class Migration(migrations.Migration): null=True, on_delete=django.db.models.deletion.PROTECT, related_name="jobs", - to="client.client", + to="company.client", ), ), ( @@ -256,7 +256,7 @@ class Migration(migrations.Migration): null=True, on_delete=django.db.models.deletion.SET_NULL, related_name="jobs", - to="client.clientcontact", + to="company.clientcontact", ), ), ( diff --git a/apps/job/migrations/0003_rename_client_company.py b/apps/job/migrations/0003_rename_client_company.py new file mode 100644 index 000000000..c409a740f --- /dev/null +++ b/apps/job/migrations/0003_rename_client_company.py @@ -0,0 +1,15 @@ +from django.db import migrations + + +class Migration(migrations.Migration): + dependencies = [ + ("job", "0002_seed_labour_subtypes"), + ] + + operations = [ + migrations.RenameField( + model_name="job", + old_name="client", + new_name="company", + ), + ] diff --git a/apps/job/models/costing.py b/apps/job/models/costing.py index 2a017461e..15fd011dd 100644 --- a/apps/job/models/costing.py +++ b/apps/job/models/costing.py @@ -98,7 +98,7 @@ class CostLine(models.Model): TIME (kind='time'): - staff_id (str, UUID): Legacy Staff reference; use staff FK instead - date (str, ISO date): Date the work was performed (legacy, use accounting_date field) - - is_billable (bool): Whether this time is billable to the client + - is_billable (bool): Whether this time is billable to the company - start_time (str, ISO time): Start time of the timesheet entry - end_time (str, ISO time): End time of the timesheet entry - wage_rate_multiplier (float): Multiplier for staff wage rate (e.g., 1.5 for overtime) diff --git a/apps/job/models/job.py b/apps/job/models/job.py index 5872dbcf8..fe98357cf 100644 --- a/apps/job/models/job.py +++ b/apps/job/models/job.py @@ -9,7 +9,7 @@ from django.utils import timezone from apps.accounts.models import Staff -from apps.client.models import Client +from apps.company.models import Company from apps.job.enums import RDTIType, SpeedQualityTradeoff from apps.workflow.models import CompanyDefaults, XeroPayItem @@ -108,8 +108,8 @@ class Job(models.Model): # CHECKLIST - when adding a new field or property to Job, check these locations: # 1. JOB_DIRECT_FIELDS below (if it's a model field) # 2. JobSerializer.Meta.fields in apps/job/serializers/job_serializer.py - # 3. ClientJobHeaderSerializer in apps/client/serializers.py - # 4. get_client_jobs() response dict in apps/client/services/client_rest_service.py + # 3. ClientJobHeaderSerializer in apps/company/serializers.py + # 4. get_company_jobs() response dict in apps/company/services/company_rest_service.py # 5. KanbanService.serialize_job_for_api() in apps/job/services/kanban_service.py # 6. KanbanJobSerializer and KanbanColumnJobSerializer in apps/job/serializers/kanban_serializer.py # 7. TestSerializeJobForApi.test_serialized_shape_for_fully_populated_job @@ -207,17 +207,17 @@ class Job(models.Model): "archived": "The job has been paid for and picked up", } - client = models.ForeignKey( - "client.Client", - on_delete=models.PROTECT, # Prevent deletion of clients with jobs + company = models.ForeignKey( + "company.Company", + on_delete=models.PROTECT, # Prevent deletion of companies with jobs null=True, - related_name="jobs", # Allows reverse lookup of jobs for a client + related_name="jobs", # Allows reverse lookup of jobs for a company ) order_number = models.CharField(max_length=100, null=True, blank=True) # New relationship to ClientContact contact = models.ForeignKey( - "client.ClientContact", + "company.ClientContact", on_delete=models.SET_NULL, null=True, blank=True, @@ -301,7 +301,7 @@ class Job(models.Model): # related_name="revisions", # on_delete=models.SET_NULL, # ) - # Shop job has no client (client_id is None) + # Shop job has no company (company_id is None) job_is_valid = models.BooleanField(default=False) collected: bool = models.BooleanField(default=False) @@ -421,21 +421,21 @@ def _calculate_next_priority_for_status(cls, status_value: str) -> float: @property def shop_job(self) -> bool: - """Indicates if this is a shop job (belongs to shop client).""" - if not self.client_id: + """Indicates if this is a shop job (belongs to shop company).""" + if not self.company_id: return False defaults = CompanyDefaults.get_solo() - return self.client_id == defaults.shop_client_id + return self.company_id == defaults.shop_company_id @shop_job.setter def shop_job(self, value: bool) -> None: - """Sets whether this is a shop job by updating the client ID.""" + """Sets whether this is a shop job by updating the company ID.""" if value: defaults = CompanyDefaults.get_solo() - self.client_id = defaults.shop_client_id + self.company_id = defaults.shop_company_id else: - self.client_id = None + self.company_id = None @property def quoted(self) -> bool: @@ -553,11 +553,11 @@ def set_latest(self, kind: str, cost_set: "CostSet", staff) -> None: @property def job_display_name(self) -> str: """ - Returns a formatted display name for the job including client name. - Format: job_number - (first 12 chars of client name), job_name + Returns a formatted display name for the job including company name. + Format: job_number - (first 12 chars of company name), job_name """ - client_name = self.client.name[:12] if self.client else "No Client" - return f"{self.job_number} - {client_name}, {self.name}" + company_name = self.company.name[:12] if self.company else "No Company" + return f"{self.job_number} - {company_name}, {self.name}" @property def start_date(self) -> Optional[date]: @@ -930,19 +930,25 @@ def _handle_status_change(self_job, old_status, new_status): ) @staticmethod - def _handle_client_change(self_job, old_client_id, new_client_id): - old_client = "Shop Job" - new_client = "Shop Job" - if old_client_id: + def _handle_company_change(self_job, old_company_id, new_company_id): + old_company = "Shop Job" + new_company = "Shop Job" + if old_company_id: try: - old_client = Client.objects.get(id=old_client_id).name + old_company = Company.objects.get(id=old_company_id).name except Exception: - old_client = "Unknown Client" - if new_client_id: - new_client = self_job.client.name if self_job.client else "Unknown Client" + old_company = "Unknown Company" + if new_company_id: + new_company = ( + self_job.company.name if self_job.company else "Unknown Company" + ) return ( "client_changed", - {"field_name": "Client", "old_value": old_client, "new_value": new_client}, + { + "field_name": "Company", + "old_value": old_company, + "new_value": new_company, + }, ) @staticmethod @@ -951,7 +957,7 @@ def _handle_contact_change(self_job, old_contact_id, new_contact_id): new_contact = "None" if old_contact_id: try: - from apps.client.models import ClientContact + from apps.company.models import ClientContact old_contact = ClientContact.objects.get(id=old_contact_id).name except Exception: @@ -1098,7 +1104,7 @@ def _handle_rdti_type_change(_self_job, old_type, new_type): "job_updated", {"field_name": "Job name", "old_value": str(old), "new_value": str(new)}, ), - "client_id": _handle_client_change.__func__, + "company_id": _handle_company_change.__func__, "contact_id": _handle_contact_change.__func__, "order_number": lambda _self, old, new: ( "job_updated", diff --git a/apps/job/models/job_event.py b/apps/job/models/job_event.py index f83a74e6c..a9b6c88a3 100644 --- a/apps/job/models/job_event.py +++ b/apps/job/models/job_event.py @@ -272,13 +272,13 @@ def _build_status_changed_description(detail: dict) -> str: @staticmethod def _build_job_created_description(detail: dict) -> str: job_name = detail.get("job_name", "Unknown") - client_name = detail.get("client_name", "Unknown") + company_name = detail.get("company_name", "Unknown") contact_name = detail.get("contact_name") initial_status = detail.get("initial_status", "Unknown") pricing = detail.get("pricing_methodology", "Unknown") contact_info = f" (Contact: {contact_name})" if contact_name else "" return ( - f"New job '{job_name}' created for client {client_name}{contact_info}. " + f"New job '{job_name}' created for company {company_name}{contact_info}. " f"Initial status: {initial_status}. " f"Pricing methodology: {pricing}." ) diff --git a/apps/job/serializers/costing_serializer.py b/apps/job/serializers/costing_serializer.py index 31cfc654f..bd9100049 100644 --- a/apps/job/serializers/costing_serializer.py +++ b/apps/job/serializers/costing_serializer.py @@ -40,7 +40,7 @@ class TimesheetCostLineSerializer(serializers.ModelSerializer): Architecture principle: Job data comes from CostSet->Job relationship, NOT from metadata. This ensures data consistency and follows SRP: - Metadata = timesheet-specific data (staff, date, billable, etc.) - - Relationship = job data (job_id, job_number, job_name, client) + - Relationship = job data (job_id, job_number, job_name, company) Benefits: - No data duplication @@ -64,8 +64,8 @@ class TimesheetCostLineSerializer(serializers.ModelSerializer): source="labour_subtype.name", read_only=True ) - # Client name with null handling - client_name = serializers.SerializerMethodField() + # Company name with null handling + company_name = serializers.SerializerMethodField() # Staff wage rate for frontend cost calculations wage_rate = serializers.SerializerMethodField() @@ -97,10 +97,10 @@ def get_total_rev(self, obj) -> float: """Get total revenue (quantity * unit_rev)""" return float(obj.quantity * obj.unit_rev) if obj.unit_rev else 0.0 - def get_client_name(self, obj) -> str: - """Get client name with safe null handling""" - if obj.cost_set and obj.cost_set.job and obj.cost_set.job.client: - return obj.cost_set.job.client.name + def get_company_name(self, obj) -> str: + """Get company name with safe null handling""" + if obj.cost_set and obj.cost_set.job and obj.cost_set.job.company: + return obj.cost_set.job.company.name return "" def get_wage_rate(self, obj) -> float: @@ -124,7 +124,7 @@ class Meta: "job_id", "job_number", "job_name", - "client_name", + "company_name", "charge_out_rate", "wage_rate", "xero_pay_item_name", diff --git a/apps/job/serializers/data_quality_report_serializers.py b/apps/job/serializers/data_quality_report_serializers.py index 2f023c932..c4729be92 100644 --- a/apps/job/serializers/data_quality_report_serializers.py +++ b/apps/job/serializers/data_quality_report_serializers.py @@ -2,7 +2,7 @@ from rest_framework import serializers -from apps.client.services.duplicate_phone_report import ( +from apps.company.services.duplicate_phone_report import ( DuplicatePhoneIssue, DuplicatePhoneOwner, DuplicatePhonesReport, @@ -30,7 +30,7 @@ class ArchivedJobIssueSerializer(serializers.Serializer): job_id = serializers.CharField(help_text="Job's unique identifier") job_number = serializers.CharField(help_text="Job number") - client_name = serializers.CharField(help_text="Client name or 'Shop Job'") + company_name = serializers.CharField(help_text="Company name or 'Shop Job'") archived_date = serializers.DateField(help_text="Date when job was archived") current_status = serializers.CharField(help_text="Job's current status") issue = serializers.CharField( @@ -71,10 +71,10 @@ class DuplicatePhoneOwnerSerializer(serializers.Serializer[DuplicatePhoneOwner]) """One owner of a mis-owned phone number.""" method_id = serializers.CharField(help_text="Contact-method id") - owner_kind = serializers.CharField(help_text="'client' or 'contact'") + owner_kind = serializers.CharField(help_text="'company' or 'contact'") owner_name = serializers.CharField(help_text="Human-readable owner") effective_client_id = serializers.CharField( - allow_null=True, help_text="Client the number resolves to" + allow_null=True, help_text="Company the number resolves to" ) @@ -82,10 +82,10 @@ class DuplicatePhoneSummarySerializer(serializers.Serializer[DuplicatePhoneSumma """Summary of duplicate-phone issues.""" cross_client = serializers.IntegerField( - help_text="Numbers owned by more than one client" + help_text="Numbers owned by more than one company" ) internal_line = serializers.IntegerField( - help_text="Client numbers that are actually internal company lines" + help_text="Company numbers that are actually internal company lines" ) diff --git a/apps/job/serializers/job_profitability_report_serializers.py b/apps/job/serializers/job_profitability_report_serializers.py index 1c16dca4c..6dc953377 100644 --- a/apps/job/serializers/job_profitability_report_serializers.py +++ b/apps/job/serializers/job_profitability_report_serializers.py @@ -59,7 +59,7 @@ class JobProfitabilityItemSerializer(serializers.Serializer): job_id = serializers.CharField(help_text="Job UUID") job_number = serializers.IntegerField(help_text="Job number") job_name = serializers.CharField(help_text="Job description", allow_blank=True) - client_name = serializers.CharField(help_text="Client name") + company_name = serializers.CharField(help_text="Company name") pricing_type = serializers.CharField(help_text="Pricing methodology key") pricing_type_display = serializers.CharField(help_text="Pricing methodology label") completion_date = serializers.CharField( diff --git a/apps/job/serializers/job_serializer.py b/apps/job/serializers/job_serializer.py index 069078a6e..0ef14b2a5 100644 --- a/apps/job/serializers/job_serializer.py +++ b/apps/job/serializers/job_serializer.py @@ -8,7 +8,7 @@ from apps.accounting.models.invoice import Invoice from apps.accounting.models.quote import Quote -from apps.client.models import Client, ClientContact +from apps.company.models import ClientContact, Company from apps.job.models import Job, JobEvent, JobFile from apps.workflow.models import XeroPayItem @@ -113,16 +113,16 @@ class JobSerializer(serializers.ModelSerializer): xero_quote = serializers.SerializerMethodField() xero_invoices = serializers.SerializerMethodField() - client_id = serializers.PrimaryKeyRelatedField( - queryset=Client.objects.all(), - source="client", + company_id = serializers.PrimaryKeyRelatedField( + queryset=Company.objects.all(), + source="company", write_only=False, # Allow read access, allow_null=True, required=False, ) - # Some clients might not exist in old production data, so for compatibility we allow null values for it (unrequired, allow_null=True) - client_name = serializers.CharField( - source="client.name", read_only=True, allow_null=True, required=False + # Some companies might not exist in old production data, so for compatibility we allow null values for it (unrequired, allow_null=True) + company_name = serializers.CharField( + source="company.name", read_only=True, allow_null=True, required=False ) contact_id = serializers.PrimaryKeyRelatedField( queryset=ClientContact.objects.all(), @@ -206,8 +206,8 @@ class Meta: fields = [ "id", "name", - "client_id", - "client_name", + "company_id", + "company_name", "contact_id", "contact_name", "job_number", @@ -250,30 +250,30 @@ def validate(self, attrs): if DEBUG_SERIALIZER: logger.debug(f"JobSerializer validate called with attrs: {attrs}") - # Validate contact belongs to client + # Validate contact belongs to company contact = attrs.get("contact") - client = attrs.get("client") + company = attrs.get("company") - # If we're updating and no client is provided, use the existing client - if not client and self.instance: - client = self.instance.client + # If we're updating and no company is provided, use the existing company + if not company and self.instance: + company = self.instance.company - if contact and client: + if contact and company: logger.debug( - f"JobSerializer validate - Checking if contact {contact.id} belongs to client {client.id}" + f"JobSerializer validate - Checking if contact {contact.id} belongs to company {company.id}" ) - if contact.client != client: + if contact.company != company: logger.error( - f"JobSerializer validate - Contact {contact.id} does not belong to client {client.id}" + f"JobSerializer validate - Contact {contact.id} does not belong to company {company.id}" ) raise serializers.ValidationError( { - "contact_id": f"Contact does not belong to the selected client. Contact belongs to {contact.client.name}, but job is for {client.name}." + "contact_id": f"Contact does not belong to the selected company. Contact belongs to {contact.company.name}, but job is for {company.name}." } ) if DEBUG_SERIALIZER: logger.debug( - f"JobSerializer validate - Contact {contact.id} belongs to client {client.id}" + f"JobSerializer validate - Contact {contact.id} belongs to company {company.id}" ) logger.debug("JobSerializer validate - Contact validation passed") @@ -437,7 +437,7 @@ def get_undo_description(self, obj) -> str | None: "status": "status", "order_number": "order number", "notes": "notes", - "client_id": "client", + "company_id": "company", "contact_id": "contact", "delivery_date": "delivery date", "job_status": "status", @@ -501,19 +501,26 @@ class JobSummaryResponseSerializer(serializers.Serializer): class CompleteJobSerializer(serializers.ModelSerializer): - client_name = serializers.CharField(source="client.name", read_only=True) + company_name = serializers.CharField(source="company.name", read_only=True) job_status = serializers.CharField(source="status") class Meta: model = Job - fields = ["id", "job_number", "name", "client_name", "updated_at", "job_status"] + fields = [ + "id", + "job_number", + "name", + "company_name", + "updated_at", + "job_status", + ] class JobCreateSerializer(serializers.Serializer): """Serializer for job creation request data.""" name = serializers.CharField(max_length=255) - client_id = serializers.UUIDField() + company_id = serializers.UUIDField() description = serializers.CharField(required=False, allow_blank=True) order_number = serializers.CharField(required=False, allow_blank=True) notes = serializers.CharField(required=False, allow_blank=True) @@ -631,7 +638,7 @@ class MonthEndJobSerializer(serializers.Serializer): job_id = serializers.UUIDField() job_number = serializers.IntegerField() job_name = serializers.CharField() - client_name = serializers.CharField() + company_name = serializers.CharField() history = MonthEndJobHistorySerializer(many=True) total_hours = serializers.FloatField() total_dollars = serializers.FloatField() @@ -786,7 +793,7 @@ class WeeklyMetricsSerializer(serializers.Serializer): job_id = serializers.UUIDField() job_number = serializers.IntegerField() name = serializers.CharField() - client = serializers.CharField(required=False, allow_null=True, allow_blank=True) + company = serializers.CharField(required=False, allow_null=True, allow_blank=True) description = serializers.CharField( required=False, allow_null=True, allow_blank=True ) @@ -806,11 +813,11 @@ class JobHeaderResponseSerializer(serializers.ModelSerializer): """Serializer for job header response - essential job data for fast loading.""" job_id = serializers.UUIDField(source="id") - client_id = serializers.UUIDField( - source="client.id", read_only=True, allow_null=True + company_id = serializers.UUIDField( + source="company.id", read_only=True, allow_null=True ) - client_name = serializers.CharField( - source="client.name", read_only=True, allow_null=True + company_name = serializers.CharField( + source="company.name", read_only=True, allow_null=True ) contact_id = serializers.UUIDField( source="contact.id", read_only=True, allow_null=True @@ -831,8 +838,8 @@ class Meta: # Derive fields from Job.JOB_DIRECT_FIELDS, plus special fields fields = [ "job_id", - "client_id", - "client_name", + "company_id", + "company_name", "contact_id", "contact_name", "quoted", @@ -1022,7 +1029,7 @@ def get_undo_description(self, obj) -> str | None: "status": "status", "order_number": "order number", "notes": "notes", - "client_id": "client", + "company_id": "company", "contact_id": "contact", "delivery_date": "delivery date", "job_status": "status", @@ -1095,7 +1102,7 @@ class JobPatchSerializer(serializers.Serializer): ) rejected_flag = serializers.BooleanField( required=False, - help_text="Whether the job was rejected (quote declined by client)", + help_text="Whether the job was rejected (quote declined by company)", ) # Dates @@ -1133,8 +1140,8 @@ class JobPatchSerializer(serializers.Serializer): ) # Relationships (only IDs, no derived names) - client_id = serializers.UUIDField( - required=False, allow_null=True, help_text="Client ID" + company_id = serializers.UUIDField( + required=False, allow_null=True, help_text="Company ID" ) contact_id = serializers.UUIDField( required=False, allow_null=True, help_text="Contact person ID" @@ -1145,13 +1152,13 @@ class JobPatchSerializer(serializers.Serializer): child=serializers.DictField(), required=False, help_text="Job files" ) - def validate_client_id(self, value): - """Validate that client exists if provided""" + def validate_company_id(self, value): + """Validate that company exists if provided""" if value: try: - Client.objects.get(id=value) - except Client.DoesNotExist: - raise serializers.ValidationError("Client not found") + Company.objects.get(id=value) + except Company.DoesNotExist: + raise serializers.ValidationError("Company not found") return value def validate_contact_id(self, value): @@ -1165,19 +1172,21 @@ def validate_contact_id(self, value): def validate(self, attrs): """Cross-field validation""" - # Validate contact belongs to client if both are provided + # Validate contact belongs to company if both are provided contact_id = attrs.get("contact_id") - client_id = attrs.get("client_id") + company_id = attrs.get("company_id") - if contact_id and client_id: + if contact_id and company_id: try: contact = ClientContact.objects.get(id=contact_id) - client = Client.objects.get(id=client_id) - if contact.client != client: + company = Company.objects.get(id=company_id) + if contact.company != company: raise serializers.ValidationError( - {"contact_id": "Contact does not belong to the selected client"} + { + "contact_id": "Contact does not belong to the selected company" + } ) - except (ClientContact.DoesNotExist, Client.DoesNotExist): + except (ClientContact.DoesNotExist, Company.DoesNotExist): pass # Individual field validation will catch these return attrs diff --git a/apps/job/serializers/kanban_serializer.py b/apps/job/serializers/kanban_serializer.py index b1e2285ec..41fa8776a 100644 --- a/apps/job/serializers/kanban_serializer.py +++ b/apps/job/serializers/kanban_serializer.py @@ -69,7 +69,7 @@ class JobSearchFiltersSerializer(serializers.Serializer): job_number = serializers.IntegerField(required=False, allow_null=True) name = serializers.CharField(required=False, allow_blank=True) description = serializers.CharField(required=False, allow_blank=True) - client_name = serializers.CharField(required=False, allow_blank=True) + company_name = serializers.CharField(required=False, allow_blank=True) contact_person = serializers.CharField(required=False, allow_blank=True) created_by = serializers.CharField(required=False, allow_blank=True) created_after = serializers.DateField(required=False, allow_null=True) @@ -100,8 +100,8 @@ class KanbanJobSerializer(serializers.Serializer): description = serializers.CharField(allow_blank=True, allow_null=True) job_number = serializers.IntegerField() - # Client and contact info - client_name = serializers.CharField(allow_blank=True) + # Company and contact info + company_name = serializers.CharField(allow_blank=True) contact_person = serializers.CharField(allow_blank=True) # People assigned to the job @@ -198,8 +198,8 @@ class KanbanColumnJobSerializer(serializers.Serializer): name = serializers.CharField() description = serializers.CharField(allow_blank=True, allow_null=True) - # Client and contact info - client_name = serializers.CharField(allow_blank=True) + # Company and contact info + company_name = serializers.CharField(allow_blank=True) contact_person = serializers.CharField(allow_blank=True) # People assigned to the job (empty list for now) @@ -264,6 +264,6 @@ class WorkshopJobSerializer(serializers.Serializer): name = serializers.CharField() description = serializers.CharField(allow_blank=True, allow_null=True) job_number = serializers.IntegerField() - client_name = serializers.CharField() + company_name = serializers.CharField() contact_person = serializers.CharField(allow_blank=True, allow_null=True) people = KanbanJobPersonSerializer(many=True) diff --git a/apps/job/services/chat_service.py b/apps/job/services/chat_service.py index 62c71aacb..9702ff3c8 100644 --- a/apps/job/services/chat_service.py +++ b/apps/job/services/chat_service.py @@ -54,7 +54,7 @@ def _get_system_prompt(self, job: Job) -> str: Current Job Context: - Job: {job.name} (#{job.job_number}) - Job ID: {job.id} -- Client: {job.client.name} +- Company: {job.company.name} - Status: {job.get_status_display()} - Description: {job.description or 'No description available'} diff --git a/apps/job/services/data_integrity_service.py b/apps/job/services/data_integrity_service.py index ef5bbdfef..4fc247647 100644 --- a/apps/job/services/data_integrity_service.py +++ b/apps/job/services/data_integrity_service.py @@ -17,7 +17,7 @@ Quote, ) from apps.accounts.models import Staff -from apps.client.models import Client, ClientContact +from apps.company.models import ClientContact, Company from apps.job.models import ( CostLine, CostSet, @@ -92,8 +92,8 @@ def _check_all_fk_references() -> list[dict[str, Any]]: # Workflow App issues.extend(DataIntegrityService._check_apperror_fks()) - # Client App - issues.extend(DataIntegrityService._check_client_fks()) + # Company App + issues.extend(DataIntegrityService._check_company_fks()) issues.extend(DataIntegrityService._check_clientcontact_fks()) # Purchasing App @@ -115,20 +115,20 @@ def _check_all_fk_references() -> list[dict[str, Any]]: def _check_job_fks() -> list[dict[str, Any]]: """Check Job FK references.""" issues = [] - valid_client_ids = set(Client.objects.values_list("id", flat=True)) + valid_company_ids = set(Company.objects.values_list("id", flat=True)) valid_contact_ids = set(ClientContact.objects.values_list("id", flat=True)) valid_staff_ids = set(Staff.objects.values_list("id", flat=True)) valid_costset_ids = set(CostSet.objects.values_list("id", flat=True)) for job in Job.objects.all(): - if job.client_id and job.client_id not in valid_client_ids: + if job.company_id and job.company_id not in valid_company_ids: issues.append( { "model": "Job", "record_id": str(job.id), - "field": "client", - "target_model": "Client", - "target_id": str(job.client_id), + "field": "company", + "target_model": "Company", + "target_id": str(job.company_id), } ) if job.contact_id and job.contact_id not in valid_contact_ids: @@ -335,7 +335,7 @@ def _check_invoice_fks() -> list[dict[str, Any]]: """Check Invoice FK references.""" issues = [] valid_job_ids = set(Job.objects.values_list("id", flat=True)) - valid_client_ids = set(Client.objects.values_list("id", flat=True)) + valid_company_ids = set(Company.objects.values_list("id", flat=True)) for invoice in Invoice.objects.all(): if invoice.job_id and invoice.job_id not in valid_job_ids: @@ -348,14 +348,14 @@ def _check_invoice_fks() -> list[dict[str, Any]]: "target_id": str(invoice.job_id), } ) - if invoice.client_id not in valid_client_ids: + if invoice.company_id not in valid_company_ids: issues.append( { "model": "Invoice", "record_id": str(invoice.id), - "field": "client", - "target_model": "Client", - "target_id": str(invoice.client_id), + "field": "company", + "target_model": "Company", + "target_id": str(invoice.company_id), } ) @@ -365,17 +365,17 @@ def _check_invoice_fks() -> list[dict[str, Any]]: def _check_bill_fks() -> list[dict[str, Any]]: """Check Bill FK references.""" issues = [] - valid_client_ids = set(Client.objects.values_list("id", flat=True)) + valid_company_ids = set(Company.objects.values_list("id", flat=True)) for bill in Bill.objects.all(): - if bill.client_id not in valid_client_ids: + if bill.company_id not in valid_company_ids: issues.append( { "model": "Bill", "record_id": str(bill.id), - "field": "client", - "target_model": "Client", - "target_id": str(bill.client_id), + "field": "company", + "target_model": "Company", + "target_id": str(bill.company_id), } ) @@ -385,17 +385,17 @@ def _check_bill_fks() -> list[dict[str, Any]]: def _check_creditnote_fks() -> list[dict[str, Any]]: """Check CreditNote FK references.""" issues = [] - valid_client_ids = set(Client.objects.values_list("id", flat=True)) + valid_company_ids = set(Company.objects.values_list("id", flat=True)) for creditnote in CreditNote.objects.all(): - if creditnote.client_id not in valid_client_ids: + if creditnote.company_id not in valid_company_ids: issues.append( { "model": "CreditNote", "record_id": str(creditnote.id), - "field": "client", - "target_model": "Client", - "target_id": str(creditnote.client_id), + "field": "company", + "target_model": "Company", + "target_id": str(creditnote.company_id), } ) @@ -499,7 +499,7 @@ def _check_quote_fks() -> list[dict[str, Any]]: """Check Quote FK references.""" issues = [] valid_job_ids = set(Job.objects.values_list("id", flat=True)) - valid_client_ids = set(Client.objects.values_list("id", flat=True)) + valid_company_ids = set(Company.objects.values_list("id", flat=True)) for quote in Quote.objects.all(): if quote.job_id and quote.job_id not in valid_job_ids: @@ -512,14 +512,14 @@ def _check_quote_fks() -> list[dict[str, Any]]: "target_id": str(quote.job_id), } ) - if quote.client_id not in valid_client_ids: + if quote.company_id not in valid_company_ids: issues.append( { "model": "Quote", "record_id": str(quote.id), - "field": "client", - "target_model": "Client", - "target_id": str(quote.client_id), + "field": "company", + "target_model": "Company", + "target_id": str(quote.company_id), } ) @@ -567,22 +567,25 @@ def _check_apperror_fks() -> list[dict[str, Any]]: return issues - # Client App FK Checks + # Company App FK Checks @staticmethod - def _check_client_fks() -> list[dict[str, Any]]: - """Check Client FK references.""" + def _check_company_fks() -> list[dict[str, Any]]: + """Check Company FK references.""" issues = [] - valid_client_ids = set(Client.objects.values_list("id", flat=True)) + valid_company_ids = set(Company.objects.values_list("id", flat=True)) - for client in Client.objects.all(): - if client.merged_into_id and client.merged_into_id not in valid_client_ids: + for company in Company.objects.all(): + if ( + company.merged_into_id + and company.merged_into_id not in valid_company_ids + ): issues.append( { - "model": "Client", - "record_id": str(client.id), + "model": "Company", + "record_id": str(company.id), "field": "merged_into", - "target_model": "Client", - "target_id": str(client.merged_into_id), + "target_model": "Company", + "target_id": str(company.merged_into_id), } ) @@ -592,17 +595,17 @@ def _check_client_fks() -> list[dict[str, Any]]: def _check_clientcontact_fks() -> list[dict[str, Any]]: """Check ClientContact FK references.""" issues = [] - valid_client_ids = set(Client.objects.values_list("id", flat=True)) + valid_company_ids = set(Company.objects.values_list("id", flat=True)) for contact in ClientContact.objects.all(): - if contact.client_id not in valid_client_ids: + if contact.company_id not in valid_company_ids: issues.append( { "model": "ClientContact", "record_id": str(contact.id), - "field": "client", - "target_model": "Client", - "target_id": str(contact.client_id), + "field": "company", + "target_model": "Company", + "target_id": str(contact.company_id), } ) @@ -613,17 +616,17 @@ def _check_clientcontact_fks() -> list[dict[str, Any]]: def _check_purchaseorder_fks() -> list[dict[str, Any]]: """Check PurchaseOrder FK references.""" issues = [] - valid_client_ids = set(Client.objects.values_list("id", flat=True)) + valid_company_ids = set(Company.objects.values_list("id", flat=True)) valid_job_ids = set(Job.objects.values_list("id", flat=True)) for po in PurchaseOrder.objects.all(): - if po.supplier_id and po.supplier_id not in valid_client_ids: + if po.supplier_id and po.supplier_id not in valid_company_ids: issues.append( { "model": "PurchaseOrder", "record_id": str(po.id), "field": "supplier", - "target_model": "Client", + "target_model": "Company", "target_id": str(po.supplier_id), } ) @@ -757,19 +760,19 @@ def _check_stock_fks() -> list[dict[str, Any]]: def _check_supplierproduct_fks() -> list[dict[str, Any]]: """Check SupplierProduct FK references.""" issues = [] - valid_client_ids = set(Client.objects.values_list("id", flat=True)) + valid_company_ids = set(Company.objects.values_list("id", flat=True)) valid_pricelist_ids = set( SupplierPriceList.objects.values_list("id", flat=True) ) for product in SupplierProduct.objects.all(): - if product.supplier_id not in valid_client_ids: + if product.supplier_id not in valid_company_ids: issues.append( { "model": "SupplierProduct", "record_id": str(product.id), "field": "supplier", - "target_model": "Client", + "target_model": "Company", "target_id": str(product.supplier_id), } ) @@ -790,16 +793,16 @@ def _check_supplierproduct_fks() -> list[dict[str, Any]]: def _check_supplierpricelist_fks() -> list[dict[str, Any]]: """Check SupplierPriceList FK references.""" issues = [] - valid_client_ids = set(Client.objects.values_list("id", flat=True)) + valid_company_ids = set(Company.objects.values_list("id", flat=True)) for pricelist in SupplierPriceList.objects.all(): - if pricelist.supplier_id not in valid_client_ids: + if pricelist.supplier_id not in valid_company_ids: issues.append( { "model": "SupplierPriceList", "record_id": str(pricelist.id), "field": "supplier", - "target_model": "Client", + "target_model": "Company", "target_id": str(pricelist.supplier_id), } ) @@ -810,16 +813,16 @@ def _check_supplierpricelist_fks() -> list[dict[str, Any]]: def _check_scrapejob_fks() -> list[dict[str, Any]]: """Check ScrapeJob FK references.""" issues = [] - valid_client_ids = set(Client.objects.values_list("id", flat=True)) + valid_company_ids = set(Company.objects.values_list("id", flat=True)) for scrapejob in ScrapeJob.objects.all(): - if scrapejob.supplier_id not in valid_client_ids: + if scrapejob.supplier_id not in valid_company_ids: issues.append( { "model": "ScrapeJob", "record_id": str(scrapejob.id), "field": "supplier", - "target_model": "Client", + "target_model": "Company", "target_id": str(scrapejob.supplier_id), } ) @@ -941,8 +944,8 @@ def _check_business_rules() -> list[dict[str, Any]]: # Stock business rules issues.extend(DataIntegrityService._check_stock_business_rules()) - # Client business rules - issues.extend(DataIntegrityService._check_client_business_rules()) + # Company business rules + issues.extend(DataIntegrityService._check_company_business_rules()) # JobFile business rules issues.extend(DataIntegrityService._check_jobfile_business_rules()) @@ -1215,24 +1218,24 @@ def _check_stock_business_rules() -> list[dict[str, Any]]: return issues @staticmethod - def _check_client_business_rules() -> list[dict[str, Any]]: - """Check Client business rules.""" + def _check_company_business_rules() -> list[dict[str, Any]]: + """Check Company business rules.""" issues = [] # Check circular merges - checked_clients = set() - for client in Client.objects.exclude(merged_into__isnull=True): - if client.id in checked_clients: + checked_companies = set() + for company in Company.objects.exclude(merged_into__isnull=True): + if company.id in checked_companies: continue - path = [client.id] - current = client + path = [company.id] + current = company while current.merged_into_id: if current.merged_into_id in path: issues.append( { - "model": "Client", - "record_id": str(client.id), + "model": "Company", + "record_id": str(company.id), "field": "merged_into", "rule": "circular merge detected", "path": [str(p) for p in path], @@ -1240,10 +1243,10 @@ def _check_client_business_rules() -> list[dict[str, Any]]: ) break path.append(current.merged_into_id) - checked_clients.add(current.id) + checked_companies.add(current.id) try: - current = Client.objects.get(id=current.merged_into_id) - except Client.DoesNotExist: + current = Company.objects.get(id=current.merged_into_id) + except Company.DoesNotExist: break return issues diff --git a/apps/job/services/data_quality_report.py b/apps/job/services/data_quality_report.py index d9e98944d..cb292dcbf 100644 --- a/apps/job/services/data_quality_report.py +++ b/apps/job/services/data_quality_report.py @@ -33,7 +33,7 @@ def get_compliance_report(self) -> Dict[str, Any]: not_cancelled_count = 0 has_open_tasks_count = 0 - for job in archived_jobs.select_related("client"): + for job in archived_jobs.select_related("company"): issues = [] # Job should either be cancelled/rejected OR (fully invoiced AND paid) @@ -63,8 +63,8 @@ def get_compliance_report(self) -> Dict[str, Any]: { "job_id": str(job.id), "job_number": job.job_number, - "client_name": ( - job.client.name if job.client else "Shop Job" + "company_name": ( + job.company.name if job.company else "Shop Job" ), "archived_date": ( timezone.localtime(job.updated_at).date() diff --git a/apps/job/services/job_profitability_report.py b/apps/job/services/job_profitability_report.py index fdc2f851b..57e3d8587 100644 --- a/apps/job/services/job_profitability_report.py +++ b/apps/job/services/job_profitability_report.py @@ -69,14 +69,14 @@ def _get_queryset(self): completed_at__date__range=(self.start_date, self.end_date), ) - shop_client_id = CompanyDefaults.get_solo().shop_client_id - qs = qs.exclude(client_id=shop_client_id) + shop_company_id = CompanyDefaults.get_solo().shop_company_id + qs = qs.exclude(company_id=shop_company_id) if self.pricing_type: qs = qs.filter(pricing_methodology=self.pricing_type) return qs.select_related( - "client", "latest_estimate", "latest_quote", "latest_actual" + "company", "latest_estimate", "latest_quote", "latest_actual" ) def _build_job_rows(self, jobs_qs) -> List[Dict[str, Any]]: @@ -162,7 +162,7 @@ def _compute_job_row(self, job: Job, revenue: Decimal) -> Dict[str, Any]: "job_id": str(job.id), "job_number": job.job_number, "job_name": job.description or "", - "client_name": job.client.name if job.client else "Unknown", + "company_name": job.company.name if job.company else "Unknown", "pricing_type": job.pricing_methodology, "pricing_type_display": job.get_pricing_methodology_display(), "completion_date": ( diff --git a/apps/job/services/job_rest_service.py b/apps/job/services/job_rest_service.py index 9049b0fac..bbd98bd28 100644 --- a/apps/job/services/job_rest_service.py +++ b/apps/job/services/job_rest_service.py @@ -21,7 +21,7 @@ from django.utils import timezone from apps.accounts.models import Staff -from apps.client.models import Client, ClientContact +from apps.company.models import ClientContact, Company from apps.job.models import Job, JobDeltaRejection, JobEvent, LabourSubtype from apps.job.models.costing import CostLine from apps.job.serializers import JobSerializer @@ -74,7 +74,7 @@ def _current_job_etag_value(job: Job) -> str: @dataclass class JobDeltaPayload: """ - Structured representation of the delta envelope submitted by the client. + Structured representation of the delta envelope submitted by the company. We keep this lightweight dataclass (instead of passing DRF serializer instances around) so the service layer can remain decoupled from REST @@ -252,23 +252,23 @@ def create_job(data: Dict[str, Any], user: Staff) -> Job: if not data.get("name"): raise ValueError("Job name is required") - if not data.get("client_id"): - raise ValueError("Client is required") + if not data.get("company_id"): + raise ValueError("Company is required") try: - client = Client.objects.get(id=data["client_id"]) - except Client.DoesNotExist: - raise ValueError("Client not found") + company = Company.objects.get(id=data["company_id"]) + except Company.DoesNotExist: + raise ValueError("Company not found") - if not client.allow_jobs: + if not company.allow_jobs: raise ValueError( - f"Client '{client.name}' is not permitted for jobs. " - f"Update the client to allow jobs if this is wrong." + f"Company '{company.name}' is not permitted for jobs. " + f"Update the company to allow jobs if this is wrong." ) job_data = { "name": data["name"], - "client": client, + "company": company, "created_by": user, } @@ -310,14 +310,14 @@ def create_job(data: Dict[str, Any], user: Staff) -> Job: job.save(staff=user) # Create job creation event (moved from Job.save() to prevent duplicates) - client_name = job.client.name if job.client else "Shop Job" + company_name = job.company.name if job.company else "Shop Job" contact_name = job.contact.name if job.contact else None JobEvent.objects.create( job=job, event_type="job_created", detail={ "job_name": job.name, - "client_name": client_name, + "company_name": company_name, "contact_name": contact_name, "initial_status": job.get_status_display(), "pricing_methodology": job.get_pricing_methodology_display(), @@ -669,7 +669,7 @@ def get_job_for_edit(job_id: UUID, request) -> Dict[str, Any]: ValueError: If job is not found. """ try: - job = Job.objects.select_related("client").get(id=job_id) + job = Job.objects.select_related("company").get(id=job_id) except Job.DoesNotExist: raise ValueError(f"Job with id {job_id} not found") @@ -715,7 +715,7 @@ def get_job_summary(job_id: UUID, request) -> Dict[str, Any]: ValueError: If job is not found. """ try: - job = Job.objects.select_related("client").get(id=job_id) + job = Job.objects.select_related("company").get(id=job_id) except Job.DoesNotExist: raise ValueError(f"Job with id {job_id} not found") @@ -948,7 +948,7 @@ def _mark_job_delta_rejection_group_by_fingerprint( """Match rows by sha256(reason) and cascade. Callers send the fingerprint returned by the grouped listing; this - sidesteps client-side whitespace mangling on the raw reason. See + sidesteps company-side whitespace mangling on the raw reason. See apps.workflow.services.error_grouping._mark_group_by_fingerprint for the same pattern on AppError/XeroError. """ @@ -1065,7 +1065,7 @@ def update_job( else: current_norm = _current_job_etag_value(job) logger.debug(f"[JOB_UPDATE] Current ETag: {current_norm}") - logger.debug(f"[JOB_UPDATE] Client ETag: {if_match}") + logger.debug(f"[JOB_UPDATE] Company ETag: {if_match}") if current_norm != if_match: logger.error( f"[JOB_UPDATE] ETag mismatch! Current: {current_norm}, Expected: {if_match}" @@ -1082,9 +1082,9 @@ def update_job( f"JobRestService.update_job - Current job contact_id: {job.contact.id if job.contact else None}" ) - # Snapshot values needed for post-save logic (priority bump, client change) + # Snapshot values needed for post-save logic (priority bump, company change) original_status = job.status - original_client_id = job.client_id + original_company_id = job.company_id if delta_payload.job_id and str(job.id) != delta_payload.job_id: detail = { @@ -1146,35 +1146,35 @@ def update_job( job.priority = Job._calculate_next_priority_for_status(job.status) job.save(staff=user, update_fields=["priority", "updated_at"]) - # When client changes, auto-set contact to new client's primary contact. + # When company changes, auto-set contact to new company's primary contact. # Only do this if contact_id was NOT explicitly provided in the update. contact_explicitly_updated = "contact_id" in delta_payload.fields try: if ( - job.client - and original_client_id != job.client_id + job.company + and original_company_id != job.company_id and not contact_explicitly_updated ): primary_contact = ClientContact.objects.filter( - client_id=job.client_id, + company_id=job.company_id, is_primary=True, is_active=True, ).first() job.contact = primary_contact # May be None if no primary job.save(staff=user) logger.info( - f"Auto-set contact to primary contact after client change: " - f"job.client_id={job.client_id}, " + f"Auto-set contact to primary contact after company change: " + f"job.company_id={job.company_id}, " f"contact={'None' if not primary_contact else primary_contact.id}" ) elif ( job.contact - and job.client - and job.contact.client_id != job.client_id + and job.company + and job.contact.company_id != job.company_id ): logger.warning( - "Clearing mismatched contact after client change: " - f"contact.client_id={job.contact.client_id} != job.client_id={job.client_id}" + "Clearing mismatched contact after company change: " + f"contact.company_id={job.contact.company_id} != job.company_id={job.company_id}" ) job.contact = None job.save(staff=user) @@ -1577,7 +1577,7 @@ def get_job_timeline(job_id: UUID) -> list[Dict[str, Any]]: "status": "status", "order_number": "order number", "notes": "notes", - "client_id": "client", + "company_id": "company", "contact_id": "contact", "delivery_date": "delivery date", "job_status": "status", @@ -1763,7 +1763,7 @@ def get_weekly_metrics(week: date = None) -> list[Dict[str, Any]]: jobs = ( Job.objects.filter(id__in=job_ids_with_time_entries) - .select_related("client") + .select_related("company") .prefetch_related("people") ) @@ -1808,7 +1808,7 @@ def get_weekly_metrics(week: date = None) -> list[Dict[str, Any]]: "job_id": str(job.id), "name": job.name, "job_number": job.job_number, - "client": job.client.name if job.client else None, + "company": job.company.name if job.company else None, "description": job.description, "status": job.status, "price_cap": job.price_cap, diff --git a/apps/job/services/job_service.py b/apps/job/services/job_service.py index 728925194..041101016 100644 --- a/apps/job/services/job_service.py +++ b/apps/job/services/job_service.py @@ -20,7 +20,7 @@ def get_paid_complete_jobs(): """Fetches the jobs that are both completed and paid.""" return ( Job.objects.filter(status__in=["completed", "recently_completed"], paid=True) - .select_related("client") + .select_related("company") .order_by("-updated_at") ) diff --git a/apps/job/services/kanban_service.py b/apps/job/services/kanban_service.py index 1cb2e281d..62e2f56c5 100644 --- a/apps/job/services/kanban_service.py +++ b/apps/job/services/kanban_service.py @@ -34,7 +34,7 @@ from apps.accounting.models import Invoice from apps.accounts.models import Staff -from apps.client.models import Client, ClientContact +from apps.company.models import ClientContact, Company from apps.job.models import CostSet, Job from apps.job.services.kanban_categorization_service import KanbanCategorizationService from apps.workflow.models import CompanyDefaults @@ -55,9 +55,9 @@ class KanbanSerializationContext: full stack inspection (~20ms), which made fetch-all take 20-30s. """ - shop_client_id: Optional[UUID] + shop_company_id: Optional[UUID] summaries: Dict[UUID, Dict[str, Any]] - client_names: Dict[UUID, str] + company_names: Dict[UUID, str] contact_names: Dict[UUID, str] people_by_job: Dict[UUID, List[Staff]] @@ -71,7 +71,7 @@ class KanbanService: SEARCH_SCORE_JOB_NUMBER_CONTAINS = 45.0 SEARCH_SCORE_QUOTE_CONTAINS = 75.0 SEARCH_SCORE_NAME_CONTAINS = 65.0 - SEARCH_SCORE_CLIENT_CONTAINS = 55.0 + SEARCH_SCORE_COMPANY_CONTAINS = 55.0 SEARCH_SCORE_CONTACT_CONTAINS = 55.0 SEARCH_SCORE_DESCRIPTION_CONTAINS = 35.0 SEARCH_SCORE_TRIGRAM_MULTIPLIER = 30.0 @@ -87,9 +87,11 @@ class KanbanService: "reason": "name_contains", }, { - "db_path": "client__name", - "expression": Coalesce("client__name", Value(""), output_field=TextField()), - "score": SEARCH_SCORE_CLIENT_CONTAINS, + "db_path": "company__name", + "expression": Coalesce( + "company__name", Value(""), output_field=TextField() + ), + "score": SEARCH_SCORE_COMPANY_CONTAINS, "reason": "client_contains", }, { @@ -244,7 +246,7 @@ def _apply_kanban_search(jobs_query: QuerySet[Job], query: str) -> List[Job]: candidates = list( jobs_query.filter(token_filter) - .select_related("client", "contact", "quote") + .select_related("company", "contact", "quote") .prefetch_related("invoices") .order_by("-trigram_score", "-created_at") ) @@ -365,8 +367,10 @@ def _get_search_field_value(job: Job, db_path: str) -> str: match db_path: case "name": return (job.name or "").lower() - case "client__name": - return (job.client.name if job.client_id and job.client else "").lower() + case "company__name": + return ( + job.company.name if job.company_id and job.company else "" + ).lower() case "contact__name": return ( job.contact.name if job.contact_id and job.contact else "" @@ -391,7 +395,7 @@ def explain_kanban_search(query: str, limit: int = 20) -> List[Dict[str, Any]]: { "job_number": job.job_number, "name": job.name, - "client": job.client.name if job.client_id else None, + "company": job.company.name if job.company_id else None, "status": job.status, "trigram_score": getattr(job, "trigram_score", None), "search_score": getattr(job, "search_score", None), @@ -440,7 +444,7 @@ def log_kanban_search_results( "job_number": job.job_number, "status": job.status, "name": job.name, - "client": job.client.name if job.client_id else None, + "company": job.company.name if job.company_id else None, "trigram_score": getattr(job, "trigram_score", None), "search_score": getattr(job, "search_score", None), "search_reasons": getattr(job, "search_reasons", None), @@ -520,9 +524,9 @@ def build_serialization_context(jobs: List[Job]) -> KanbanSerializationContext: CostSet.objects.filter(id__in=costset_ids).values_list("id", "summary") ) - client_ids = {job.client_id for job in jobs} - {None} - client_names = dict( - Client.objects.filter(id__in=client_ids).values_list("id", "name") + company_ids = {job.company_id for job in jobs} - {None} + company_names = dict( + Company.objects.filter(id__in=company_ids).values_list("id", "name") ) contact_ids = {job.contact_id for job in jobs} - {None} @@ -549,9 +553,9 @@ def build_serialization_context(jobs: List[Job]) -> KanbanSerializationContext: people.sort(key=lambda staff: (staff.last_name, staff.first_name)) return KanbanSerializationContext( - shop_client_id=defaults.shop_client_id, + shop_company_id=defaults.shop_company_id, summaries=summaries, - client_names=client_names, + company_names=company_names, contact_names=contact_names, people_by_job=dict(people_by_job), ) @@ -623,8 +627,8 @@ def serialize_job_for_api( "name": job.name, "description": job.description or "", "job_number": job.job_number, - "client_name": ( - context.client_names.get(job.client_id, "") if job.client_id else "" + "company_name": ( + context.company_names.get(job.company_id, "") if job.company_id else "" ), "contact_person": ( context.contact_names.get(job.contact_id, "") if job.contact_id else "" @@ -650,7 +654,7 @@ def serialize_job_for_api( job.delivery_date.isoformat() if job.delivery_date else None ), "priority": job.priority, - "shop_job": job.client_id == context.shop_client_id, + "shop_job": job.company_id == context.shop_company_id, "over_budget": over_budget, "quote_revenue": quote_revenue, "time_and_materials_revenue": time_and_materials_revenue, @@ -951,8 +955,8 @@ def perform_advanced_search( if description := filters.get("description", "").strip(): jobs_query = jobs_query.filter(description__icontains=description) - if client_name := filters.get("client_name", "").strip(): - jobs_query = jobs_query.filter(client__name__icontains=client_name) + if company_name := filters.get("company_name", "").strip(): + jobs_query = jobs_query.filter(company__name__icontains=company_name) if contact_person := filters.get("contact_person", "").strip(): jobs_query = jobs_query.filter(contact__name__icontains=contact_person) diff --git a/apps/job/services/labour_subtype_service.py b/apps/job/services/labour_subtype_service.py index eab11fd6a..30991f777 100644 --- a/apps/job/services/labour_subtype_service.py +++ b/apps/job/services/labour_subtype_service.py @@ -29,7 +29,7 @@ def seed_subtype_onto_existing_jobs(subtype: LabourSubtype) -> int: with connection.cursor() as cursor: cursor.execute(f"LOCK TABLE {table_name} IN SHARE ROW EXCLUSIVE MODE") - shop_client_id = CompanyDefaults.get_solo().shop_client_id + shop_company_id = CompanyDefaults.get_solo().shop_company_id existing_job_ids = set( JobLabourRate.objects.filter(labour_subtype=subtype).values_list( "job_id", flat=True @@ -41,13 +41,13 @@ def seed_subtype_onto_existing_jobs(subtype: LabourSubtype) -> int: labour_subtype=subtype, charge_out_rate=( Decimal("0.00") - if client_id == shop_client_id + if company_id == shop_company_id else subtype.default_charge_out_rate ), ) - for job_id, client_id in Job.objects.exclude( + for job_id, company_id in Job.objects.exclude( id__in=existing_job_ids - ).values_list("id", "client_id") + ).values_list("id", "company_id") ] JobLabourRate.objects.bulk_create(rows, batch_size=1000, ignore_conflicts=True) missing_job_ids = list( diff --git a/apps/job/services/mcp_chat_service.py b/apps/job/services/mcp_chat_service.py index 895853f0c..c96d08831 100644 --- a/apps/job/services/mcp_chat_service.py +++ b/apps/job/services/mcp_chat_service.py @@ -46,7 +46,7 @@ def _get_system_prompt(self, job: Job) -> str: Current Job Context: - Job: {job.name} -- Client: {job.client.name} +- Company: {job.company.name} - Status: {job.get_status_display()} - Description: {job.description or 'No description available'} diff --git a/apps/job/services/quote_mode_controller.py b/apps/job/services/quote_mode_controller.py index 3dac323f2..0a76654ff 100644 --- a/apps/job/services/quote_mode_controller.py +++ b/apps/job/services/quote_mode_controller.py @@ -63,7 +63,7 @@ def _summarize_previous_context( Args: chat_history: List of previous chat messages - gemini_client: Gemini client for API calls + gemini_client: Gemini company for API calls Returns: Summary of relevant context from previous conversation @@ -109,7 +109,7 @@ def render_prompt( job_info = "" if job_ctx: - job_info = f"\nJOB_CONTEXT: Job #{job_ctx.get('job_number', 'N/A')} for {job_ctx.get('client', 'N/A')}" + job_info = f"\nJOB_CONTEXT: Job #{job_ctx.get('job_number', 'N/A')} for {job_ctx.get('company', 'N/A')}" context_info = "" if context_summary: @@ -477,7 +477,7 @@ def run( if job: job_ctx = { "job_number": job.job_number, - "client": job.client.name if job.client else "N/A", + "company": job.company.name if job.company else "N/A", "description": job.description or "", } diff --git a/apps/job/services/workshop_pdf_service.py b/apps/job/services/workshop_pdf_service.py index e9518f2a2..c8f4bf005 100644 --- a/apps/job/services/workshop_pdf_service.py +++ b/apps/job/services/workshop_pdf_service.py @@ -21,7 +21,7 @@ from reportlab.pdfgen import canvas from reportlab.platypus import Flowable, Paragraph, Table, TableStyle -from apps.client.models import ClientContactMethod +from apps.company.models import ClientContactMethod from apps.crm.models import PhoneEndpoint from apps.job.enums import SpeedQualityTradeoff from apps.job.models import CostLine, CostSet, Job, JobFile @@ -35,25 +35,25 @@ WORKSHOP_PDF_COST_LINES_ATTR = "_workshop_pdf_cost_lines" WORKSHOP_PDF_FILES_ATTR = "_workshop_pdf_files_to_print" WORKSHOP_PDF_CONTACT_PHONE_ATTR = "_workshop_pdf_contact_phone" -WORKSHOP_PDF_CLIENT_PHONE_ATTR = "_workshop_pdf_client_phone" +WORKSHOP_PDF_COMPANY_PHONE_ATTR = "_workshop_pdf_company_phone" def _primary_phone_for_job(job: Job) -> str: """Phone to print on workshop docs and delivery dockets. Display preference order: the job contact's own number first, falling back - to the client's number when the contact has no phone method (the business + to the company's number when the contact has no phone method (the business rule carried over from the pre-ClientContactMethod scalar fields). """ if not hasattr(job, WORKSHOP_PDF_CONTACT_PHONE_ATTR) or not hasattr( - job, WORKSHOP_PDF_CLIENT_PHONE_ATTR + job, WORKSHOP_PDF_COMPANY_PHONE_ATTR ): raise ValueError("PDF phone fields must be loaded before rendering") contact_phone = getattr(job, WORKSHOP_PDF_CONTACT_PHONE_ATTR) if contact_phone: return str(contact_phone) - return str(getattr(job, WORKSHOP_PDF_CLIENT_PHONE_ATTR) or "") + return str(getattr(job, WORKSHOP_PDF_COMPANY_PHONE_ATTR) or "") def _primary_company_endpoint_number() -> str: @@ -77,17 +77,17 @@ def _primary_phone_annotations() -> dict[str, Subquery]: .order_by("-is_primary", "label", "value") .values("value")[:1] ) - client_phone = ( + company_phone = ( ClientContactMethod.objects.filter( method_type=ClientContactMethod.MethodType.PHONE, - client_id=OuterRef("client_id"), + company_id=OuterRef("company_id"), ) .order_by("-is_primary", "label", "value") .values("value")[:1] ) return { WORKSHOP_PDF_CONTACT_PHONE_ATTR: Subquery(contact_phone), - WORKSHOP_PDF_CLIENT_PHONE_ATTR: Subquery(client_phone), + WORKSHOP_PDF_COMPANY_PHONE_ATTR: Subquery(company_phone), } @@ -95,7 +95,7 @@ def get_job_for_delivery_docket_pdf(job_id: UUID) -> Job: """Load a job with the relations required to render a delivery docket.""" return cast( Job, - Job.objects.select_related("client", "contact") + Job.objects.select_related("company", "contact") .annotate(**_primary_phone_annotations()) .get(id=job_id), ) @@ -114,7 +114,7 @@ def get_job_for_workshop_pdf(job_id: UUID) -> Job: return cast( Job, Job.objects.select_related( - "client", + "company", "contact", "latest_estimate", "latest_quote", @@ -162,7 +162,7 @@ def _ensure_workshop_pdf_job_loaded(job: Job) -> Job: def _ensure_delivery_docket_pdf_job_loaded(job: Job) -> Job: if hasattr(job, WORKSHOP_PDF_CONTACT_PHONE_ATTR) and hasattr( - job, WORKSHOP_PDF_CLIENT_PHONE_ATTR + job, WORKSHOP_PDF_COMPANY_PHONE_ATTR ): return job return get_job_for_delivery_docket_pdf(job.id) @@ -431,8 +431,8 @@ def get_time_breakdown(job: Job) -> dict: ROW_ALT = colors.HexColor("#F8FAFC") # Paragraph styles for table content -header_client_style = ParagraphStyle( - "HeaderClient", +header_company_style = ParagraphStyle( + "HeaderCompany", parent=styles["Normal"], fontName="Helvetica-Bold", fontSize=18, @@ -1173,7 +1173,7 @@ def add_workshop_details_table( pdf: canvas.Canvas, y_position: float, job: Job ) -> float: """Render a full-page workshop brief: identity, constraints, labour budget, and specs.""" - client_name = job.client.name if job.client else "N/A" + company_name = job.company.name if job.company else "N/A" contact_name = job.contact.name if job.contact else "" contact_phone = _primary_phone_for_job(job) contact_info = ( @@ -1185,7 +1185,7 @@ def add_workshop_details_table( contact_table = Table( [ [ - _plain_paragraph(client_name, header_client_style), + _plain_paragraph(company_name, header_company_style), Paragraph(contact_info, header_contact_style), ] ], @@ -1322,7 +1322,7 @@ def add_delivery_docket_details_table( job: Job, ): """Render the delivery docket details with page-aware wrapping.""" - client_name = job.client.name if job.client else "N/A" + company_name = job.company.name if job.company else "N/A" contact_name = job.contact.name if job.contact else "" contact_phone = _primary_phone_for_job(job) @@ -1333,7 +1333,7 @@ def add_delivery_docket_details_table( # Delivery docket details - no workshop time or internal notes job_details = [ [ - Paragraph(client_name, header_client_style), + Paragraph(company_name, header_company_style), Paragraph(contact_info, header_contact_style), ], [ diff --git a/apps/job/services/workshop_service.py b/apps/job/services/workshop_service.py index 3f95f300f..7225641a7 100644 --- a/apps/job/services/workshop_service.py +++ b/apps/job/services/workshop_service.py @@ -45,7 +45,7 @@ def list_entries(self, entry_date) -> Tuple[list[CostLine], dict]: staff=self.staff, accounting_date=entry_date, ) - .select_related("cost_set__job__client") + .select_related("cost_set__job__company") .order_by("entry_seq") ) @@ -116,7 +116,7 @@ def create_entry(self, data) -> CostLine: def update_entry(self, data) -> CostLine: """Update an existing CostLine belonging to the staff member.""" - cost_line = CostLine.objects.select_related("cost_set__job__client").get( + cost_line = CostLine.objects.select_related("cost_set__job__company").get( id=data["entry_id"], kind="time" ) diff --git a/apps/job/tests/_pdf_golden_fixtures.py b/apps/job/tests/_pdf_golden_fixtures.py index ec5db42db..326b67a9a 100644 --- a/apps/job/tests/_pdf_golden_fixtures.py +++ b/apps/job/tests/_pdf_golden_fixtures.py @@ -24,7 +24,7 @@ from django.conf import settings from apps.accounts.models import Staff -from apps.client.models import Client, ClientContact +from apps.company.models import ClientContact, Company from apps.job.models import CostLine, Job, JobEvent, JobFile, LabourSubtype from apps.workflow.models import CompanyDefaults, XeroPayItem @@ -73,18 +73,18 @@ def build_golden_job(test_staff: Staff) -> Job: company.starting_job_number = STARTING_JOB_NUMBER company.save() - client = Client.objects.create( + company = Company.objects.create( name="ACME Engineering Ltd", xero_last_modified=FROZEN_NOW, ) contact = ClientContact.objects.create( - client=client, + company=company, name="Jane Doe", ) job = Job.objects.create( - client=client, + company=company, contact=contact, name="Custom Bracket Fabrication", description=("Stainless steel mounting bracket, 200x100x5mm, 4 holes per spec"), diff --git a/apps/job/tests/fixtures/chat_test_data.json b/apps/job/tests/fixtures/chat_test_data.json index b43cd71af..20076716c 100644 --- a/apps/job/tests/fixtures/chat_test_data.json +++ b/apps/job/tests/fixtures/chat_test_data.json @@ -72,7 +72,7 @@ } }, { - "model": "client.client", + "model": "company.company", "pk": "550e8400-e29b-41d4-a716-446655440001", "fields": { "name": "Test Manufacturing Ltd", @@ -90,7 +90,7 @@ } }, { - "model": "client.client", + "model": "company.company", "pk": "550e8400-e29b-41d4-a716-446655440002", "fields": { "name": "ABC Construction Co", @@ -114,7 +114,7 @@ "name": "Steel Handrail Project", "job_number": "JOB001", "description": "Custom steel handrail for commercial building", - "client": "550e8400-e29b-41d4-a716-446655440001", + "company": "550e8400-e29b-41d4-a716-446655440001", "status": "quoting", "priority": "medium", "due_date": "2024-02-15", @@ -131,7 +131,7 @@ "name": "Industrial Staircase", "job_number": "JOB002", "description": "Heavy-duty industrial staircase with platform", - "client": "550e8400-e29b-41d4-a716-446655440002", + "company": "550e8400-e29b-41d4-a716-446655440002", "status": "in_progress", "priority": "high", "due_date": "2024-03-01", @@ -148,7 +148,7 @@ "name": "Sheet Metal Ducting", "job_number": "JOB003", "description": "HVAC ducting for office building", - "client": "550e8400-e29b-41d4-a716-446655440001", + "company": "550e8400-e29b-41d4-a716-446655440001", "status": "quoting", "priority": "low", "due_date": "2024-02-28", diff --git a/apps/job/tests/test_chat_api_endpoints.py b/apps/job/tests/test_chat_api_endpoints.py index 9c300754d..6d2ea4be4 100644 --- a/apps/job/tests/test_chat_api_endpoints.py +++ b/apps/job/tests/test_chat_api_endpoints.py @@ -14,7 +14,7 @@ from rest_framework.test import APIClient from apps.accounts.models import Staff -from apps.client.models import Client +from apps.company.models import Company from apps.job.models import Job, JobQuoteChat from apps.testing import BaseTestCase from apps.workflow.enums import AIProviderTypes @@ -31,9 +31,9 @@ def setUp(self): # Get test data from fixture self.company_defaults = CompanyDefaults.get_solo() - self.client_obj = Client.objects.create( - name="Test Client", - email="client@example.com", + self.client_obj = Company.objects.create( + name="Test Company", + email="company@example.com", xero_last_modified="2024-01-01T00:00:00Z", ) @@ -44,7 +44,7 @@ def setUp(self): name="Test Job", job_number=1001, description="Test job description", - client=self.client_obj, + company=self.client_obj, status="quoting", default_xero_pay_item=self.xero_pay_item, staff=self.test_staff, @@ -352,9 +352,9 @@ def setUp(self): # Get test data from fixture self.company_defaults = CompanyDefaults.get_solo() - self.client_obj = Client.objects.create( - name="Test Client", - email="client@example.com", + self.client_obj = Company.objects.create( + name="Test Company", + email="company@example.com", xero_last_modified="2024-01-01T00:00:00Z", ) @@ -365,7 +365,7 @@ def setUp(self): name="Test Job", job_number=1001, description="Test job description", - client=self.client_obj, + company=self.client_obj, status="quoting", default_xero_pay_item=self.xero_pay_item, staff=self.test_staff, @@ -436,9 +436,9 @@ def setUp(self): self.company_defaults = CompanyDefaults.get_solo() - self.client_obj = Client.objects.create( - name="Test Client", - email="client@example.com", + self.client_obj = Company.objects.create( + name="Test Company", + email="company@example.com", xero_last_modified="2024-01-01T00:00:00Z", ) @@ -449,7 +449,7 @@ def setUp(self): name="Test Job", job_number=1001, description="Test job description", - client=self.client_obj, + company=self.client_obj, status="quoting", default_xero_pay_item=self.xero_pay_item, staff=self.test_staff, diff --git a/apps/job/tests/test_chat_performance.py b/apps/job/tests/test_chat_performance.py index c12c85bc1..c33313137 100644 --- a/apps/job/tests/test_chat_performance.py +++ b/apps/job/tests/test_chat_performance.py @@ -7,7 +7,7 @@ from django.db import connection from django.test.utils import CaptureQueriesContext -from apps.client.models import Client +from apps.company.models import Company from apps.job.models import Job, JobQuoteChat from apps.job.services.chat_service import ChatService from apps.testing import BaseTestCase @@ -48,9 +48,9 @@ def setUp(self): """Set up test data""" self.company_defaults = CompanyDefaults.get_solo() - self.client = Client.objects.create( - name="Test Client", - email="client@example.com", + self.company = Company.objects.create( + name="Test Company", + email="company@example.com", xero_last_modified="2024-01-01T00:00:00Z", ) @@ -61,7 +61,7 @@ def setUp(self): name="Test Job", job_number=1001, description="Test job description", - client=self.client, + company=self.company, status="quoting", default_xero_pay_item=self.xero_pay_item, staff=self.test_staff, @@ -95,7 +95,7 @@ def test_database_query_optimization(self): mock_llm.completion.return_value = mock_response mock_get_llm.return_value = mock_llm - # Upper bound: Job, CompanyDefaults, Client, History, Insert, Savepoint x2. + # Upper bound: Job, CompanyDefaults, Company, History, Insert, Savepoint x2. # CompanyDefaults is usually cached by SOLO_CACHE after setUp() primes # it, so the observed count is typically 6 — but 7 is also acceptable # if the cache isn't warm. @@ -118,7 +118,7 @@ def test_bulk_message_creation(self): name=f"DB Test Job {i}", job_number=3000 + i, description=f"Database test job {i}", - client=self.client, + company=self.company, status="quoting", default_xero_pay_item=self.xero_pay_item, staff=self.test_staff, diff --git a/apps/job/tests/test_chat_service.py b/apps/job/tests/test_chat_service.py index 0c3a4a7ab..dfddf2288 100644 --- a/apps/job/tests/test_chat_service.py +++ b/apps/job/tests/test_chat_service.py @@ -18,7 +18,7 @@ from django.test import TestCase -from apps.client.models import Client +from apps.company.models import Company from apps.job.models import Job, JobQuoteChat from apps.job.services.chat_service import ChatService from apps.testing import BaseTestCase @@ -122,9 +122,9 @@ def setUp(self): """Set up test data""" self.company_defaults = CompanyDefaults.get_solo() - self.client = Client.objects.create( - name="Test Client", - email="client@example.com", + self.company = Company.objects.create( + name="Test Company", + email="company@example.com", xero_last_modified="2024-01-01T00:00:00Z", ) @@ -135,7 +135,7 @@ def setUp(self): name="Test Job", job_number=1001, description="Test job description", - client=self.client, + company=self.company, status="quoting", default_xero_pay_item=self.xero_pay_item, staff=self.test_staff, @@ -197,7 +197,7 @@ def test_system_prompt_generation(self): self.assertIn(self.company_defaults.company_name, prompt) self.assertIn(self.job.name, prompt) self.assertIn(str(self.job.job_number), prompt) - self.assertIn(self.client.name, prompt) + self.assertIn(self.company.name, prompt) self.assertIn(self.job.description, prompt) def test_mcp_tools_definition(self): @@ -266,9 +266,9 @@ def setUp(self): """Set up test data""" self.company_defaults = CompanyDefaults.get_solo() - self.client = Client.objects.create( - name="Test Client", - email="client@example.com", + self.company = Company.objects.create( + name="Test Company", + email="company@example.com", xero_last_modified="2024-01-01T00:00:00Z", ) @@ -279,7 +279,7 @@ def setUp(self): name="Test Job", job_number=1001, description="Test job description", - client=self.client, + company=self.company, status="quoting", default_xero_pay_item=self.xero_pay_item, staff=self.test_staff, @@ -416,9 +416,9 @@ def setUp(self): """Set up test data""" self.company_defaults = CompanyDefaults.get_solo() - self.client = Client.objects.create( - name="Test Client", - email="client@example.com", + self.company = Company.objects.create( + name="Test Company", + email="company@example.com", xero_last_modified="2024-01-01T00:00:00Z", ) @@ -428,7 +428,7 @@ def setUp(self): name="Test Job", job_number=1001, description="Test job description", - client=self.client, + company=self.company, status="quoting", default_xero_pay_item=self.xero_pay_item, staff=self.test_staff, @@ -615,9 +615,9 @@ def setUp(self): """Set up test data""" self.company_defaults = CompanyDefaults.get_solo() - self.client = Client.objects.create( - name="Test Client", - email="client@example.com", + self.company = Company.objects.create( + name="Test Company", + email="company@example.com", xero_last_modified="2024-01-01T00:00:00Z", ) @@ -628,7 +628,7 @@ def setUp(self): name="Test Job", job_number=1001, description="Test job description", - client=self.client, + company=self.company, status="quoting", default_xero_pay_item=self.xero_pay_item, staff=self.test_staff, @@ -703,9 +703,9 @@ def setUp(self): """Set up test data""" self.company_defaults = CompanyDefaults.get_solo() - self.client = Client.objects.create( - name="Test Client", - email="client@example.com", + self.company = Company.objects.create( + name="Test Company", + email="company@example.com", xero_last_modified="2024-01-01T00:00:00Z", ) @@ -715,7 +715,7 @@ def setUp(self): name="Test Job", job_number=1001, description="Test job description", - client=self.client, + company=self.company, status="quoting", default_xero_pay_item=self.xero_pay_item, staff=self.test_staff, diff --git a/apps/job/tests/test_costline_schema_validation.py b/apps/job/tests/test_costline_schema_validation.py index 8990bc351..27ce5f286 100644 --- a/apps/job/tests/test_costline_schema_validation.py +++ b/apps/job/tests/test_costline_schema_validation.py @@ -6,7 +6,7 @@ from django.core.exceptions import ValidationError -from apps.client.models import Client +from apps.company.models import Company from apps.job.models import CostLine, Job, LabourSubtype from apps.testing import BaseTestCase from apps.workflow.models import XeroPayItem @@ -15,8 +15,8 @@ class CostLineSchemaValidationTests(BaseTestCase): def setUp(self) -> None: - self.client = Client.objects.create( - name="Test Client", + self.company = Company.objects.create( + name="Test Company", email="test@example.com", xero_last_modified="2024-01-01T00:00:00Z", ) @@ -24,7 +24,7 @@ def setUp(self) -> None: self.job = Job.objects.create( job_number=1, name="CostLine Schema Test", - client=self.client, + company=self.company, default_xero_pay_item=self.xero_pay_item, staff=self.test_staff, ) diff --git a/apps/job/tests/test_costline_time_rate_serialization.py b/apps/job/tests/test_costline_time_rate_serialization.py index bfd7c3e86..3273f24cf 100644 --- a/apps/job/tests/test_costline_time_rate_serialization.py +++ b/apps/job/tests/test_costline_time_rate_serialization.py @@ -4,7 +4,7 @@ from decimal import Decimal from apps.accounts.models import Staff -from apps.client.models import Client +from apps.company.models import Company from apps.job.models import Job, LabourSubtype from apps.job.serializers.costing_serializer import CostLineCreateUpdateSerializer from apps.testing import BaseTestCase @@ -13,15 +13,15 @@ class CostLineTimeRateSerializationTests(BaseTestCase): def setUp(self) -> None: - self.client_obj = Client.objects.create( - name="Rate Serialization Client", + self.client_obj = Company.objects.create( + name="Rate Serialization Company", email="rates@example.com", xero_last_modified="2024-01-01T00:00:00Z", ) self.job = Job.objects.create( job_number=9100, name="Rate Serialization Job", - client=self.client_obj, + company=self.client_obj, staff=self.test_staff, ) self.job.labour_rates.update(charge_out_rate=Decimal("125.00")) @@ -127,7 +127,7 @@ def test_leave_job_keeps_leave_pay_item(self) -> None: sick_job = Job.objects.create( job_number=9101, name="Sick Leave", - client=self.client_obj, + company=self.client_obj, staff=self.test_staff, default_xero_pay_item=sick_pay_item, ) diff --git a/apps/job/tests/test_delivery_docket_service.py b/apps/job/tests/test_delivery_docket_service.py index 12ccec5a4..f56bd62d7 100644 --- a/apps/job/tests/test_delivery_docket_service.py +++ b/apps/job/tests/test_delivery_docket_service.py @@ -13,7 +13,7 @@ from django.test import override_settings from django.utils import timezone -from apps.client.models import Client +from apps.company.models import Company from apps.job.models import Job, JobEvent, JobFile from apps.job.services.delivery_docket_service import generate_delivery_docket from apps.testing import BaseTestCase @@ -32,12 +32,12 @@ def setUp(self): ) self._settings_override.enable() - self.client_obj = Client.objects.create( - name="Test Client", + self.client_obj = Company.objects.create( + name="Test Company", xero_last_modified=timezone.now(), ) self.job = Job.objects.create( - client=self.client_obj, + company=self.client_obj, name="Test Delivery Job", description="Deliver some steel", staff=self.test_staff, diff --git a/apps/job/tests/test_duplicate_phones_view.py b/apps/job/tests/test_duplicate_phones_view.py index 9712fd4ad..347bf987d 100644 --- a/apps/job/tests/test_duplicate_phones_view.py +++ b/apps/job/tests/test_duplicate_phones_view.py @@ -1,7 +1,7 @@ from django.utils import timezone from apps.accounts.models import Staff -from apps.client.models import Client, ClientContactMethod +from apps.company.models import ClientContactMethod, Company from apps.testing import BaseAPITestCase URL = "/api/job/data-quality/duplicate-phones/" @@ -17,18 +17,18 @@ def _office_staff(self) -> Staff: is_office_staff=True, ) - def _phone(self, value: str, client: Client) -> None: + def _phone(self, value: str, company: Company) -> None: method = ClientContactMethod( - client=client, + company=company, method_type=ClientContactMethod.MethodType.PHONE, value=value, ) method.normalized_value = ClientContactMethod.normalize_phone(value) ClientContactMethod.objects.bulk_create([method]) - def test_returns_cross_client_conflict_to_office_staff(self) -> None: - acme = Client.objects.create(name="Acme", xero_last_modified=timezone.now()) - beta = Client.objects.create(name="Beta", xero_last_modified=timezone.now()) + def test_returns_cross_company_conflict_to_office_staff(self) -> None: + acme = Company.objects.create(name="Acme", xero_last_modified=timezone.now()) + beta = Company.objects.create(name="Beta", xero_last_modified=timezone.now()) self._phone("021 111 111", acme) self._phone("021 111 111", beta) self.client.force_authenticate(self._office_staff()) diff --git a/apps/job/tests/test_event_deduplication.py b/apps/job/tests/test_event_deduplication.py index 7601d9ed0..77347ac56 100644 --- a/apps/job/tests/test_event_deduplication.py +++ b/apps/job/tests/test_event_deduplication.py @@ -3,7 +3,7 @@ from django.core.exceptions import ValidationError from apps.accounts.models import Staff -from apps.client.models import Client +from apps.company.models import Company from apps.job.models import Job, JobEvent from apps.job.services.job_rest_service import JobRestService from apps.testing import BaseTestCase @@ -25,16 +25,16 @@ def setUp(self): first_name="Test", last_name="User", ) - self.client_obj = Client.objects.create( - name="Test Client", - email="client@example.com", + self.client_obj = Company.objects.create( + name="Test Company", + email="company@example.com", xero_last_modified="2024-01-01T00:00:00Z", ) # Get Ordinary Time pay item (created by migration) self.xero_pay_item = XeroPayItem.get_ordinary_time() self.job = Job.objects.create( name="Test Job", - client=self.client_obj, + company=self.client_obj, created_by=self.user, default_xero_pay_item=self.xero_pay_item, staff=self.test_staff, diff --git a/apps/job/tests/test_job_event_tracking.py b/apps/job/tests/test_job_event_tracking.py index 24b03ee2f..c632d7320 100644 --- a/apps/job/tests/test_job_event_tracking.py +++ b/apps/job/tests/test_job_event_tracking.py @@ -8,7 +8,7 @@ from rest_framework.test import APIRequestFactory, force_authenticate from apps.accounts.models import Staff -from apps.client.models import Client +from apps.company.models import Company from apps.job.models import Job, JobEvent from apps.job.models.job_event import ( _format_ordinal, @@ -32,15 +32,15 @@ def setUp(self): first_name="Test", last_name="Tracker", ) - self.client_obj = Client.objects.create( - name="Test Client", - email="client@example.com", + self.client_obj = Company.objects.create( + name="Test Company", + email="company@example.com", xero_last_modified="2024-01-01T00:00:00Z", ) self.xero_pay_item = XeroPayItem.get_ordinary_time() self.job = Job( name="Test Job", - client=self.client_obj, + company=self.client_obj, created_by=self.user, default_xero_pay_item=self.xero_pay_item, ) @@ -537,8 +537,8 @@ def setUp(self): first_name="Kanban", last_name="Tester", ) - self.client_obj = Client.objects.create( - name="Kanban Client", + self.client_obj = Company.objects.create( + name="Kanban Company", email="kc@example.com", xero_last_modified="2024-01-01T00:00:00Z", ) @@ -547,7 +547,7 @@ def setUp(self): def _make_job(self, name, status="in_progress"): job = Job( name=name, - client=self.client_obj, + company=self.client_obj, created_by=self.user, default_xero_pay_item=self.xero_pay_item, status=status, @@ -687,15 +687,15 @@ def setUp(self) -> None: first_name="Staff", last_name="Required", ) - self.client_obj = Client.objects.create( - name="Required Client", - email="required-client@example.com", + self.client_obj = Company.objects.create( + name="Required Company", + email="required-company@example.com", xero_last_modified="2024-01-01T00:00:00Z", ) self.xero_pay_item = XeroPayItem.get_ordinary_time() self.job = Job( name="Required Job", - client=self.client_obj, + company=self.client_obj, created_by=self.user, default_xero_pay_item=self.xero_pay_item, ) diff --git a/apps/job/tests/test_job_files_api.py b/apps/job/tests/test_job_files_api.py index 77358984e..6a5af452b 100644 --- a/apps/job/tests/test_job_files_api.py +++ b/apps/job/tests/test_job_files_api.py @@ -7,7 +7,7 @@ from rest_framework.test import APIClient from apps.accounts.models import Staff -from apps.client.models import Client +from apps.company.models import Company from apps.job.models import Job from apps.testing import BaseAPITestCase @@ -34,12 +34,12 @@ def setUp(self): is_office_staff=True, is_workshop_staff=False, ) - self.client_obj = Client.objects.create( - name="Job Files Client", + self.client_obj = Company.objects.create( + name="Job Files Company", xero_last_modified=timezone.now(), ) self.job = Job.objects.create( - client=self.client_obj, + company=self.client_obj, name="Attachment API Job", staff=self.test_staff, ) diff --git a/apps/job/tests/test_job_invoicing.py b/apps/job/tests/test_job_invoicing.py index ae69918b4..55ddae0b8 100644 --- a/apps/job/tests/test_job_invoicing.py +++ b/apps/job/tests/test_job_invoicing.py @@ -7,7 +7,7 @@ from django.utils import timezone from apps.accounting.models.invoice import Invoice -from apps.client.models import Client +from apps.company.models import Company from apps.job.models import Job from apps.job.models.costing import CostLine from apps.job.services.job_service import recalculate_job_invoicing_state @@ -18,15 +18,15 @@ class TestRecalculateJobInvoicingState(BaseTestCase): """Tests for recalculate_job_invoicing_state().""" def setUp(self): - self.client_obj = Client.objects.create( - name="Test Client", + self.client_obj = Company.objects.create( + name="Test Company", xero_last_modified=timezone.now(), ) def _create_job(self, pricing_methodology="time_materials"): """Create a job. Job.save() auto-creates CostSets (actual, quote, estimate).""" job = Job( - client=self.client_obj, + company=self.client_obj, name="Test Job", pricing_methodology=pricing_methodology, ) @@ -49,7 +49,7 @@ def _create_invoice(self, job, amount, status="AUTHORISED"): """Create an invoice for the given job.""" return Invoice.objects.create( job=job, - client=self.client_obj, + company=self.client_obj, xero_id=uuid.uuid4(), number=f"INV-{uuid.uuid4().hex[:8]}", status=status, diff --git a/apps/job/tests/test_job_latest_costsets.py b/apps/job/tests/test_job_latest_costsets.py index 6ce24756a..35fbb2c89 100644 --- a/apps/job/tests/test_job_latest_costsets.py +++ b/apps/job/tests/test_job_latest_costsets.py @@ -2,7 +2,7 @@ from django.db.models import RestrictedError -from apps.client.models import Client +from apps.company.models import Company from apps.job.models import Job from apps.job.models.costing import CostSet from apps.testing import BaseTestCase @@ -10,8 +10,8 @@ class JobLatestCostSetCreationTests(BaseTestCase): def setUp(self): - self.client_obj = Client.objects.create( - name="Latest CostSet Client", + self.client_obj = Company.objects.create( + name="Latest CostSet Company", xero_last_modified="2024-01-01T00:00:00Z", ) @@ -36,7 +36,7 @@ def _assert_initial_cost_sets(self, job: Job) -> None: def test_manager_create_seeds_required_latest_cost_sets(self): job = Job.objects.create( - client=self.client_obj, + company=self.client_obj, name="Manager create job", staff=self.test_staff, ) @@ -44,7 +44,7 @@ def test_manager_create_seeds_required_latest_cost_sets(self): self._assert_initial_cost_sets(job) def test_model_save_seeds_required_latest_cost_sets(self): - job = Job(client=self.client_obj, name="Model save job") + job = Job(company=self.client_obj, name="Model save job") job.save(staff=self.test_staff) self._assert_initial_cost_sets(job) @@ -52,12 +52,12 @@ def test_model_save_seeds_required_latest_cost_sets(self): class JobLatestCostSetDeletionTests(BaseTestCase): def setUp(self): - self.client_obj = Client.objects.create( - name="Latest CostSet Deletion Client", + self.client_obj = Company.objects.create( + name="Latest CostSet Deletion Company", xero_last_modified="2024-01-01T00:00:00Z", ) self.job = Job.objects.create( - client=self.client_obj, + company=self.client_obj, name="Deletion behavior job", staff=self.test_staff, ) diff --git a/apps/job/tests/test_job_quote_chat_model.py b/apps/job/tests/test_job_quote_chat_model.py index 238835e50..b93cc4720 100644 --- a/apps/job/tests/test_job_quote_chat_model.py +++ b/apps/job/tests/test_job_quote_chat_model.py @@ -5,7 +5,7 @@ from django.db import IntegrityError from django.utils import timezone -from apps.client.models import Client +from apps.company.models import Company from apps.job.models import Job, JobQuoteChat from apps.testing import BaseTestCase from apps.workflow.models import CompanyDefaults, XeroPayItem @@ -18,9 +18,9 @@ def setUp(self): """Set up test data""" self.company_defaults = CompanyDefaults.get_solo() - self.client = Client.objects.create( - name="Test Client", - email="client@example.com", + self.company = Company.objects.create( + name="Test Company", + email="company@example.com", xero_last_modified=timezone.now(), ) @@ -29,7 +29,7 @@ def setUp(self): self.job = Job.objects.create( name="Test Job", description="Test job description", - client=self.client, + company=self.company, default_xero_pay_item=self.xero_pay_item, staff=self.test_staff, ) diff --git a/apps/job/tests/test_job_rest_service.py b/apps/job/tests/test_job_rest_service.py index 4aea25f4b..6525f07f5 100644 --- a/apps/job/tests/test_job_rest_service.py +++ b/apps/job/tests/test_job_rest_service.py @@ -2,7 +2,7 @@ from django.utils import timezone -from apps.client.models import Client, ClientContact +from apps.company.models import ClientContact, Company from apps.job.models import Job, JobEvent from apps.job.models.costing import CostLine from apps.job.services.job_rest_service import JobRestService @@ -12,15 +12,15 @@ class JobRestServiceCreateJobTests(BaseTestCase): def test_fixed_price_create_copies_estimate_pay_item_without_relation_loads(self): - client = Client.objects.create( - name="Create Job Client", + company = Company.objects.create( + name="Create Job Company", xero_last_modified=timezone.now(), ) job = JobRestService.create_job( { "name": "Fixed Price Job", - "client_id": client.id, + "company_id": company.id, "pricing_methodology": "fixed_price", "estimated_materials": Decimal("120.00"), "estimated_time": Decimal("2.50"), @@ -45,14 +45,14 @@ def test_fixed_price_create_copies_estimate_pay_item_without_relation_loads(self class JobRestServiceEditTests(BaseTestCase): def test_get_job_for_edit_serializes_event_staff(self): - client = Client.objects.create( - name="Edit Job Client", + company = Company.objects.create( + name="Edit Job Company", xero_last_modified=timezone.now(), ) - contact = ClientContact.objects.create(client=client, name="Site Contact") + contact = ClientContact.objects.create(company=company, name="Site Contact") job = Job.objects.create( name="Editable Job", - client=client, + company=company, contact=contact, created_by=self.test_staff, default_xero_pay_item=XeroPayItem.get_ordinary_time(), diff --git a/apps/job/tests/test_job_summary_pdf_service.py b/apps/job/tests/test_job_summary_pdf_service.py index a77e50b27..728fd9e12 100644 --- a/apps/job/tests/test_job_summary_pdf_service.py +++ b/apps/job/tests/test_job_summary_pdf_service.py @@ -10,7 +10,7 @@ from django.test import TestCase, override_settings from django.utils import timezone -from apps.client.models import Client +from apps.company.models import Company from apps.job.models import CostLine, Job, JobFile from apps.job.services.job_summary_pdf_service import JobSummaryPdfService from apps.job.services.workshop_pdf_service import JOB_SUMMARY_PDF_FILENAME @@ -31,12 +31,12 @@ def setUp(self) -> None: self.dropbox = tempfile.mkdtemp(prefix="dw-summary-pdf-") self.settings_override = override_settings(DROPBOX_WORKFLOW_FOLDER=self.dropbox) self.settings_override.enable() - self.client_obj = Client.objects.create( - name="Summary Client", + self.client_obj = Company.objects.create( + name="Summary Company", xero_last_modified=timezone.now(), ) self.job = Job.objects.create( - client=self.client_obj, + company=self.client_obj, name="Summary Job", staff=self.test_staff, ) @@ -66,12 +66,12 @@ def test_refresh_writes_stable_pdf_and_job_file(self) -> None: def test_refresh_batches_missing_jobs_and_skips_fresh(self) -> None: missing = Job.objects.create( - client=self.client_obj, + company=self.client_obj, name="Missing Summary", staff=self.test_staff, ) fresh = Job.objects.create( - client=self.client_obj, + company=self.client_obj, name="Fresh Summary", staff=self.test_staff, ) diff --git a/apps/job/tests/test_kanban_reorder_priority.py b/apps/job/tests/test_kanban_reorder_priority.py index 3c1c1085c..9dc1b1a3d 100644 --- a/apps/job/tests/test_kanban_reorder_priority.py +++ b/apps/job/tests/test_kanban_reorder_priority.py @@ -2,7 +2,7 @@ from django.contrib.auth import get_user_model -from apps.client.models import Client +from apps.company.models import Company from apps.job.models import Job, JobEvent from apps.job.services.kanban_service import KanbanService from apps.testing import BaseTestCase @@ -19,9 +19,9 @@ def setUp(self): first_name="Reorder", last_name="Tester", ) - self.client = Client.objects.create( - name="Reorder Client", - email="reorder-client@example.com", + self.company = Company.objects.create( + name="Reorder Company", + email="reorder-company@example.com", xero_last_modified="2024-01-01T00:00:00Z", ) self.xero_pay_item = XeroPayItem.get_ordinary_time() @@ -29,7 +29,7 @@ def setUp(self): def _make_job(self, name, status="in_progress"): job = Job( name=name, - client=self.client, + company=self.company, created_by=self.user, default_xero_pay_item=self.xero_pay_item, status=status, diff --git a/apps/job/tests/test_kanban_search.py b/apps/job/tests/test_kanban_search.py index c37ca756d..fcdedffc2 100644 --- a/apps/job/tests/test_kanban_search.py +++ b/apps/job/tests/test_kanban_search.py @@ -12,7 +12,7 @@ from nplusone.ext.django.patch import apply_patches from apps.accounting.models import Invoice, Quote -from apps.client.models import Client, ClientContact +from apps.company.models import ClientContact, Company from apps.job.models import Job from apps.job.services.kanban_service import KanbanService from apps.testing import BaseTestCase @@ -23,11 +23,11 @@ class KanbanSearchTest(BaseTestCase): def setUp(self): super().setUp() self.xero_pay_item = XeroPayItem.get_ordinary_time() - self.shop_client = self._make_client("Demo Company Shop") + self.shop_company = self._make_client("Demo Company Shop") self.job_number = 9000 - def _make_client(self, name: str) -> Client: - return Client.objects.create( + def _make_client(self, name: str) -> Company: + return Company.objects.create( name=name, xero_last_modified=timezone.now(), ) @@ -36,21 +36,21 @@ def _make_job( self, *, name: str, - client_name: str, + company_name: str, status: str = "in_progress", contact_name: str | None = None, order_number: str | None = None, ): - client = self._make_client(client_name) + company = self._make_client(company_name) contact = None if contact_name: - contact = ClientContact.objects.create(client=client, name=contact_name) + contact = ClientContact.objects.create(company=company, name=contact_name) self.job_number += 1 return Job.objects.create( staff=self.test_staff, name=name, - client=client, + company=company, contact=contact, status=status, job_number=self.job_number, @@ -68,7 +68,7 @@ def _make_invoice(self, job: Job, *, number: str) -> Invoice: return Invoice.objects.create( xero_id=uuid.uuid4(), number=number, - client=job.client, + company=job.company, job=job, date=date.today(), total_excl_tax=Decimal("100.00"), @@ -83,7 +83,7 @@ def _make_quote(self, job: Job, *, number: str) -> Quote: return Quote.objects.create( xero_id=uuid.uuid4(), number=number, - client=job.client, + company=job.company, job=job, date=date.today(), total_excl_tax=Decimal("100.00"), @@ -96,11 +96,11 @@ def test_perform_advanced_search_matches_single_token_job_name_substring(self): """Catches quick search no longer finding job-name substrings.""" target = self._make_job( name="2 X 1.2MM S/S KICK PLATES 910MM (W) X 300MM (H)", - client_name="Weaver, Decker and Schultz", + company_name="Weaver, Decker and Schultz", ) self._make_job( name="Aluminium handrail", - client_name="Other Client", + company_name="Other Company", ) jobs = list(KanbanService.perform_advanced_search({"universal_search": "kick"})) @@ -114,7 +114,7 @@ def test_perform_advanced_search_serializes_without_lazy_relation_loads(self): """ target = self._make_job( name="2 X 1.2MM S/S KICK PLATES 910MM (W) X 300MM (H)", - client_name="Weaver, Decker and Schultz", + company_name="Weaver, Decker and Schultz", ) jobs = list(KanbanService.perform_advanced_search({"universal_search": "kick"})) @@ -137,7 +137,7 @@ def test_search_with_results_does_not_trip_unused_eager_load_guard(self) -> None """ target = self._make_job( name="Stainless balustrade panels", - client_name="Guarded Rails Ltd", + company_name="Guarded Rails Ltd", ) # The middleware only installs (and patches only auto-apply) when @@ -157,11 +157,11 @@ def test_perform_advanced_search_preloads_quote_for_ranking(self): """Catches quote ranking that re-queries quotes per candidate job.""" target = self._make_job( name="Cool Awnings", - client_name="Cool Awnings Ltd", + company_name="Cool Awnings Ltd", ) other = self._make_job( name="Cool Store", - client_name="Cool Stores Ltd", + company_name="Cool Stores Ltd", ) self._make_quote(target, number="QU-56005") self._make_quote(other, number="QU-99999") @@ -183,11 +183,11 @@ def test_perform_advanced_search_matches_quote_number(self): """Catches quote-number search no longer finding the owning job.""" target = self._make_job( name="Cool Awnings", - client_name="Cool Awnings Ltd", + company_name="Cool Awnings Ltd", ) other = self._make_job( name="Other work", - client_name="Other Client", + company_name="Other Company", ) self._make_quote(target, number="QU-56005") self._make_quote(other, number="QU-99999") @@ -202,11 +202,11 @@ def test_perform_advanced_search_matches_numeric_substring(self): """Catches numeric quick search no longer matching job descriptions.""" target = self._make_job( name="2 X 1.2MM S/S KICK PLATES 910MM (W) X 300MM (H)", - client_name="Weaver, Decker and Schultz", + company_name="Weaver, Decker and Schultz", ) self._make_job( name="5MM folded flashing 1200MM", - client_name="Other Client", + company_name="Other Company", ) jobs = list(KanbanService.perform_advanced_search({"universal_search": "910"})) @@ -218,20 +218,20 @@ def test_numeric_query_prefers_job_number_over_long_description_substring(self): target = self._set_job_number( self._make_job( name="Workshop Closed due to new roof", - client_name="Weaver, Decker and Schultz", + company_name="Weaver, Decker and Schultz", ), 96977, ) description_match = self._make_job( name="Auckland airport - bag drop", - client_name="Other Client", + company_name="Other Company", ) description_match.description = "quote for bag drop components\n2-3977" description_match.save(staff=self.test_staff, update_fields=["description"]) for index in range(100): noisy_job = self._make_job( name=f"Noise job {index}", - client_name=f"Noise Client {index}", + company_name=f"Noise Company {index}", ) self._set_job_number(noisy_job, 70000 + index) @@ -245,7 +245,7 @@ def test_get_jobs_by_kanban_column_exact_job_number_suppresses_distant_noise(sel target = self._set_job_number( self._make_job( name="Best matching job", - client_name="Weaver, Decker and Schultz", + company_name="Weaver, Decker and Schultz", status="in_progress", ), 78941, @@ -253,7 +253,7 @@ def test_get_jobs_by_kanban_column_exact_job_number_suppresses_distant_noise(sel self._set_job_number( self._make_job( name="Adjacent but weaker job", - client_name="Other Client", + company_name="Other Company", status="in_progress", ), 78940, @@ -261,7 +261,7 @@ def test_get_jobs_by_kanban_column_exact_job_number_suppresses_distant_noise(sel for index in range(100): noisy_job = self._make_job( name=f"Noise job {index}", - client_name=f"Noise Client {index}", + company_name=f"Noise Company {index}", status="in_progress", ) self._set_job_number(noisy_job, 70000 + index) @@ -280,13 +280,13 @@ def test_perform_advanced_search_keeps_plausible_short_job_number_match( near_match = self._set_job_number( self._make_job( name="Best approximate job", - client_name="Other Client", + company_name="Other Company", ), 96977, ) self._make_job( name="Auckland airport - bag drop", - client_name="Another Client", + company_name="Another Company", contact_name="Alice Brown", ) @@ -299,14 +299,14 @@ def test_numeric_query_prefers_job_number_suffix_over_middle_substring(self): suffix_match = self._set_job_number( self._make_job( name="Suffix match", - client_name="Other Client", + company_name="Other Company", ), 96977, ) middle_match = self._set_job_number( self._make_job( name="Middle match", - client_name="Other Client", + company_name="Other Company", ), 97701, ) @@ -328,15 +328,15 @@ def test_perform_advanced_search_keeps_multiple_close_text_matches(self): """Catches text search collapsing distinct plausible job matches.""" target_one = self._make_job( name="Kick plates", - client_name="Weaver, Decker and Schultz", + company_name="Weaver, Decker and Schultz", ) target_two = self._make_job( name="Kick rails", - client_name="Other Client", + company_name="Other Company", ) self._make_job( name="Aluminium handrail", - client_name="Distant Client", + company_name="Distant Company", ) jobs = list(KanbanService.perform_advanced_search({"universal_search": "kick"})) @@ -344,14 +344,14 @@ def test_perform_advanced_search_keeps_multiple_close_text_matches(self): self.assertEqual({job.id for job in jobs}, {target_one.id, target_two.id}) def test_perform_advanced_search_matches_client_tokens_in_any_order(self): - """Catches client-name token search becoming order-sensitive.""" + """Catches company-name token search becoming order-sensitive.""" target = self._make_job( name="Kick plates", - client_name="Weaver, Decker and Schultz", + company_name="Weaver, Decker and Schultz", ) self._make_job( name="Other work", - client_name="Schultz Fabrication Only", + company_name="Schultz Fabrication Only", ) jobs = list( @@ -366,12 +366,12 @@ def test_perform_advanced_search_matches_contact_name_substring(self): """Catches contact-name search no longer matching partial names.""" target = self._make_job( name="Kick plates", - client_name="Weaver, Decker and Schultz", + company_name="Weaver, Decker and Schultz", contact_name="Molly Wainwright", ) self._make_job( name="Other work", - client_name="Other Client", + company_name="Other Company", contact_name="Alice Brown", ) @@ -380,15 +380,15 @@ def test_perform_advanced_search_matches_contact_name_substring(self): self.assertEqual([job.id for job in jobs], [target.id]) def test_get_jobs_by_kanban_column_matches_client_tokens_in_any_order(self): - """Catches kanban column search ignoring unordered client-name tokens.""" + """Catches kanban column search ignoring unordered company-name tokens.""" target = self._make_job( name="Kick plates", - client_name="Weaver, Decker and Schultz", + company_name="Weaver, Decker and Schultz", status="in_progress", ) self._make_job( name="Draft work", - client_name="Weaver Draft", + company_name="Weaver Draft", status="draft", ) @@ -403,7 +403,7 @@ def test_perform_advanced_search_returns_empty_when_query_not_present(self): """Catches unrelated jobs being returned for absent search terms.""" self._make_job( name="2 X 1.2MM S/S KICK PLATES 910MM (W) X 300MM (H)", - client_name="Weaver, Decker and Schultz", + company_name="Weaver, Decker and Schultz", ) jobs = list( @@ -416,7 +416,7 @@ def test_perform_advanced_search_returns_empty_for_only_weak_trigram_matches(sel """Catches weak fuzzy matches leaking below the display threshold.""" weak_match = self._make_job( name="5x swaged ends", - client_name="Other Client", + company_name="Other Company", ) setattr( weak_match, @@ -432,10 +432,10 @@ def test_perform_advanced_search_returns_empty_for_only_weak_trigram_matches(sel self.assertEqual(ranked_jobs, []) def test_perform_advanced_search_recovers_typo_tolerance(self): - """Catches typo tolerance no longer recovering misspelled client searches.""" + """Catches typo tolerance no longer recovering misspelled company searches.""" target = self._make_job( name="2 X 1.2MM S/S KICK PLATES 910MM (W) X 300MM (H)", - client_name="Weaver, Decker and Schultz", + company_name="Weaver, Decker and Schultz", ) jobs = list( @@ -450,7 +450,7 @@ def test_get_jobs_by_kanban_column_recovers_typo_tolerance(self): """Catches kanban column search losing typo tolerance.""" target = self._make_job( name="Kick plates", - client_name="Weaver, Decker and Schultz", + company_name="Weaver, Decker and Schultz", status="in_progress", ) @@ -465,11 +465,11 @@ def test_perform_advanced_search_does_not_fuzzy_match_invoice_numbers(self): """Catches invoice searches fuzzily matching the wrong invoice.""" target = self._make_job( name="Kick plates", - client_name="Weaver, Decker and Schultz", + company_name="Weaver, Decker and Schultz", ) other = self._make_job( name="Other work", - client_name="Other Client", + company_name="Other Company", ) self._make_invoice(target, number="INV-15152") self._make_invoice(other, number="INV-15153") @@ -484,11 +484,11 @@ def test_perform_advanced_search_matches_invoice_number_exactly_via_filter(self) """Catches the invoice filter failing to match a full invoice number.""" target = self._make_job( name="Kick plates", - client_name="Weaver, Decker and Schultz", + company_name="Weaver, Decker and Schultz", ) other = self._make_job( name="Other work", - client_name="Other Client", + company_name="Other Company", ) self._make_invoice(target, number="INV-15151") self._make_invoice(other, number="INV-15152") @@ -503,11 +503,11 @@ def test_perform_advanced_search_matches_bare_invoice_number(self): """Catches the invoice filter failing to match bare invoice digits.""" target = self._make_job( name="Cool Awnings", - client_name="Cool Awnings Ltd", + company_name="Cool Awnings Ltd", ) other = self._make_job( name="Other work", - client_name="Other Client", + company_name="Other Company", ) self._make_invoice(target, number="INV-56005") self._make_invoice(other, number="INV-12345") @@ -522,7 +522,7 @@ def test_perform_advanced_search_unrecognised_invoice_returns_empty(self): """Catches invalid invoice filters returning unrelated jobs.""" target = self._make_job( name="Kick plates", - client_name="Weaver, Decker and Schultz", + company_name="Weaver, Decker and Schultz", ) self._make_invoice(target, number="INV-15151") @@ -536,12 +536,12 @@ def test_perform_advanced_search_quick_search_matches_order_number(self): """Catches universal search no longer matching order numbers.""" target = self._make_job( name="Cool Awnings", - client_name="Cool Awnings Ltd", + company_name="Cool Awnings Ltd", order_number="8057", ) self._make_job( name="Other work", - client_name="Other Client", + company_name="Other Company", order_number="99999", ) @@ -553,12 +553,12 @@ def test_perform_advanced_search_order_number_filter(self): """Catches the explicit order-number filter returning the wrong job.""" target = self._make_job( name="Cool Awnings", - client_name="Cool Awnings Ltd", + company_name="Cool Awnings Ltd", order_number="8057", ) self._make_job( name="Other work", - client_name="Other Client", + company_name="Other Company", order_number="99999", ) @@ -570,11 +570,11 @@ def test_perform_advanced_search_quick_search_matches_invoice_number(self): """Catches universal search no longer matching full invoice numbers.""" target = self._make_job( name="Cool Awnings", - client_name="Cool Awnings Ltd", + company_name="Cool Awnings Ltd", ) other = self._make_job( name="Other work", - client_name="Other Client", + company_name="Other Company", ) self._make_invoice(target, number="INV-56005") self._make_invoice(other, number="INV-99999") @@ -589,11 +589,11 @@ def test_perform_advanced_search_quick_search_matches_bare_invoice_number(self): """Catches universal search no longer matching bare invoice digits.""" target = self._make_job( name="Cool Awnings", - client_name="Cool Awnings Ltd", + company_name="Cool Awnings Ltd", ) other = self._make_job( name="Other work", - client_name="Other Client", + company_name="Other Company", ) self._make_invoice(target, number="INV-56005") self._make_invoice(other, number="INV-99999") @@ -610,7 +610,7 @@ def test_perform_advanced_search_invoice_match_returns_job_once_with_multiple_in """Catches invoice joins duplicating jobs with multiple matching invoices.""" target = self._make_job( name="Cool Awnings", - client_name="Cool Awnings Ltd", + company_name="Cool Awnings Ltd", ) self._make_invoice(target, number="INV-56005") self._make_invoice(target, number="INV-56005-REV") @@ -627,7 +627,7 @@ def test_perform_advanced_search_text_match_returns_job_once_with_multiple_invoi """Catches text search duplicating jobs that have multiple invoices.""" target = self._make_job( name="Cool Awnings", - client_name="Cool Awnings Ltd", + company_name="Cool Awnings Ltd", ) self._make_invoice(target, number="INV-56005") self._make_invoice(target, number="INV-99999") @@ -640,7 +640,7 @@ def test_perform_advanced_search_invoice_reason_present(self): """Catches invoice matches losing their explainable search reason.""" target = self._make_job( name="Cool Awnings", - client_name="Cool Awnings Ltd", + company_name="Cool Awnings Ltd", ) self._make_invoice(target, number="INV-56005") @@ -658,7 +658,7 @@ def test_kanban_search_logging_records_ranked_results_and_reasons(self): target = self._set_job_number( self._make_job( name="Workshop Closed due to new roof", - client_name="Weaver, Decker and Schultz", + company_name="Weaver, Decker and Schultz", ), 96977, ) diff --git a/apps/job/tests/test_kanban_service.py b/apps/job/tests/test_kanban_service.py index 8a8bcd369..f4dbe3221 100644 --- a/apps/job/tests/test_kanban_service.py +++ b/apps/job/tests/test_kanban_service.py @@ -8,7 +8,7 @@ from django.utils import timezone from apps.accounts.models import Staff -from apps.client.models import Client, ClientContact +from apps.company.models import ClientContact, Company from apps.job.models import Job from apps.job.services.kanban_service import KanbanService from apps.testing import BaseTestCase @@ -19,16 +19,16 @@ class TestSerializeJobForApi(BaseTestCase): """Tests for KanbanService kanban job serialization.""" def setUp(self): - self.client_obj = Client.objects.create( - name="Test Client", + self.client_obj = Company.objects.create( + name="Test Company", xero_last_modified=timezone.now(), ) - self.shop_client = CompanyDefaults.get_solo().shop_client + self.shop_company = CompanyDefaults.get_solo().shop_company def _create_job(self, pricing_methodology="time_materials", name="Test Job"): """Create a job. Job.save() auto-creates CostSets (actual, quote, estimate).""" job = Job( - client=self.client_obj, + company=self.client_obj, name=name, pricing_methodology=pricing_methodology, ) @@ -81,7 +81,7 @@ def test_serialize_jobs_for_api_query_count_is_constant(self): for index in range(3): job = self._create_job(name=f"Batch Job {index}") job.contact = ClientContact.objects.create( - client=self.client_obj, name=f"Contact {index}" + company=self.client_obj, name=f"Contact {index}" ) job.save(staff=self.test_staff, update_fields=["contact"]) job.people.add(self.test_staff) @@ -107,7 +107,7 @@ def test_serialized_shape_for_fully_populated_job(self): """The rewritten serializer must keep the exact response contract the frontend Zod schema requires.""" job = self._create_job(name="Shape Job") - contact = ClientContact.objects.create(client=self.client_obj, name="Jane Doe") + contact = ClientContact.objects.create(company=self.client_obj, name="Jane Doe") job.contact = contact job.delivery_date = timezone.localdate() job.save(staff=self.test_staff, update_fields=["contact", "delivery_date"]) @@ -136,7 +136,7 @@ def test_serialized_shape_for_fully_populated_job(self): "name", "description", "job_number", - "client_name", + "company_name", "contact_person", "people", "status", @@ -161,7 +161,7 @@ def test_serialized_shape_for_fully_populated_job(self): "badge_color", }, ) - self.assertEqual(serialized["client_name"], "Test Client") + self.assertEqual(serialized["company_name"], "Test Company") self.assertEqual(serialized["contact_person"], "Jane Doe") self.assertEqual(serialized["quote_revenue"], 200.0) self.assertEqual(serialized["time_and_materials_revenue"], 100.0) @@ -178,7 +178,7 @@ def test_serialized_shape_for_fully_populated_job(self): def test_serialize_jobs_for_api_resolves_shop_client_once_for_batch(self): shop_job = Job( - client=self.shop_client, + company=self.shop_company, name="Shop Job", pricing_methodology="time_materials", ) @@ -200,7 +200,7 @@ def test_serialize_jobs_for_api_resolves_shop_client_once_for_batch(self): shop_client_queries = [ query["sql"] for query in captured - if "client_client" in query["sql"] and "Demo Company Shop" in query["sql"] + if "company_company" in query["sql"] and "Demo Company Shop" in query["sql"] ] self.assertEqual(shop_client_queries, []) self.assertEqual( diff --git a/apps/job/tests/test_labour_subtypes.py b/apps/job/tests/test_labour_subtypes.py index a800841f4..95e7bdcb7 100644 --- a/apps/job/tests/test_labour_subtypes.py +++ b/apps/job/tests/test_labour_subtypes.py @@ -9,7 +9,7 @@ from rest_framework.test import APIClient from apps.accounts.models import Staff -from apps.client.models import Client +from apps.company.models import Company from apps.job.models import Job, JobLabourRate, LabourSubtype from apps.job.services.labour_subtype_service import seed_subtype_onto_existing_jobs from apps.job.services.workshop_service import WorkshopTimesheetService @@ -60,14 +60,14 @@ def test_onsite_defaults_to_onsite_charge_out_rate(self) -> None: class JobLabourRateSeedingTests(BaseTestCase): def setUp(self) -> None: - self.client_obj = Client.objects.create( - name="Labour Rate Client", + self.client_obj = Company.objects.create( + name="Labour Rate Company", email="labour-rates@example.com", xero_last_modified="2024-01-01T00:00:00Z", ) def _create_job(self) -> Job: - job = Job(name="Labour Rate Job", client=self.client_obj) + job = Job(name="Labour Rate Job", company=self.client_obj) job.save(staff=self.test_staff) return job @@ -137,12 +137,12 @@ def test_new_non_workshop_staff_defaults_to_office_admin(self) -> None: class TimesheetLabourSubtypeTests(BaseTestCase): def setUp(self) -> None: - self.client_obj = Client.objects.create( - name="Timesheet Subtype Client", + self.client_obj = Company.objects.create( + name="Timesheet Subtype Company", email="timesheet-subtypes@example.com", xero_last_modified="2024-01-01T00:00:00Z", ) - self.job = Job(name="Timesheet Subtype Job", client=self.client_obj) + self.job = Job(name="Timesheet Subtype Job", company=self.client_obj) self.job.save(staff=self.test_staff) self.staff = Staff.objects.create_user( email="timesheet-subtypes@example.com", @@ -248,16 +248,16 @@ def setUp(self) -> None: ) self.api = APIClient() self.api.force_authenticate(user=self.office_staff) - self.client_obj = Client.objects.create( - name="Mgmt Client", + self.client_obj = Company.objects.create( + name="Mgmt Company", email="mgmt-subtypes@example.com", xero_last_modified="2024-01-01T00:00:00Z", ) - self.job = Job(name="Mgmt Job", client=self.client_obj) + self.job = Job(name="Mgmt Job", company=self.client_obj) self.job.save(staff=self.test_staff) def _create_job(self, name: str) -> Job: - job = Job(name=name, client=self.client_obj) + job = Job(name=name, company=self.client_obj) job.save(staff=self.test_staff) return job diff --git a/apps/job/tests/test_mcp_tool_integration.py b/apps/job/tests/test_mcp_tool_integration.py index 9c410674b..a6b1109e8 100644 --- a/apps/job/tests/test_mcp_tool_integration.py +++ b/apps/job/tests/test_mcp_tool_integration.py @@ -8,7 +8,7 @@ - Error handling in tool execution """ -from apps.client.models import Client +from apps.company.models import Company from apps.job.models import Job from apps.quoting.mcp import QuotingTool, SupplierProductQueryTool from apps.quoting.models import SupplierPriceList, SupplierProduct @@ -21,7 +21,7 @@ class QuotingToolTests(BaseTestCase): def setUp(self): """Set up test data""" # Create a supplier - self.supplier = Client.objects.create( + self.supplier = Company.objects.create( name="ABC Steel", email="sales@abcsteel.com", is_supplier=True, @@ -29,17 +29,17 @@ def setUp(self): ) # Create another supplier for comparison tests - self.supplier2 = Client.objects.create( + self.supplier2 = Company.objects.create( name="XYZ Metals", email="sales@xyzmetals.com", is_supplier=True, xero_last_modified="2024-01-01T00:00:00Z", ) - # Create a client for job tests - self.client_obj = Client.objects.create( - name="Test Client", - email="client@example.com", + # Create a company for job tests + self.client_obj = Company.objects.create( + name="Test Company", + email="company@example.com", xero_last_modified="2024-01-01T00:00:00Z", ) @@ -107,7 +107,7 @@ def setUp(self): self.job = Job.objects.create( name="Test Job", description="Test job description", - client=self.client_obj, + company=self.client_obj, status="quoting", staff=self.test_staff, ) @@ -254,7 +254,7 @@ class SupplierProductQueryToolTests(BaseTestCase): def setUp(self): """Set up test data""" - self.supplier = Client.objects.create( + self.supplier = Company.objects.create( name="Test Supplier", email="supplier@test.com", is_supplier=True, @@ -304,22 +304,22 @@ class MCPToolIntegrationTests(BaseTestCase): def setUp(self): """Set up test data""" - self.supplier = Client.objects.create( + self.supplier = Company.objects.create( name="Integration Test Supplier", email="int@test.com", is_supplier=True, xero_last_modified="2024-01-01T00:00:00Z", ) - self.client_obj = Client.objects.create( - name="Test Client", - email="client@test.com", + self.client_obj = Company.objects.create( + name="Test Company", + email="company@test.com", xero_last_modified="2024-01-01T00:00:00Z", ) self.job = Job.objects.create( name="Integration Test Job", - client=self.client_obj, + company=self.client_obj, status="quoting", staff=self.test_staff, ) diff --git a/apps/job/tests/test_modern_timesheet_views.py b/apps/job/tests/test_modern_timesheet_views.py index 62095ff3f..2dcf31633 100644 --- a/apps/job/tests/test_modern_timesheet_views.py +++ b/apps/job/tests/test_modern_timesheet_views.py @@ -5,7 +5,7 @@ from rest_framework.test import APIRequestFactory, force_authenticate from apps.accounts.models import Staff -from apps.client.models import Client +from apps.company.models import Company from apps.job.models import CostLine, Job, LabourSubtype from apps.job.serializers.costing_serializer import TimesheetCostLineSerializer from apps.job.views.modern_timesheet_views import ModernTimesheetEntryView @@ -17,8 +17,8 @@ class ModernTimesheetEntryQueryTests(BaseTestCase): def setUp(self): super().setUp() self.target_date = date(2026, 5, 22) - self.client = Client.objects.create( - name="Modern Timesheet Client", + self.company = Company.objects.create( + name="Modern Timesheet Company", email="modern-timesheet@example.com", xero_last_modified=timezone.now(), ) @@ -26,7 +26,7 @@ def setUp(self): self.job = Job.objects.create( job_number=98767, name="Modern Timesheet Job", - client=self.client, + company=self.company, default_xero_pay_item=self.pay_item, staff=self.test_staff, ) @@ -57,7 +57,7 @@ def test_timesheet_cost_line_serializer_uses_preloaded_relations(self): cost_lines = ( CostLine.objects.filter(pk=self.cost_line.pk) .select_related( - "cost_set__job__client", + "cost_set__job__company", "staff", "xero_pay_item", "labour_subtype", @@ -70,7 +70,7 @@ def test_timesheet_cost_line_serializer_uses_preloaded_relations(self): with self.assertNumQueries(2): data = TimesheetCostLineSerializer(cost_lines, many=True).data - self.assertEqual(data[0]["client_name"], "Modern Timesheet Client") + self.assertEqual(data[0]["company_name"], "Modern Timesheet Company") self.assertEqual(data[0]["wage_rate"], 40.0) self.assertEqual(data[0]["xero_pay_item_name"], self.pay_item.name) self.assertEqual(data[0]["labour_subtype_name"], "Workshop") diff --git a/apps/job/tests/test_paid_flag_service.py b/apps/job/tests/test_paid_flag_service.py index f7496af90..e0099bd67 100644 --- a/apps/job/tests/test_paid_flag_service.py +++ b/apps/job/tests/test_paid_flag_service.py @@ -9,7 +9,7 @@ from django.utils import timezone from apps.accounting.models import Invoice -from apps.client.models import Client +from apps.company.models import Company from apps.job.models import Job from apps.job.services.paid_flag_service import PaidFlagService from apps.testing import BaseTestCase @@ -17,14 +17,14 @@ class PaidFlagServiceTests(BaseTestCase): def setUp(self): - self.client_obj = Client.objects.create( - name="Paid Flag Client", + self.client_obj = Company.objects.create( + name="Paid Flag Company", xero_last_modified=timezone.now(), ) def _create_job(self, name: str) -> Job: job = Job( - client=self.client_obj, + company=self.client_obj, name=name, status="recently_completed", paid=False, @@ -35,7 +35,7 @@ def _create_job(self, name: str) -> Job: def _create_invoice(self, job: Job, status: str) -> Invoice: return Invoice.objects.create( job=job, - client=self.client_obj, + company=self.client_obj, xero_id=uuid.uuid4(), number=f"INV-{uuid.uuid4().hex[:8]}", status=status, diff --git a/apps/job/tests/test_quote_modes.py b/apps/job/tests/test_quote_modes.py index 7ee79eead..58161daab 100644 --- a/apps/job/tests/test_quote_modes.py +++ b/apps/job/tests/test_quote_modes.py @@ -11,7 +11,7 @@ from django.utils import timezone from jsonschema import ValidationError -from apps.client.models import Client +from apps.company.models import Company from apps.job.models import Job from apps.job.schemas import quote_mode_schemas from apps.job.services.quote_mode_controller import QuoteModeController @@ -147,14 +147,14 @@ def setUp(self): self.controller = QuoteModeController() # Create test job - self.client_obj = Client.objects.create( - name="Test Client", + self.client_obj = Company.objects.create( + name="Test Company", email="test@example.com", xero_last_modified=timezone.now(), ) self.job = Job.objects.create( name="Test Job", - client=self.client_obj, + company=self.client_obj, staff=self.test_staff, ) @@ -240,7 +240,7 @@ def test_render_prompt(self): calc_prompt = self.controller.render_prompt( mode="CALC", user_input="Calculate area for 100x50mm", - job_ctx={"job_number": "TEST001", "client": "Test Client"}, + job_ctx={"job_number": "TEST001", "company": "Test Company"}, ) self.assertIn("MODE=CALC", calc_prompt) self.assertIn("SCHEMA for CALC", calc_prompt) @@ -317,14 +317,14 @@ class TestSheetTenthsIntegration(BaseTestCase): def setUp(self): """Set up test fixtures.""" # Create test job - self.client_obj = Client.objects.create( - name="Test Client", + self.client_obj = Company.objects.create( + name="Test Company", email="test@example.com", xero_last_modified=timezone.now(), ) self.job = Job.objects.create( name="Sheet Tenths Test Job", - client=self.client_obj, + company=self.client_obj, staff=self.test_staff, ) diff --git a/apps/job/tests/test_workshop_pdf_service.py b/apps/job/tests/test_workshop_pdf_service.py index 99d3d59f2..612208bde 100644 --- a/apps/job/tests/test_workshop_pdf_service.py +++ b/apps/job/tests/test_workshop_pdf_service.py @@ -14,7 +14,7 @@ from django.utils import timezone from pypdf import PdfReader -from apps.client.models import Client, ClientContact, ClientContactMethod +from apps.company.models import ClientContact, ClientContactMethod, Company from apps.job.models import CostSet, Job, LabourSubtype from apps.job.models.costing import CostLine from apps.job.services.workshop_pdf_service import ( @@ -33,25 +33,25 @@ class PrimaryPhoneForJobTests(BaseTestCase): """Phone preference order on workshop PDFs and delivery dockets: - the job contact's number first, then the client's own number.""" + the job contact's number first, then the company's own number.""" def setUp(self) -> None: - self.client_obj = Client.objects.create( - name="Phone Pref Client", + self.client_obj = Company.objects.create( + name="Phone Pref Company", xero_last_modified=timezone.now(), ) self.contact = ClientContact.objects.create( - client=self.client_obj, + company=self.client_obj, name="Jane Doe", ) self.job = Job.objects.create( - client=self.client_obj, + company=self.client_obj, contact=self.contact, name="Phone Pref Job", staff=self.test_staff, ) ClientContactMethod.objects.create( - client=self.client_obj, + company=self.client_obj, method_type=ClientContactMethod.MethodType.PHONE, value="09 555 0001", ) @@ -396,12 +396,12 @@ class WorkshopHourBreakdownTests(BaseTestCase): """Tests for subtype-based workshop PDF hour calculations.""" def setUp(self) -> None: - self.client_obj = Client.objects.create( - name="PDF Time Client", + self.client_obj = Company.objects.create( + name="PDF Time Company", xero_last_modified=timezone.now(), ) self.job = Job.objects.create( - client=self.client_obj, + company=self.client_obj, name="PDF Time Job", staff=self.test_staff, ) @@ -554,7 +554,7 @@ def test_preloaded_workshop_pdf_render_does_not_query_cost_lines(self) -> None: assert estimate is not None self._add_time(estimate, "Workshop", "4.000", "Workshop") ClientContactMethod.objects.create( - client=self.job.client, + company=self.job.company, method_type=ClientContactMethod.MethodType.PHONE, value="09 555 0001", ) @@ -583,7 +583,7 @@ def test_preloaded_workshop_pdf_render_does_not_query_cost_lines(self) -> None: contact_method_queries = [ query["sql"] for query in captured - if 'FROM "client_clientcontactmethod"' in query["sql"] + if 'FROM "company_clientcontactmethod"' in query["sql"] ] self.assertEqual(cost_line_queries, []) self.assertEqual(contact_method_queries, []) @@ -659,12 +659,12 @@ class DeliveryDocketPDFTests(BaseTestCase): """Tests for delivery docket PDF generation.""" def setUp(self) -> None: - self.client_obj = Client.objects.create( - name="Test Client", + self.client_obj = Company.objects.create( + name="Test Company", xero_last_modified=timezone.now(), ) self.job = Job.objects.create( - client=self.client_obj, + company=self.client_obj, name="Test Delivery Job", description="Deliver some steel", staff=self.test_staff, diff --git a/apps/job/tests/test_workshop_timesheet_api.py b/apps/job/tests/test_workshop_timesheet_api.py index d1d0bfa95..436c80edb 100644 --- a/apps/job/tests/test_workshop_timesheet_api.py +++ b/apps/job/tests/test_workshop_timesheet_api.py @@ -7,7 +7,7 @@ from django.urls import reverse from apps.accounts.models import Staff -from apps.client.models import Client +from apps.company.models import Company from apps.job.models import CostLine, Job from apps.testing import BaseAPITestCase from apps.workflow.models import XeroPayItem @@ -17,15 +17,15 @@ class WorkshopTimesheetAPITests(BaseAPITestCase): """Verify that normal (non-admin) staff can use the workshop timesheet API.""" def setUp(self) -> None: - self.test_client = Client.objects.create( - name="Workshop Test Client", + self.test_company = Company.objects.create( + name="Workshop Test Company", email="workshop-test@example.com", xero_last_modified="2024-01-01T00:00:00Z", ) self.job = Job.objects.create( job_number=9000, name="Workshop Timesheet Test Job", - client=self.test_client, + company=self.test_company, staff=self.test_staff, ) self.job.labour_rates.update(charge_out_rate=Decimal("120.00")) diff --git a/apps/job/utils.py b/apps/job/utils.py index a23c350b0..4cac5ca69 100644 --- a/apps/job/utils.py +++ b/apps/job/utils.py @@ -15,4 +15,4 @@ def get_active_jobs() -> models.QuerySet[Job]: """ excluded_statuses = ["rejected", "on_hold", "archived"] # Include select_related for fields commonly needed when displaying these jobs - return Job.objects.exclude(status__in=excluded_statuses).select_related("client") + return Job.objects.exclude(status__in=excluded_statuses).select_related("company") diff --git a/apps/job/views/data_quality_report_views.py b/apps/job/views/data_quality_report_views.py index 905317af7..fd7570070 100644 --- a/apps/job/views/data_quality_report_views.py +++ b/apps/job/views/data_quality_report_views.py @@ -12,7 +12,7 @@ from rest_framework.response import Response from rest_framework.views import APIView -from apps.client.services.duplicate_phone_report import DuplicatePhoneReportService +from apps.company.services.duplicate_phone_report import DuplicatePhoneReportService from apps.job.permissions import IsOfficeStaff from apps.job.serializers.data_quality_report_serializers import ( ArchivedJobsComplianceResponseSerializer, @@ -71,7 +71,7 @@ class DuplicatePhonesView(APIView): operation_id="check_duplicate_phones", summary="Check duplicate phone ownership", description=( - "List phone numbers owned by more than one client, or client numbers " + "List phone numbers owned by more than one company, or company numbers " "that are actually internal company lines." ), responses={ @@ -81,7 +81,7 @@ class DuplicatePhonesView(APIView): tags=["Data Quality"], ) def get(self, request: Request) -> Response: - """Return phone numbers that break the one-number-one-client rule.""" + """Return phone numbers that break the one-number-one-company rule.""" try: result = DuplicatePhoneReportService().get_report() diff --git a/apps/job/views/delivery_docket_view.py b/apps/job/views/delivery_docket_view.py index bffe79707..63a78ec69 100644 --- a/apps/job/views/delivery_docket_view.py +++ b/apps/job/views/delivery_docket_view.py @@ -23,7 +23,7 @@ class DeliveryDocketView(APIView): API view for generating and serving delivery docket PDFs. This view creates delivery docket PDFs that are identical to the workshop - PDF (job details, specifications, client info, signature fields), saves + PDF (job details, specifications, company info, signature fields), saves them as JobFile records, creates a JobEvent for tracking, and returns the PDF for immediate download or printing. diff --git a/apps/job/views/job_rest_views.py b/apps/job/views/job_rest_views.py index 4d78f686b..dd0b39096 100644 --- a/apps/job/views/job_rest_views.py +++ b/apps/job/views/job_rest_views.py @@ -259,7 +259,7 @@ def post(self, request): Expected JSON: { "name": "Job Name", - "client_id": "client-uuid", + "company_id": "company-uuid", "description": "Optional description", "order_number": "Optional order number", "notes": "Optional notes", @@ -709,7 +709,7 @@ def post(self, request, job_id): if not if_match: return self._precondition_required_response() - # Verify client ETag against current job version + # Verify company ETag against current job version try: job_for_etag = Job.objects.only("id", "updated_at").get(id=job_id) current_etag_norm = self._normalize_etag( @@ -889,15 +889,15 @@ def get(self, request, job_id): Fetch essential job header data for fast initial loading. """ try: - # Query fields derived from JOB_DIRECT_FIELDS, plus id and client/contact for joins + # Query fields derived from JOB_DIRECT_FIELDS, plus id and company/contact for joins query_fields = [ "id", "updated_at", - "client_id", + "company_id", "contact_id", ] + Job.JOB_DIRECT_FIELDS job = ( - Job.objects.select_related("client", "contact") + Job.objects.select_related("company", "contact") .only(*query_fields) .get(id=job_id) ) diff --git a/apps/job/views/kanban_view_api.py b/apps/job/views/kanban_view_api.py index 34169277d..9261b01ef 100644 --- a/apps/job/views/kanban_view_api.py +++ b/apps/job/views/kanban_view_api.py @@ -335,7 +335,7 @@ class AdvancedSearchAPIView(APIView): type=str, location=OpenApiParameter.QUERY, required=False, - description="Universal search - searches across job number, name, description, and client name with OR logic", + description="Universal search - searches across job number, name, description, and company name with OR logic", ), OpenApiParameter( name="job_number", @@ -359,11 +359,11 @@ class AdvancedSearchAPIView(APIView): description="Filter by job description", ), OpenApiParameter( - name="client_name", + name="company_name", type=str, location=OpenApiParameter.QUERY, required=False, - description="Filter by client name", + description="Filter by company name", ), OpenApiParameter( name="contact_person", @@ -439,7 +439,7 @@ def get(self, request: Request) -> Response: "job_number": request.GET.get("job_number", ""), "name": request.GET.get("name", ""), "description": request.GET.get("description", ""), - "client_name": request.GET.get("client_name", ""), + "company_name": request.GET.get("company_name", ""), "contact_person": request.GET.get("contact_person", ""), "order_number": request.GET.get("order_number", ""), "created_by": request.GET.get("created_by", ""), diff --git a/apps/job/views/modern_timesheet_views.py b/apps/job/views/modern_timesheet_views.py index a67dc1f62..81a1ba2eb 100644 --- a/apps/job/views/modern_timesheet_views.py +++ b/apps/job/views/modern_timesheet_views.py @@ -158,7 +158,7 @@ def get(self, request): accounting_date=parsed_date, ) .select_related( - "cost_set__job__client", + "cost_set__job__company", "staff", "xero_pay_item", "labour_subtype", @@ -553,7 +553,7 @@ def get(self, request, staff_id, entry_date): accounting_date=parsed_date, ) .select_related( - "cost_set__job__client", + "cost_set__job__company", "staff", "xero_pay_item", "labour_subtype", @@ -624,7 +624,7 @@ def get(self, request, job_id): meta__created_from_timesheet=True, ) .select_related( - "cost_set__job__client", + "cost_set__job__company", "staff", "xero_pay_item", "labour_subtype", diff --git a/apps/job/views/month_end_rest_view.py b/apps/job/views/month_end_rest_view.py index 377557321..aab2036b3 100644 --- a/apps/job/views/month_end_rest_view.py +++ b/apps/job/views/month_end_rest_view.py @@ -50,8 +50,8 @@ def get(self, request): "job_id": str(item["job"].id), "job_number": item["job"].job_number, "job_name": item["job"].name, - "client_name": ( - item["job"].client.name if item["job"].client else "" + "company_name": ( + item["job"].company.name if item["job"].company else "" ), "history": [ { diff --git a/apps/job/views/workshop_view.py b/apps/job/views/workshop_view.py index fe2170d10..fdb8b5e03 100644 --- a/apps/job/views/workshop_view.py +++ b/apps/job/views/workshop_view.py @@ -39,7 +39,7 @@ def get_queryset(self): "name": job.name, "description": job.description, "job_number": job.job_number, - "client_name": job.client.name, + "company_name": job.company.name, "contact_person": job.contact.name if job.contact else None, "people": [ { diff --git a/apps/operations/serializers/workshop_schedule_serializer.py b/apps/operations/serializers/workshop_schedule_serializer.py index e78570248..a908c2f07 100644 --- a/apps/operations/serializers/workshop_schedule_serializer.py +++ b/apps/operations/serializers/workshop_schedule_serializer.py @@ -17,7 +17,7 @@ class ScheduledJobSerializer(serializers.Serializer): id = serializers.UUIDField() job_number = serializers.IntegerField() name = serializers.CharField() - client_name = serializers.CharField() + company_name = serializers.CharField() remaining_hours = serializers.FloatField() delivery_date = serializers.DateField(allow_null=True) anticipated_start_date = serializers.DateField(allow_null=True) @@ -33,7 +33,7 @@ class UnscheduledJobSerializer(serializers.Serializer): id = serializers.UUIDField() job_number = serializers.IntegerField() name = serializers.CharField() - client_name = serializers.CharField() + company_name = serializers.CharField() delivery_date = serializers.DateField(allow_null=True) remaining_hours = serializers.FloatField() reason = serializers.CharField() diff --git a/apps/operations/tests/test_scheduler_persistence.py b/apps/operations/tests/test_scheduler_persistence.py index 24f1f459b..2626ac90e 100644 --- a/apps/operations/tests/test_scheduler_persistence.py +++ b/apps/operations/tests/test_scheduler_persistence.py @@ -8,7 +8,7 @@ from django.utils import timezone from apps.accounts.models import Staff -from apps.client.models import Client +from apps.company.models import Company from apps.job.models import CostSet, Job, LabourSubtype from apps.job.models.costing import CostLine from apps.operations.models import AllocationBlock, JobProjection, SchedulerRun @@ -56,11 +56,11 @@ def _set_workshop_hours(cost_set: CostSet, hours: float) -> None: ) -def _make_job(client: Client, staff: Staff, name: str = "Persist Test Job") -> Job: +def _make_job(company: Company, staff: Staff, name: str = "Persist Test Job") -> Job: job = cast( Job, Job.objects.create( - client=client, + company=company, name=name, status="approved", staff=staff, @@ -74,8 +74,8 @@ class TestSchedulerRunRecord(BaseTestCase): """Verify SchedulerRun records are created correctly.""" def setUp(self): - self.client_obj = Client.objects.create( - name="Persist Client", + self.client_obj = Company.objects.create( + name="Persist Company", xero_last_modified=timezone.now(), ) _make_staff("p1") @@ -101,8 +101,8 @@ class TestFailedRunPreservesData(BaseTestCase): """Verify a failed run does not overwrite good data from a previous run.""" def setUp(self): - self.client_obj = Client.objects.create( - name="Persist Client", + self.client_obj = Company.objects.create( + name="Persist Company", xero_last_modified=timezone.now(), ) _make_staff("p2") @@ -155,8 +155,8 @@ class TestLatestForecastReadsNewestRun(BaseTestCase): """Verify the API reads from the most recent successful SchedulerRun.""" def setUp(self): - self.client_obj = Client.objects.create( - name="Persist Client", + self.client_obj = Company.objects.create( + name="Persist Company", xero_last_modified=timezone.now(), ) _make_staff("p3") diff --git a/apps/operations/tests/test_scheduler_service.py b/apps/operations/tests/test_scheduler_service.py index d10c8a627..b55ed8ba3 100644 --- a/apps/operations/tests/test_scheduler_service.py +++ b/apps/operations/tests/test_scheduler_service.py @@ -6,7 +6,7 @@ from django.utils import timezone from apps.accounts.models import Staff -from apps.client.models import Client +from apps.company.models import Company from apps.job.models import CostSet, Job, LabourSubtype from apps.job.models.costing import CostLine from apps.operations.models import AllocationBlock, JobProjection @@ -35,15 +35,15 @@ def _make_staff( ) -def _make_client() -> Client: - return Client.objects.create( - name="Test Client", +def _make_client() -> Company: + return Company.objects.create( + name="Test Company", xero_last_modified=timezone.now(), ) def _make_job( - client: Client, + company: Company, staff: Staff, name: str = "Test Job", status: str = "approved", @@ -51,7 +51,7 @@ def _make_job( max_people: int = 1, ) -> Job: job = Job( - client=client, + company=company, name=name, status=status, min_people=min_people, @@ -474,7 +474,7 @@ def _make_special_leave_job(self): """Create a leave-style job with an actual CostSet for booking time against (matches how create_leave_entries.py models leave).""" leave_job = Job( - client=self.client_obj, + company=self.client_obj, name="Annual Leave", status="special", min_people=1, diff --git a/apps/operations/tests/test_workshop_schedule_api.py b/apps/operations/tests/test_workshop_schedule_api.py index cb7251b04..f6c82eb85 100644 --- a/apps/operations/tests/test_workshop_schedule_api.py +++ b/apps/operations/tests/test_workshop_schedule_api.py @@ -9,7 +9,7 @@ from rest_framework.test import APIClient from apps.accounts.models import Staff -from apps.client.models import Client +from apps.company.models import Company from apps.job.models import CostSet, Job, LabourSubtype from apps.job.models.costing import CostLine from apps.operations.services.scheduler_service import run_workshop_schedule @@ -57,7 +57,7 @@ def _set_workshop_hours(cost_set: CostSet, hours: float) -> None: def _make_job( - client: Client, + company: Company, staff: Staff, name: str = "API Test Job", hours: float = 8.0, @@ -65,7 +65,7 @@ def _make_job( job = cast( Job, Job.objects.create( - client=client, + company=company, name=name, status="approved", staff=staff, @@ -79,8 +79,8 @@ class WorkshopScheduleGetTests(BaseAPITestCase): """Tests for GET /api/operations/workshop-schedule/""" def setUp(self): - self.client_obj = Client.objects.create( - name="API Test Client", + self.client_obj = Company.objects.create( + name="API Test Company", xero_last_modified=timezone.now(), ) self.staff = _make_staff("a1") @@ -139,7 +139,7 @@ def test_unscheduled_job_has_reason(self): """Unscheduled jobs include a machine-readable reason field.""" # Job with no hours → unscheduled Job.objects.create( - client=self.client_obj, + company=self.client_obj, name="No Hours Job", status="approved", staff=self.test_staff, @@ -166,8 +166,8 @@ class WorkshopScheduleRecalculateTests(BaseAPITestCase): """Tests for POST /api/operations/workshop-schedule/recalculate/""" def setUp(self): - self.client_obj = Client.objects.create( - name="Recalc Test Client", + self.client_obj = Company.objects.create( + name="Recalc Test Company", xero_last_modified=timezone.now(), ) self.staff = _make_staff("b1") diff --git a/apps/operations/views/workshop_schedule_view.py b/apps/operations/views/workshop_schedule_view.py index 4da1e19a4..9416b0601 100644 --- a/apps/operations/views/workshop_schedule_view.py +++ b/apps/operations/views/workshop_schedule_view.py @@ -34,7 +34,7 @@ def _build_schedule_response(day_horizon: int) -> dict[str, Any]: return {"days": [], "jobs": [], "unscheduled_jobs": []} projections = JobProjection.objects.filter(scheduler_run=latest_run).select_related( - "job", "job__client" + "job", "job__company" ) projections = list(projections) scheduled_job_ids = [ @@ -59,7 +59,7 @@ def _build_schedule_response(day_horizon: int) -> dict[str, Any]: for projection in projections: job = projection.job - client_name = job.client.name if job.client else "" + company_name = job.company.name if job.company else "" if projection.is_unscheduled: unscheduled_jobs.append( @@ -67,7 +67,7 @@ def _build_schedule_response(day_horizon: int) -> dict[str, Any]: "id": job.id, "job_number": job.job_number, "name": job.name, - "client_name": client_name, + "company_name": company_name, "delivery_date": job.delivery_date, "remaining_hours": projection.remaining_hours, "reason": projection.unscheduled_reason or "", @@ -88,7 +88,7 @@ def _build_schedule_response(day_horizon: int) -> dict[str, Any]: "id": job.id, "job_number": job.job_number, "name": job.name, - "client_name": client_name, + "company_name": company_name, "remaining_hours": projection.remaining_hours, "delivery_date": job.delivery_date, "anticipated_start_date": projection.anticipated_start_date, diff --git a/apps/process/services/google_docs_service.py b/apps/process/services/google_docs_service.py index 7f5ecb57f..8b47db89f 100644 --- a/apps/process/services/google_docs_service.py +++ b/apps/process/services/google_docs_service.py @@ -625,11 +625,11 @@ def _build_info_section( # Job details site_location = content.get("site_location", "To be confirmed") - client_name = job.client.name if job.client else "N/A" + company_name = job.company.name if job.company else "N/A" info_lines = [ f"Job Number: {job.job_number}", - f"Client: {client_name}", + f"Company: {company_name}", f"Site Location: {site_location}", ] diff --git a/apps/process/services/safety_ai_service.py b/apps/process/services/safety_ai_service.py index 024bcbc7d..8422707a7 100644 --- a/apps/process/services/safety_ai_service.py +++ b/apps/process/services/safety_ai_service.py @@ -110,12 +110,12 @@ def generate_full_jsa(self, job: Job) -> dict[str, Any]: Job Name: {job.name} Job Number: {job.job_number} -Client: {job.client.name if job.client else 'Unknown'} +Company: {job.company.name if job.company else 'Unknown'} Description: {job.description or 'No description provided'} Generate a comprehensive JSA with: 1. A clear title (job name) -2. Site location (use client address if known, otherwise "To be confirmed on site") +2. Site location (use company address if known, otherwise "To be confirmed on site") 3. A detailed job description for safety purposes 4. 4-6 sequential tasks covering the work from setup to completion 5. For each task: 2-4 potential hazards and appropriate control measures diff --git a/apps/process/tests/conftest.py b/apps/process/tests/conftest.py index 901e936cf..db535f5b9 100644 --- a/apps/process/tests/conftest.py +++ b/apps/process/tests/conftest.py @@ -2,7 +2,7 @@ from django.utils import timezone from apps.accounts.models import Staff -from apps.client.models import Client +from apps.company.models import Company from apps.job.models import Job from apps.workflow.models import CompanyDefaults @@ -19,16 +19,16 @@ def test_staff(db): @pytest.fixture def job(db, test_staff): - shop_client = Client.objects.create( - name="Process Shop Client", + shop_company = Company.objects.create( + name="Process Shop Company", xero_last_modified=timezone.now(), ) CompanyDefaults.objects.create( company_name="Process Test Co", - shop_client=shop_client, + shop_company=shop_company, ) - client = Client.objects.create( - name="Test Client", + company = Company.objects.create( + name="Test Company", xero_last_modified=timezone.now(), ) - return Job.objects.create(client=client, name="Test Job", staff=test_staff) + return Job.objects.create(company=company, name="Test Job", staff=test_staff) diff --git a/apps/process/tests/test_procedure_service.py b/apps/process/tests/test_procedure_service.py index 3365e2ffd..8c7635ad1 100644 --- a/apps/process/tests/test_procedure_service.py +++ b/apps/process/tests/test_procedure_service.py @@ -3,7 +3,7 @@ import pytest from django.utils import timezone -from apps.client.models import Client +from apps.company.models import Company from apps.process.services.google_docs_service import GoogleDocResult from apps.process.services.procedure_service import ProcedureService from apps.workflow.models import CompanyDefaults @@ -20,8 +20,8 @@ def _make_service(): return service -def _create_shop_client(name: str = "Shop Client") -> Client: - return Client.objects.create(name=name, xero_last_modified=timezone.now()) +def _create_shop_client(name: str = "Shop Company") -> Company: + return Company.objects.create(name=name, xero_last_modified=timezone.now()) @pytest.mark.django_db @@ -30,7 +30,7 @@ def test_create_blank_procedure_happy_path(self): CompanyDefaults.objects.create( company_name="Morris Sheetmetal", gdrive_reference_library_folder_id="folder-123", - shop_client=_create_shop_client(), + shop_company=_create_shop_client(), ) service = _make_service() @@ -73,7 +73,7 @@ def test_create_blank_procedure_rejects_missing_folder_config(self): CompanyDefaults.objects.create( company_name="Test", gdrive_reference_library_folder_id="", - shop_client=_create_shop_client(), + shop_company=_create_shop_client(), ) service = _make_service() diff --git a/apps/purchasing/migrations/0001_baseline.py b/apps/purchasing/migrations/0001_baseline.py index fa1bd201f..0efad55f0 100644 --- a/apps/purchasing/migrations/0001_baseline.py +++ b/apps/purchasing/migrations/0001_baseline.py @@ -15,7 +15,7 @@ class Migration(migrations.Migration): initial = True dependencies = [ - ("client", "0001_baseline"), + ("company", "0001_baseline"), ("job", "0001_baseline"), migrations.swappable_dependency(settings.AUTH_USER_MODEL), ] @@ -111,7 +111,7 @@ class Migration(migrations.Migration): null=True, on_delete=django.db.models.deletion.SET_NULL, related_name="purchase_orders", - to="client.supplierpickupaddress", + to="company.supplierpickupaddress", ), ), ( @@ -121,7 +121,7 @@ class Migration(migrations.Migration): null=True, on_delete=django.db.models.deletion.PROTECT, related_name="purchase_orders", - to="client.client", + to="company.client", ), ), ], diff --git a/apps/purchasing/models.py b/apps/purchasing/models.py index fc6fb4c29..015ca492b 100644 --- a/apps/purchasing/models.py +++ b/apps/purchasing/models.py @@ -73,14 +73,14 @@ class PurchaseOrder(models.Model): id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) supplier = models.ForeignKey( - "client.Client", + "company.Company", on_delete=models.PROTECT, related_name="purchase_orders", null=True, blank=True, ) pickup_address = models.ForeignKey( - "client.SupplierPickupAddress", + "company.SupplierPickupAddress", on_delete=models.SET_NULL, null=True, blank=True, diff --git a/apps/purchasing/serializers.py b/apps/purchasing/serializers.py index e869b7010..270e33f6a 100644 --- a/apps/purchasing/serializers.py +++ b/apps/purchasing/serializers.py @@ -2,7 +2,7 @@ from rest_framework import serializers -from apps.client.serializers import SupplierPickupAddressSerializer +from apps.company.serializers import SupplierPickupAddressSerializer from apps.job.models import Job from apps.job.models.costing import CostLine from apps.job.serializers.costing_serializer import CostLineSerializer @@ -62,10 +62,10 @@ class SupplierSearchResponseSerializer(serializers.Serializer): class JobForPurchasingSerializer(serializers.ModelSerializer): """Serializer for Job model in purchasing contexts.""" - client_name = serializers.CharField( - source="client.name", + company_name = serializers.CharField( + source="company.name", read_only=True, - default="No Client", + default="No Company", ) is_stock_holding = serializers.SerializerMethodField() job_display_name = serializers.SerializerMethodField() @@ -83,7 +83,7 @@ class Meta: "id", "job_number", "name", - "client_name", + "company_name", "status", "is_stock_holding", "job_display_name", @@ -97,8 +97,8 @@ class PurchaseOrderLineSerializer(serializers.ModelSerializer): job_number = serializers.IntegerField( source="job.job_number", read_only=True, allow_null=True ) - client_name = serializers.CharField( - source="job.client.name", read_only=True, allow_null=True + company_name = serializers.CharField( + source="job.company.name", read_only=True, allow_null=True ) job_name = serializers.CharField(source="job.name", read_only=True, allow_null=True) times_used = serializers.SerializerMethodField() @@ -108,13 +108,13 @@ class Meta: fields = PurchaseOrderLine.PURCHASEORDERLINE_API_FIELDS + [ "job_id", "job_number", - "client_name", + "company_name", "job_name", "times_used", ] def get_times_used(self, obj: PurchaseOrderLine) -> int: - """Always emit a numeric usage count for generated client validation.""" + """Always emit a numeric usage count for generated company validation.""" if not obj.item_code: return 0 @@ -220,7 +220,7 @@ class PurchaseOrderJobSerializer(serializers.Serializer): job_number = serializers.CharField() name = serializers.CharField() - client = serializers.CharField(allow_blank=True) + company = serializers.CharField(allow_blank=True) class PurchaseOrderListSerializer(serializers.Serializer): @@ -370,7 +370,7 @@ class Meta: fields = Stock.STOCK_API_FIELDS + ["job_id", "times_used"] def get_times_used(self, obj: Stock) -> int: - """Always emit a numeric usage count for generated client validation.""" + """Always emit a numeric usage count for generated company validation.""" return int(getattr(obj, "times_used", 0) or 0) diff --git a/apps/purchasing/services/purchasing_rest_service.py b/apps/purchasing/services/purchasing_rest_service.py index cbca2f7cd..9d46c04e3 100644 --- a/apps/purchasing/services/purchasing_rest_service.py +++ b/apps/purchasing/services/purchasing_rest_service.py @@ -13,7 +13,7 @@ from django.shortcuts import get_object_or_404 from django.utils import timezone -from apps.client.models import Supplier, SupplierPickupAddress +from apps.company.models import Supplier, SupplierPickupAddress from apps.job.models.costing import CostLine from apps.job.models.job import Job from apps.purchasing.etag import generate_po_etag, normalize_etag @@ -293,7 +293,7 @@ def _process_field( def list_purchase_orders() -> List[Dict[str, Any]]: pos = ( PurchaseOrder.objects.select_related("supplier", "created_by") - .prefetch_related("po_lines__job__client") + .prefetch_related("po_lines__job__company") .order_by("-created_at") ) result = [] @@ -305,7 +305,7 @@ def list_purchase_orders() -> List[Dict[str, Any]]: seen_jobs[line.job.id] = { "job_number": str(line.job.job_number), "name": line.job.name, - "client": line.job.client.name if line.job.client else "", + "company": line.job.company.name if line.job.company else "", } jobs = sorted(seen_jobs.values(), key=lambda j: j["job_number"]) @@ -357,7 +357,7 @@ def create_purchase_order( elif supplier: # Auto-select primary address if supplier is set and no address specified pickup_address = SupplierPickupAddress.objects.filter( - client=supplier, is_primary=True, is_active=True + company=supplier, is_primary=True, is_active=True ).first() order_date = data.get("order_date") diff --git a/apps/purchasing/services/quote_to_po_service.py b/apps/purchasing/services/quote_to_po_service.py index ff3eca303..3bfcfb840 100644 --- a/apps/purchasing/services/quote_to_po_service.py +++ b/apps/purchasing/services/quote_to_po_service.py @@ -17,7 +17,7 @@ from pydantic import BaseModel, ConfigDict, Field, field_validator from rapidfuzz import fuzz, process -from apps.client.models import Client +from apps.company.models import Company from apps.job.enums import MetalType from apps.purchasing.models import ( PurchaseOrder, @@ -102,7 +102,7 @@ def normalize(s: str) -> str: ) # lower, remove extra whitespace, preserve everything else -def fuzzy_find_supplier(supplier_name: str) -> tuple[Client | None, str]: +def fuzzy_find_supplier(supplier_name: str) -> tuple[Company | None, str]: """ Find a supplier in the database using fuzzy matching. @@ -116,7 +116,7 @@ def fuzzy_find_supplier(supplier_name: str) -> tuple[Client | None, str]: raise ValueError("Supplier name must be a non-empty string") # Get all suppliers - suppliers = Client.objects.all() + suppliers = Company.objects.all() # Extract supplier names and create a mapping from name to supplier object supplier_names = [s.name for s in suppliers] diff --git a/apps/purchasing/services/supplier_search_service.py b/apps/purchasing/services/supplier_search_service.py index 3d1f4ec5e..a5a67b433 100644 --- a/apps/purchasing/services/supplier_search_service.py +++ b/apps/purchasing/services/supplier_search_service.py @@ -12,7 +12,7 @@ from django.db.models import Count, OuterRef, Q, Subquery from django.utils import timezone -from apps.client.models import Client, ClientContactMethod, SupplierSearchAlias +from apps.company.models import ClientContactMethod, Company, SupplierSearchAlias if TYPE_CHECKING: from django_stubs_ext import WithAnnotations @@ -26,7 +26,7 @@ class _SupplierAnnotations(TypedDict): - """Queryset annotations list_suppliers() adds; not Client model fields.""" + """Queryset annotations list_suppliers() adds; not Company model fields.""" recent_purchase_count: int primary_phone: str | None @@ -36,12 +36,12 @@ class _SupplierAnnotations(TypedDict): # Only the type checker evaluates this alias (all annotations in this # module are strings via __future__ annotations), so the dev-only # django_stubs_ext dependency is never imported at runtime. - _AnnotatedSupplier = WithAnnotations[Client, _SupplierAnnotations] + _AnnotatedSupplier = WithAnnotations[Company, _SupplierAnnotations] @dataclass(frozen=True) class _CandidateScore: - client: _AnnotatedSupplier + company: _AnnotatedSupplier score: float name_score: float recent_purchase_count: int @@ -138,20 +138,20 @@ def _supplier_candidate_filter(query: str, query_norm: str) -> Q: def _format_supplier( - client: _AnnotatedSupplier, score: _CandidateScore + company: _AnnotatedSupplier, score: _CandidateScore ) -> dict[str, Any]: return { - "id": str(client.id), - "name": client.name, - "email": client.email or "", + "id": str(company.id), + "name": company.name, + "email": company.email or "", # The Subquery annotation yields None for suppliers with no phone # method; the API contract is a plain string. - "phone": client.primary_phone or "", - "address": client.address or "", - "is_account_customer": client.is_account_customer, - "is_supplier": client.is_supplier, - "allow_jobs": client.allow_jobs, - "xero_contact_id": client.xero_contact_id or "", + "phone": company.primary_phone or "", + "address": company.address or "", + "is_account_customer": company.is_account_customer, + "is_supplier": company.is_supplier, + "allow_jobs": company.allow_jobs, + "xero_contact_id": company.xero_contact_id or "", "last_invoice_date": None, "total_spend": "$0.00", "recent_purchase_count": score.recent_purchase_count, @@ -175,7 +175,7 @@ def list_suppliers( cutoff = timezone.localdate() - timedelta(days=RECENT_PURCHASE_WINDOW_DAYS) queryset = ( - Client.objects.filter(xero_archived=False, merged_into__isnull=True) + Company.objects.filter(xero_archived=False, merged_into__isnull=True) .annotate( recent_purchase_count=Count( "purchase_orders", @@ -185,7 +185,7 @@ def list_suppliers( ), primary_phone=Subquery( ClientContactMethod.objects.filter( - client=OuterRef("pk"), + company=OuterRef("pk"), method_type=ClientContactMethod.MethodType.PHONE, ) .order_by("-is_primary", "label", "value") @@ -213,7 +213,7 @@ def list_suppliers( ] scores = [ _CandidateScore( - client=supplier, + company=supplier, score=float(supplier.recent_purchase_count), name_score=0.0, recent_purchase_count=supplier.recent_purchase_count, @@ -229,15 +229,15 @@ def list_suppliers( _supplier_candidate_filter(query, query_norm) ).distinct() suppliers = list(queryset) - aliases_by_client_id: dict[Any, list[SupplierSearchAlias]] = { + aliases_by_company_id: dict[Any, list[SupplierSearchAlias]] = { supplier.id: [] for supplier in suppliers } aliases = SupplierSearchAlias.objects.filter( is_active=True, - client_id__in=aliases_by_client_id, - ).only("id", "client_id", "alias") + company_id__in=aliases_by_company_id, + ).only("id", "company_id", "alias") for alias in aliases: - aliases_by_client_id[alias.client_id].append(alias) + aliases_by_company_id[alias.company_id].append(alias) for supplier in suppliers: candidate_terms = [ @@ -246,7 +246,7 @@ def list_suppliers( ] candidate_terms.extend( term - for alias in aliases_by_client_id[supplier.id] + for alias in aliases_by_company_id[supplier.id] for term in ( normalize_supplier_phrase(alias.alias), _normalize_literal_phrase(alias.alias), @@ -260,7 +260,7 @@ def list_suppliers( purchase_boost = min(recent_purchase_count, 50) * 5.0 scores.append( _CandidateScore( - client=supplier, + company=supplier, score=name_score + purchase_boost, name_score=name_score, recent_purchase_count=recent_purchase_count, @@ -274,7 +274,7 @@ def list_suppliers( -score.score, -score.name_score, -score.recent_purchase_count, - score.client.name.lower(), + score.company.name.lower(), ) ) total_count = len(scores) @@ -283,7 +283,7 @@ def list_suppliers( total_pages = math.ceil(total_count / page_size) if total_count else 0 return { - "results": [_format_supplier(score.client, score) for score in scores], + "results": [_format_supplier(score.company, score) for score in scores], "count": total_count, "page": page, "page_size": page_size, diff --git a/apps/purchasing/tests/test_purchase_order_line_usage.py b/apps/purchasing/tests/test_purchase_order_line_usage.py index a91c19c8d..3793f63aa 100644 --- a/apps/purchasing/tests/test_purchase_order_line_usage.py +++ b/apps/purchasing/tests/test_purchase_order_line_usage.py @@ -16,7 +16,7 @@ @pytest.fixture def company_defaults(db: None) -> None: # Job.save -> generate_job_number -> CompanyDefaults.get_solo(); the - # singleton cannot be lazily created (shop_client is NOT NULL). + # singleton cannot be lazily created (shop_company is NOT NULL). call_command("loaddata", "company_defaults") diff --git a/apps/purchasing/tests/test_quote_to_po_service.py b/apps/purchasing/tests/test_quote_to_po_service.py index 3e72743c0..29fe2b57f 100644 --- a/apps/purchasing/tests/test_quote_to_po_service.py +++ b/apps/purchasing/tests/test_quote_to_po_service.py @@ -10,7 +10,7 @@ from django.conf import settings from django.utils import timezone -from apps.client.models import Client +from apps.company.models import Company from apps.job.enums import MetalType from apps.purchasing.models import PurchaseOrder, PurchaseOrderSupplierQuote from apps.purchasing.services.quote_to_po_service import ( @@ -40,7 +40,7 @@ def test_extract_uses_sdk_and_parses_response( anthropic_provider: AIProvider, tmp_path: Path ) -> None: """SDK path: prompt + file go through messages.create, JSON response parses.""" - Client.objects.create( + Company.objects.create( name="Acme Metals", xero_contact_id="xero-contact-1", xero_last_modified=timezone.now(), diff --git a/apps/purchasing/tests/test_serializers.py b/apps/purchasing/tests/test_serializers.py index b558a843a..cb7c48b51 100644 --- a/apps/purchasing/tests/test_serializers.py +++ b/apps/purchasing/tests/test_serializers.py @@ -1,6 +1,6 @@ from django.utils import timezone -from apps.client.models import Client +from apps.company.models import Company from apps.job.models import Job from apps.purchasing.models import PurchaseOrder from apps.purchasing.serializers import ( @@ -13,13 +13,13 @@ class JobForPurchasingSerializerTests(BaseTestCase): def test_client_name_uses_related_client_name(self): - client = Client.objects.create( - name="Serializer Client", + company = Company.objects.create( + name="Serializer Company", xero_last_modified=timezone.now(), ) job = Job.objects.create( name="Serializer Job", - client=client, + company=company, created_by=self.test_staff, default_xero_pay_item=XeroPayItem.get_ordinary_time(), staff=self.test_staff, @@ -27,12 +27,12 @@ def test_client_name_uses_related_client_name(self): data = JobForPurchasingSerializer(job).data - self.assertEqual(data["client_name"], "Serializer Client") + self.assertEqual(data["company_name"], "Serializer Company") class PurchaseOrderDetailSerializerTests(BaseTestCase): def test_related_display_fields_use_related_objects(self): - supplier = Client.objects.create( + supplier = Company.objects.create( name="Serializer Supplier", xero_contact_id="00000000-0000-0000-0000-000000000001", xero_last_modified=timezone.now(), diff --git a/apps/purchasing/tests/test_stock_fts_search.py b/apps/purchasing/tests/test_stock_fts_search.py index 48a64f879..142bd9b77 100644 --- a/apps/purchasing/tests/test_stock_fts_search.py +++ b/apps/purchasing/tests/test_stock_fts_search.py @@ -1,6 +1,6 @@ """Postgres FTS regression coverage for stock search (Trello #150). -Same bug class as the client search: `description.includes(query)` (and the +Same bug class as the company search: `description.includes(query)` (and the backend's `description__icontains`) matched the query as a contiguous substring, so `"5mm stainless"` could not find `"stainless 5mm sheet"`. These tests pin token-order independence, phrase ranking, the structured @@ -16,7 +16,7 @@ from rest_framework.test import APIClient from apps.accounts.models import Staff -from apps.client.models import Client +from apps.company.models import Company from apps.job.models import Job from apps.job.models.costing import CostLine from apps.purchasing.models import Stock @@ -282,13 +282,13 @@ def test_galvanised_sheet_queries_surface_expected_material(db, query): class TestStockSearchHistoricalRanking(BaseTestCase): def setUp(self): super().setUp() - self.client_obj = Client.objects.create( - name="Stock Search Client", + self.client_obj = Company.objects.create( + name="Stock Search Company", xero_last_modified=timezone.now(), ) def _create_job(self, name: str) -> Job: - job = Job(client=self.client_obj, name=name) + job = Job(company=self.client_obj, name=name) job.save(staff=self.test_staff) return job @@ -390,16 +390,16 @@ def test_view_returns_search_results_via_http(auth_api, office_staff, db): """ _stock(description="Stainless 5mm sheet", item_code="STK-001") _stock(description="Aluminium bar", item_code="STK-002") - client = Client.objects.create( - name="Stock Search View Client", + company = Company.objects.create( + name="Stock Search View Company", xero_last_modified=timezone.now(), ) - shop_client = Client.objects.create( - name="Stock Search Shop Client", + shop_company = Company.objects.create( + name="Stock Search Shop Company", xero_last_modified=timezone.now(), ) - CompanyDefaults.objects.create(company_name="Test Co", shop_client=shop_client) - job = Job(client=client, name="Stock Search View Job") + CompanyDefaults.objects.create(company_name="Test Co", shop_company=shop_company) + job = Job(company=company, name="Stock Search View Job") job.save(staff=office_staff) CostLine.objects.create( cost_set=job.latest_actual, diff --git a/apps/purchasing/tests/test_stock_metadata_tasks.py b/apps/purchasing/tests/test_stock_metadata_tasks.py index baa1f78b9..04e2e2c47 100644 --- a/apps/purchasing/tests/test_stock_metadata_tasks.py +++ b/apps/purchasing/tests/test_stock_metadata_tasks.py @@ -13,7 +13,7 @@ from rest_framework.test import APIClient from apps.accounts.models import Staff -from apps.client.models import Client +from apps.company.models import Company from apps.job.models import Job from apps.purchasing.models import PurchaseOrder, PurchaseOrderLine, Stock from apps.purchasing.services.delivery_receipt_service import ( @@ -385,7 +385,7 @@ def test_stock_viewset_create_enqueues_metadata_parse() -> None: @pytest.fixture def company_defaults(db: None) -> None: # Job.save -> generate_job_number -> CompanyDefaults.get_solo(); the - # singleton cannot be lazily created (shop_client is NOT NULL). + # singleton cannot be lazily created (shop_company is NOT NULL). call_command("loaddata", "company_defaults") @@ -394,11 +394,11 @@ def test_delivery_receipt_stock_creation_enqueues_metadata_parse( company_defaults: None, ) -> None: staff = _staff() - client = Client.objects.create( - name="Receipt Stock Client", + company = Company.objects.create( + name="Receipt Stock Company", xero_last_modified=timezone.now(), ) - job = Job.objects.create(client=client, name="Receipt Stock Job", staff=staff) + job = Job.objects.create(company=company, name="Receipt Stock Job", staff=staff) po = PurchaseOrder.objects.create(po_number="PO-STOCK-META") line = PurchaseOrderLine.objects.create( purchase_order=po, diff --git a/apps/purchasing/tests/test_supplier_search.py b/apps/purchasing/tests/test_supplier_search.py index d2cbca92b..f1a91b687 100644 --- a/apps/purchasing/tests/test_supplier_search.py +++ b/apps/purchasing/tests/test_supplier_search.py @@ -10,7 +10,7 @@ from rest_framework.test import APIClient from apps.accounts.models import Staff -from apps.client.models import Client, SupplierSearchAlias +from apps.company.models import Company, SupplierSearchAlias from apps.purchasing.models import PurchaseOrder from apps.purchasing.services.supplier_search_service import ( _name_match_score, @@ -25,10 +25,10 @@ def _make_client(name: str, **overrides): "xero_last_modified": timezone.now(), } defaults.update(overrides) - return Client.objects.create(**defaults) + return Company.objects.create(**defaults) -def _make_po(supplier: Client, *, days_ago: int = 0, status: str = "draft"): +def _make_po(supplier: Company, *, days_ago: int = 0, status: str = "draft"): sequence = PurchaseOrder.objects.count() + 1 return PurchaseOrder.objects.create( supplier=supplier, @@ -114,7 +114,7 @@ def test_s_and_t_ranks_s_t_before_t_s(): @pytest.mark.django_db def test_supplier_alias_matches_attached_client(): supplier = _make_client("S&T Stainless Limited") - SupplierSearchAlias.objects.create(client=supplier, alias="Steel and Tube") + SupplierSearchAlias.objects.create(company=supplier, alias="Steel and Tube") assert _names("Steel and Tube")[0] == "S&T Stainless Limited" @@ -187,24 +187,24 @@ def test_supplier_alias_api_lists_creates_and_deactivates_alias(auth_api): give suppliers nicknames — searching large supplier lists by legal name alone becomes unusable. """ - client = _make_client("S&T Stainless Limited") + company = _make_client("S&T Stainless Limited") create_resp = auth_api.post( - f"/api/clients/{client.id}/supplier-aliases/", + f"/api/companies/{company.id}/supplier-aliases/", {"alias": "Steel and Tube"}, format="json", ) assert create_resp.status_code == 201, create_resp.content alias_id = create_resp.json()["id"] - list_resp = auth_api.get(f"/api/clients/{client.id}/supplier-aliases/") + list_resp = auth_api.get(f"/api/companies/{company.id}/supplier-aliases/") assert list_resp.status_code == 200, list_resp.content assert [row["alias"] for row in list_resp.json()] == ["Steel and Tube"] - delete_resp = auth_api.delete(f"/api/clients/supplier-aliases/{alias_id}/") + delete_resp = auth_api.delete(f"/api/companies/supplier-aliases/{alias_id}/") assert delete_resp.status_code == 204, delete_resp.content - list_resp = auth_api.get(f"/api/clients/{client.id}/supplier-aliases/") + list_resp = auth_api.get(f"/api/companies/{company.id}/supplier-aliases/") assert list_resp.status_code == 200, list_resp.content assert list_resp.json() == [] @@ -216,7 +216,7 @@ def test_supplier_search_view_returns_alias_match(auth_api): silently stops working. """ supplier = _make_client("S&T Stainless Limited") - SupplierSearchAlias.objects.create(client=supplier, alias="Steel and Tube") + SupplierSearchAlias.objects.create(company=supplier, alias="Steel and Tube") resp = auth_api.get("/api/purchasing/suppliers/search/", {"q": "Steel and Tube"}) diff --git a/apps/purchasing/views/purchasing_rest_views.py b/apps/purchasing/views/purchasing_rest_views.py index a6d2bf79a..f82229e81 100644 --- a/apps/purchasing/views/purchasing_rest_views.py +++ b/apps/purchasing/views/purchasing_rest_views.py @@ -100,7 +100,7 @@ def _set_etag(self, response, etag: str): class SupplierPriceStatusAPIView(APIView): """Return latest price upload status per supplier. - Minimal-impact: read-only query over existing Client and SupplierPriceList + Minimal-impact: read-only query over existing Company and SupplierPriceList models. No migrations required. """ @@ -112,7 +112,7 @@ class SupplierPriceStatusAPIView(APIView): ) def get(self, request): try: - from apps.client.models import Client + from apps.company.models import Company from apps.quoting.models import SupplierPriceList # Subquery to get the latest upload per supplier @@ -121,7 +121,7 @@ def get(self, request): ).order_by("-uploaded_at") suppliers = ( - Client.objects.filter( + Company.objects.filter( id__in=SupplierPriceList.objects.values("supplier_id").distinct() ) .annotate( @@ -208,7 +208,7 @@ def get(self, request): # Get active jobs for purchasing (archived jobs don't need POs) jobs = ( Job.objects.exclude(status="archived") - .select_related("client") + .select_related("company") .order_by("job_number") ) @@ -259,7 +259,7 @@ def get(self, request): ] ) .exclude(status__in=excluded_statuses) - .select_related("client") + .select_related("company") .prefetch_related("cost_sets") .order_by("job_number") ) @@ -296,7 +296,9 @@ def get(self, request): "id": str(job.id), "job_number": job.job_number, "name": job.name, - "client_name": job.client.name if job.client else "No Client", + "company_name": ( + job.company.name if job.company else "No Company" + ), "status": job.status, "cost_set_id": ( str(actual_cost_set.id) if actual_cost_set else None @@ -439,7 +441,7 @@ def get(self, request, po_id): po = get_object_or_404(queryset, id=po_id) po.detail_lines = list( PurchaseOrderLine.objects.filter(purchase_order=po).select_related( - "job__client" + "job__company" ) ) item_codes = [line.item_code for line in po.detail_lines if line.item_code] diff --git a/apps/purchasing/views/stock_search_rest_view.py b/apps/purchasing/views/stock_search_rest_view.py index 9ee17cded..c511dd3d2 100644 --- a/apps/purchasing/views/stock_search_rest_view.py +++ b/apps/purchasing/views/stock_search_rest_view.py @@ -3,7 +3,7 @@ Mirrors ClientSearchRestView in shape: paginated list with an optional `q` parameter that runs Postgres FTS over the stock table. The 3-character -minimum-query guard matches client search. +minimum-query guard matches company search. """ import logging diff --git a/apps/quoting/__init__.py b/apps/quoting/__init__.py index 458ad02eb..351641e8f 100644 --- a/apps/quoting/__init__.py +++ b/apps/quoting/__init__.py @@ -47,7 +47,7 @@ # EXCLUDED IMPORTS - These contain problematic dependencies that cause circular imports # Import these directly where needed using: -# from .admin import ClientAdmin +# from .admin import CompanyAdmin # from .admin import SupplierAdmin # from .admin import SupplierCredentialAdmin # from .admin import SupplierCredentialAdminForm diff --git a/apps/quoting/admin.py b/apps/quoting/admin.py index 78e99b2a3..f4b44d47b 100644 --- a/apps/quoting/admin.py +++ b/apps/quoting/admin.py @@ -6,7 +6,7 @@ from django.forms.models import BaseInlineFormSet from django.http import HttpRequest -from apps.client.models import Client, Supplier +from apps.company.models import Company, Supplier from apps.quoting.models import SupplierCredential, SupplierScraperConfig @@ -80,7 +80,7 @@ def add_fields(self, form: forms.BaseForm, index: int | None) -> None: ) -class SupplierCredentialInline(admin.TabularInline[SupplierCredential, Client]): +class SupplierCredentialInline(admin.TabularInline[SupplierCredential, Company]): model = SupplierCredential form = SupplierCredentialAdminForm extra = 0 @@ -95,7 +95,7 @@ class SupplierCredentialInline(admin.TabularInline[SupplierCredential, Client]): ) -class SupplierScraperConfigInline(admin.StackedInline[SupplierScraperConfig, Client]): +class SupplierScraperConfigInline(admin.StackedInline[SupplierScraperConfig, Company]): model = SupplierScraperConfig form = SupplierScraperConfigAdminForm formset = SupplierScraperConfigInlineFormSet @@ -104,15 +104,15 @@ class SupplierScraperConfigInline(admin.StackedInline[SupplierScraperConfig, Cli fields = ("scraper_class", "portal_url", "is_enabled", "active_credential") -class ClientAdmin(admin.ModelAdmin[Client]): +class CompanyAdmin(admin.ModelAdmin[Company]): list_display = ("name", "is_supplier", "email") list_filter = ("is_supplier",) search_fields = ("name", "email") inlines = (SupplierCredentialInline, SupplierScraperConfigInline) -class SupplierAdmin(ClientAdmin): - def get_queryset(self, request: HttpRequest) -> QuerySet[Client]: +class SupplierAdmin(CompanyAdmin): + def get_queryset(self, request: HttpRequest) -> QuerySet[Company]: return super().get_queryset(request).filter(is_supplier=True) @@ -132,5 +132,5 @@ class SupplierScraperConfigAdmin(admin.ModelAdmin[SupplierScraperConfig]): search_fields = ("supplier__name", "scraper_class", "portal_url") -admin.site.register(Client, ClientAdmin) +admin.site.register(Company, CompanyAdmin) admin.site.register(Supplier, SupplierAdmin) diff --git a/apps/quoting/mcp.py b/apps/quoting/mcp.py index 147e91bba..1c1523a28 100644 --- a/apps/quoting/mcp.py +++ b/apps/quoting/mcp.py @@ -3,7 +3,7 @@ from django.db.models import Q from mcp_server import MCPToolset, ModelQueryToolset -from apps.client.models import Client +from apps.company.models import Company from apps.job.models import Job from .models import ScrapeJob, SupplierPriceList, SupplierProduct @@ -102,7 +102,7 @@ def create_quote_estimate( # Create a basic quote structure quote_info = [ f"Quote Estimate for Job: {job.name}", - f"Client: {job.client.name}", + f"Company: {job.company.name}", f"Materials requested: {materials}", "", ] @@ -147,11 +147,11 @@ def create_quote_estimate( def get_supplier_status(self, supplier_name: str = None) -> str: """Get status of supplier scraping and price lists""" if supplier_name: - suppliers = Client.objects.filter( + suppliers = Company.objects.filter( name__icontains=supplier_name, is_supplier=True ) else: - suppliers = Client.objects.filter(is_supplier=True) + suppliers = Company.objects.filter(is_supplier=True) results = ["Supplier Status Report:"] diff --git a/apps/quoting/migrations/0001_baseline.py b/apps/quoting/migrations/0001_baseline.py index 3bf913acc..5b981f73d 100644 --- a/apps/quoting/migrations/0001_baseline.py +++ b/apps/quoting/migrations/0001_baseline.py @@ -14,7 +14,7 @@ class Migration(migrations.Migration): initial = True dependencies = [ - ("client", "0001_baseline"), + ("company", "0001_baseline"), migrations.swappable_dependency(settings.AUTH_USER_MODEL), ] @@ -53,7 +53,7 @@ class Migration(migrations.Migration): models.ForeignKey( on_delete=django.db.models.deletion.PROTECT, related_name="scrape_jobs", - to="client.client", + to="company.client", ), ), ], @@ -115,7 +115,7 @@ class Migration(migrations.Migration): models.ForeignKey( on_delete=django.db.models.deletion.PROTECT, related_name="supplier_credentials", - to="client.client", + to="company.client", ), ), ], @@ -150,7 +150,7 @@ class Migration(migrations.Migration): models.ForeignKey( on_delete=django.db.models.deletion.PROTECT, related_name="price_lists", - to="client.client", + to="company.client", ), ), ], @@ -357,7 +357,7 @@ class Migration(migrations.Migration): models.ForeignKey( on_delete=django.db.models.deletion.PROTECT, related_name="scraped_products", - to="client.client", + to="company.client", ), ), ], @@ -392,7 +392,7 @@ class Migration(migrations.Migration): models.OneToOneField( on_delete=django.db.models.deletion.PROTECT, related_name="scraper_config", - to="client.client", + to="company.client", ), ), ], diff --git a/apps/quoting/models.py b/apps/quoting/models.py index 67444a7ab..c7fc65544 100644 --- a/apps/quoting/models.py +++ b/apps/quoting/models.py @@ -8,7 +8,7 @@ EncryptedCharField, ) -from apps.client.models import Client +from apps.company.models import Company from apps.job.enums import MetalType from apps.purchasing.models import Stock @@ -24,7 +24,7 @@ class CredentialType(models.TextChoices): id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) supplier = models.ForeignKey( - Client, on_delete=models.PROTECT, related_name="supplier_credentials" + Company, on_delete=models.PROTECT, related_name="supplier_credentials" ) label = models.CharField(max_length=255) credential_type = models.CharField( @@ -99,7 +99,7 @@ class SupplierScraperConfig(models.Model): id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) supplier = models.OneToOneField( - Client, on_delete=models.PROTECT, related_name="scraper_config" + Company, on_delete=models.PROTECT, related_name="scraper_config" ) scraper_class = models.CharField(max_length=255, db_index=True) portal_url = models.URLField(max_length=1000) @@ -151,7 +151,7 @@ class SupplierProduct(models.Model): id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) supplier = models.ForeignKey( - Client, on_delete=models.PROTECT, related_name="scraped_products" + Company, on_delete=models.PROTECT, related_name="scraped_products" ) price_list = models.ForeignKey( "SupplierPriceList", on_delete=models.CASCADE, related_name="products" @@ -284,7 +284,7 @@ class SupplierPriceList(models.Model): id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) supplier = models.ForeignKey( - Client, on_delete=models.PROTECT, related_name="price_lists" + Company, on_delete=models.PROTECT, related_name="price_lists" ) file_name = models.CharField( max_length=255, help_text="Original filename of the uploaded price list" @@ -313,7 +313,7 @@ class ScrapeJob(models.Model): id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) supplier = models.ForeignKey( - Client, on_delete=models.PROTECT, related_name="scrape_jobs" + Company, on_delete=models.PROTECT, related_name="scrape_jobs" ) status = models.CharField(max_length=20, choices=STATUS_CHOICES, default="running") started_at = models.DateTimeField(default=timezone.now) diff --git a/apps/quoting/services/pdf_data_validation.py b/apps/quoting/services/pdf_data_validation.py index 4d84f286b..f50f21800 100644 --- a/apps/quoting/services/pdf_data_validation.py +++ b/apps/quoting/services/pdf_data_validation.py @@ -2,7 +2,7 @@ import re from typing import Any, Dict, List, Optional, Tuple -from apps.client.models import Client +from apps.company.models import Company from apps.quoting.models import SupplierProduct logger = logging.getLogger(__name__) @@ -283,8 +283,8 @@ def check_duplicates( Dictionary with 'duplicates' and 'new' product lists """ try: - supplier = Client.objects.get(name=supplier_name) - except Client.DoesNotExist: + supplier = Company.objects.get(name=supplier_name) + except Company.DoesNotExist: # If supplier doesn't exist, all products are new return {"duplicates": [], "new": products} diff --git a/apps/quoting/services/pdf_import_service.py b/apps/quoting/services/pdf_import_service.py index 3e930d1d4..5b2f33da9 100644 --- a/apps/quoting/services/pdf_import_service.py +++ b/apps/quoting/services/pdf_import_service.py @@ -4,7 +4,7 @@ from django.db import transaction from django.utils import timezone -from apps.client.models import Client +from apps.company.models import Company from apps.quoting.models import SupplierPriceList, SupplierProduct from apps.quoting.services.product_parser import create_mapping_record @@ -28,35 +28,35 @@ def __init__(self): "errors": [], } - def create_or_get_supplier(self, supplier_name: str) -> Tuple[Client, bool]: + def create_or_get_supplier(self, supplier_name: str) -> Tuple[Company, bool]: """ - Create or retrieve supplier client record. + Create or retrieve supplier company record. Args: supplier_name: Name of the supplier Returns: - Tuple of (Client instance, was_created boolean) + Tuple of (Company instance, was_created boolean) """ try: - supplier = Client.objects.get(name=supplier_name) + supplier = Company.objects.get(name=supplier_name) logger.info(f"Found existing supplier: {supplier.name} (ID: {supplier.id})") return supplier, False - except Client.DoesNotExist: + except Company.DoesNotExist: # Create new supplier - supplier = Client.objects.create( + supplier = Company.objects.create( name=supplier_name, is_supplier=True, xero_last_modified=timezone.now() ) logger.info(f"Created new supplier: {supplier.name} (ID: {supplier.id})") self.import_stats["supplier_created"] = True return supplier, True - def create_price_list(self, supplier: Client, filename: str) -> SupplierPriceList: + def create_price_list(self, supplier: Company, filename: str) -> SupplierPriceList: """ Create a new price list record for the supplier. Args: - supplier: Supplier client instance + supplier: Supplier company instance filename: Original filename of the uploaded PDF Returns: @@ -73,7 +73,7 @@ def create_price_list(self, supplier: Client, filename: str) -> SupplierPriceLis def import_products( self, products: List[Dict], - supplier: Client, + supplier: Company, price_list: SupplierPriceList, duplicate_strategy: str = "skip", ) -> Dict[str, int]: @@ -82,7 +82,7 @@ def import_products( Args: products: List of sanitized product dictionaries - supplier: Supplier client instance + supplier: Supplier company instance price_list: Price list instance duplicate_strategy: How to handle duplicates ("skip", "update", "create_new") @@ -155,7 +155,7 @@ def import_products( def _import_single_product( self, product_data: Dict, - supplier: Client, + supplier: Company, price_list: SupplierPriceList, duplicate_strategy: str, index: int, @@ -165,7 +165,7 @@ def _import_single_product( Args: product_data: Sanitized product data dictionary - supplier: Supplier client instance + supplier: Supplier company instance price_list: Price list instance duplicate_strategy: How to handle duplicates index: Product index for logging @@ -185,14 +185,14 @@ def _import_single_product( return self._create_new_product(product_data, supplier, price_list, index) def _find_existing_product( - self, product_data: Dict, supplier: Client + self, product_data: Dict, supplier: Company ) -> SupplierProduct: """ Find existing product by item_no or product_name. Args: product_data: Product data dictionary - supplier: Supplier client instance + supplier: Supplier company instance Returns: Existing SupplierProduct instance or None @@ -301,7 +301,7 @@ def _update_existing_product( def _create_new_product( self, product_data: Dict, - supplier: Client, + supplier: Company, price_list: SupplierPriceList, index: int, ) -> str: @@ -310,7 +310,7 @@ def _create_new_product( Args: product_data: Product data dictionary - supplier: Supplier client instance + supplier: Supplier company instance price_list: Price list instance index: Product index for logging @@ -360,8 +360,8 @@ def handle_duplicates( Dictionary with duplicate analysis results """ try: - supplier = Client.objects.get(name=supplier_name) - except Client.DoesNotExist: + supplier = Company.objects.get(name=supplier_name) + except Company.DoesNotExist: # No supplier exists, so no duplicates possible return { "duplicates_found": 0, diff --git a/apps/quoting/tests/test_pdf_data_validation.py b/apps/quoting/tests/test_pdf_data_validation.py index 2aa307e94..d650238a8 100644 --- a/apps/quoting/tests/test_pdf_data_validation.py +++ b/apps/quoting/tests/test_pdf_data_validation.py @@ -1,4 +1,4 @@ -from apps.client.models import Client +from apps.company.models import Company from apps.quoting.models import SupplierPriceList, SupplierProduct from apps.quoting.services.pdf_data_validation import PDFDataValidationService from apps.testing import BaseTestCase @@ -12,7 +12,7 @@ def setUp(self): self.service = PDFDataValidationService() # Create test supplier - self.supplier = Client.objects.create( + self.supplier = Company.objects.create( name="Test Supplier", is_supplier=True, xero_last_modified="2023-01-01T00:00:00Z", diff --git a/apps/quoting/tests/test_supplier_scraper_credentials.py b/apps/quoting/tests/test_supplier_scraper_credentials.py index af189d4da..a24294255 100644 --- a/apps/quoting/tests/test_supplier_scraper_credentials.py +++ b/apps/quoting/tests/test_supplier_scraper_credentials.py @@ -1,7 +1,7 @@ from django.core.exceptions import ValidationError from django.utils import timezone -from apps.client.models import Client +from apps.company.models import Company from apps.quoting.management.commands.run_scrapers import Command from apps.quoting.models import SupplierCredential, SupplierScraperConfig from apps.quoting.scrapers.steel_and_tube import SteelAndTubeScraper @@ -14,7 +14,7 @@ class DummyScraper: class SupplierScraperCredentialTests(BaseTestCase): def setUp(self) -> None: - self.supplier = Client.objects.create( + self.supplier = Company.objects.create( name="S&T Stainless Limited", is_supplier=True, xero_last_modified=timezone.now(), @@ -49,7 +49,7 @@ def test_username_password_requires_username_and_password(self) -> None: self.assertIn("password", context.exception.message_dict) def test_supplier_scraper_config_requires_same_supplier_credential(self) -> None: - other_supplier = Client.objects.create( + other_supplier = Company.objects.create( name="Other Supplier", is_supplier=True, xero_last_modified=timezone.now(), diff --git a/apps/quoting/tests_mcp.py b/apps/quoting/tests_mcp.py index 921904626..05e8251ef 100644 --- a/apps/quoting/tests_mcp.py +++ b/apps/quoting/tests_mcp.py @@ -3,7 +3,7 @@ Run with: python manage.py test apps.quoting.tests_mcp """ -from apps.client.models import Client +from apps.company.models import Company from apps.job.models import Job from apps.quoting.models import SupplierPriceList, SupplierProduct from apps.testing import BaseTestCase @@ -17,23 +17,23 @@ def setUp(self): self.tool = QuotingTool() # Create test supplier - self.supplier = Client.objects.create( + self.supplier = Company.objects.create( name="Test Steel Co", is_supplier=True, email="test@steelco.com", xero_last_modified="2024-01-01T00:00:00Z", ) - # Create test client and job - self.client_obj = Client.objects.create( - name="Test Client", - email="client@test.com", + # Create test company and job + self.company_obj = Company.objects.create( + name="Test Company", + email="company@test.com", xero_last_modified="2024-01-01T00:00:00Z", ) self.job = Job.objects.create( name="Test Job", - client=self.client_obj, + company=self.company_obj, description="Test metal work", staff=self.test_staff, ) @@ -95,7 +95,7 @@ def test_create_quote_estimate(self): ) self.assertIn("Quote Estimate for Job: Test Job", result) - self.assertIn("Client: Test Client", result) + self.assertIn("Company: Test Company", result) self.assertIn("steel sheet", result) self.assertIn("Labor estimate: 10.0 hours", result) @@ -131,7 +131,7 @@ def setUp(self): self.tool = SupplierProductQueryTool() # Create test data - self.supplier = Client.objects.create( + self.supplier = Company.objects.create( name="Query Test Supplier", is_supplier=True, xero_last_modified="2024-01-01T00:00:00Z", diff --git a/apps/testing.py b/apps/testing.py index 6c48386d8..a2a1f99d4 100644 --- a/apps/testing.py +++ b/apps/testing.py @@ -61,7 +61,7 @@ class BaseTestCase(TestCase): Base test case that loads required fixtures. The company_defaults fixture is required for most tests because: - - Job creation needs CompanyDefaults (shop client, wage rate) + - Job creation needs CompanyDefaults (shop company, wage rate) - XeroPayItem (Ordinary Time) must exist for time entries Provides ``self.test_staff`` — a generic Staff created once per TestCase diff --git a/apps/timesheet/management/commands/create_special_job.py b/apps/timesheet/management/commands/create_special_job.py index 5d1224264..9d9c8c1d9 100644 --- a/apps/timesheet/management/commands/create_special_job.py +++ b/apps/timesheet/management/commands/create_special_job.py @@ -44,9 +44,9 @@ def handle(self, *args, **options): if Job.objects.filter(name=name).exists(): raise CommandError(f"Job '{name}' already exists") - # Resolve shop client from CompanyDefaults + # Resolve shop company from CompanyDefaults defaults = CompanyDefaults.get_solo() - client = defaults.shop_client + company = defaults.shop_company # Resolve pay item pay_item = XeroPayItem.objects.filter( @@ -58,7 +58,7 @@ def handle(self, *args, **options): if options["dry_run"]: self.stdout.write(self.style.WARNING("DRY RUN — no changes made:")) self.stdout.write(f" Job: '{name}' (status=special)") - self.stdout.write(f" Client: {client.name}") + self.stdout.write(f" Company: {company.name}") self.stdout.write( f" Default pay item: {pay_item.name} ({pay_item.multiplier}x)" ) @@ -69,7 +69,7 @@ def handle(self, *args, **options): job = Job( name=name, status="special", - client=client, + company=company, contact=None, pricing_methodology="time_materials", speed_quality_tradeoff="normal", diff --git a/apps/timesheet/serializers/daily_timesheet_serializers.py b/apps/timesheet/serializers/daily_timesheet_serializers.py index efc18c251..d03c519b0 100644 --- a/apps/timesheet/serializers/daily_timesheet_serializers.py +++ b/apps/timesheet/serializers/daily_timesheet_serializers.py @@ -13,7 +13,7 @@ class JobBreakdownSerializer(serializers.Serializer): job_id = serializers.CharField() job_number = serializers.IntegerField() job_name = serializers.CharField() - client = serializers.CharField() + company = serializers.CharField() hours = serializers.FloatField() revenue = serializers.FloatField() cost = serializers.FloatField() diff --git a/apps/timesheet/serializers/modern_timesheet_serializers.py b/apps/timesheet/serializers/modern_timesheet_serializers.py index 96d7bbecb..6d8a1655d 100644 --- a/apps/timesheet/serializers/modern_timesheet_serializers.py +++ b/apps/timesheet/serializers/modern_timesheet_serializers.py @@ -19,8 +19,8 @@ class ModernTimesheetJobSerializer(serializers.ModelSerializer): """Serializer for jobs in timesheet context using modern CostSet system""" - client_name = serializers.CharField( - source="client.name", read_only=True, required=False, allow_null=True + company_name = serializers.CharField( + source="company.name", read_only=True, required=False, allow_null=True ) labour_rates = JobLabourRateSerializer(many=True, read_only=True) has_actual_costset = serializers.SerializerMethodField() @@ -43,7 +43,7 @@ class Meta: "id", "job_number", "name", - "client_name", + "company_name", "status", "labour_rates", "has_actual_costset", @@ -228,7 +228,7 @@ class WorkshopTimesheetEntrySerializer(serializers.Serializer): job_id = serializers.UUIDField(read_only=True) job_number = serializers.IntegerField(read_only=True) job_name = serializers.CharField(read_only=True) - client_name = serializers.CharField(read_only=True, allow_blank=True) + company_name = serializers.CharField(read_only=True, allow_blank=True) description = serializers.CharField(read_only=True, allow_blank=True) hours = serializers.DecimalField( max_digits=7, decimal_places=2, read_only=True, source="quantity" @@ -273,7 +273,7 @@ def to_representation(self, instance): "job_id": str(job.id) if job else None, "job_number": job.job_number if job else None, "job_name": job.name if job else "", - "client_name": job.client.name if job and job.client else "", + "company_name": job.company.name if job and job.company else "", "description": instance.desc or "", "hours": float(instance.quantity), "accounting_date": instance.accounting_date, diff --git a/apps/timesheet/services/daily_timesheet_service.py b/apps/timesheet/services/daily_timesheet_service.py index 26cc522d2..4caea5655 100644 --- a/apps/timesheet/services/daily_timesheet_service.py +++ b/apps/timesheet/services/daily_timesheet_service.py @@ -105,7 +105,7 @@ def _get_staff_timesheet_data( kind="time", staff=staff, accounting_date=target_date, - ).select_related("cost_set__job__client") + ).select_related("cost_set__job__company") logger.debug(f"Found {len(cost_lines)} cost lines for staff {staff.id}") @@ -239,7 +239,7 @@ def _get_job_breakdown(cls, cost_lines) -> List[Dict]: "job_id": job_id_str, "job_number": job_number, "job_name": job_name, - "client": job.client.name if job.client else "", + "company": job.company.name if job.company else "", "price_cap": job.price_cap, "hours": Decimal("0.0"), "revenue": Decimal("0.0"), diff --git a/apps/timesheet/tests/test_daily_timesheet_service.py b/apps/timesheet/tests/test_daily_timesheet_service.py index 78a0a99ee..0d2d1c5b8 100644 --- a/apps/timesheet/tests/test_daily_timesheet_service.py +++ b/apps/timesheet/tests/test_daily_timesheet_service.py @@ -5,7 +5,7 @@ from django.test.utils import CaptureQueriesContext from django.utils import timezone -from apps.client.models import Client +from apps.company.models import Company from apps.job.models import CostLine, Job, LabourSubtype from apps.testing import BaseTestCase from apps.timesheet.services.daily_timesheet_service import DailyTimesheetService @@ -16,8 +16,8 @@ class DailyTimesheetServiceTests(BaseTestCase): def setUp(self): super().setUp() self.target_date = date(2026, 5, 22) - self.client = Client.objects.create( - name="Daily Timesheet Client", + self.company = Company.objects.create( + name="Daily Timesheet Company", email="daily-timesheet@example.com", xero_last_modified=timezone.now(), ) @@ -25,7 +25,7 @@ def setUp(self): self.job = Job.objects.create( job_number=98766, name="Daily Timesheet Job", - client=self.client, + company=self.company, default_xero_pay_item=self.pay_item, staff=self.test_staff, ) @@ -50,16 +50,16 @@ def setUp(self): entry_seq=1, ) - def _create_job(self, *, job_number: int, client_name: str) -> Job: - client = Client.objects.create( - name=client_name, + def _create_job(self, *, job_number: int, company_name: str) -> Job: + company = Company.objects.create( + name=company_name, email=f"{job_number}@example.com", xero_last_modified=timezone.now(), ) job = Job.objects.create( job_number=job_number, name=f"Daily Timesheet Job {job_number}", - client=client, + company=company, default_xero_pay_item=self.pay_item, staff=self.test_staff, ) @@ -88,10 +88,10 @@ def _create_time_line(self, job: Job, *, hours: str, entry_seq: int) -> CostLine ) def test_staff_timesheet_data_does_not_lazy_load_job_breakdown_relations(self): - """Catches N+1 DB access while daily timesheets serialize job/client data.""" + """Catches N+1 DB access while daily timesheets serialize job/company data.""" other_job = self._create_job( job_number=98767, - client_name="Second Daily Timesheet Client", + company_name="Second Daily Timesheet Company", ) self._create_time_line(other_job, hours="3.000", entry_seq=2) @@ -105,14 +105,14 @@ def test_staff_timesheet_data_does_not_lazy_load_job_breakdown_relations(self): for query in captured if 'FROM "job_costset"' in query["sql"] or 'FROM "job_job"' in query["sql"] - or 'FROM "client_client"' in query["sql"] + or 'FROM "company_company"' in query["sql"] ] self.assertEqual(direct_relation_queries, []) self.assertEqual( - [(row["client"], row["hours"]) for row in staff_data["job_breakdown"]], + [(row["company"], row["hours"]) for row in staff_data["job_breakdown"]], [ - ("Second Daily Timesheet Client", 3.0), - ("Daily Timesheet Client", 2.0), + ("Second Daily Timesheet Company", 3.0), + ("Daily Timesheet Company", 2.0), ], ) diff --git a/apps/timesheet/tests/test_jobs_api_filter.py b/apps/timesheet/tests/test_jobs_api_filter.py index 4de3c14ad..1b2c43c65 100644 --- a/apps/timesheet/tests/test_jobs_api_filter.py +++ b/apps/timesheet/tests/test_jobs_api_filter.py @@ -11,7 +11,7 @@ from rest_framework.test import APIClient from apps.accounts.models import Staff -from apps.client.models import Client +from apps.company.models import Company from apps.job.models import Job from apps.testing import BaseTestCase @@ -30,8 +30,8 @@ def setUp(self) -> None: is_office_staff=True, ) self.api.force_authenticate(user=self.superuser) - self.test_client = Client.objects.create( - name="Test Client", + self.test_client = Company.objects.create( + name="Test Company", email="test@example.com", xero_last_modified="2024-01-01T00:00:00Z", ) @@ -46,7 +46,7 @@ def _make_job( # Job.save() auto-generates job_number, so we can't set it here. job = Job.objects.create( name=f"Job {status} {pricing_methodology}", - client=self.test_client, + company=self.test_client, status=status, pricing_methodology=pricing_methodology, staff=self.test_staff, diff --git a/apps/timesheet/tests/test_weekly_timesheet_service.py b/apps/timesheet/tests/test_weekly_timesheet_service.py index 514e79f3f..ef0600c03 100644 --- a/apps/timesheet/tests/test_weekly_timesheet_service.py +++ b/apps/timesheet/tests/test_weekly_timesheet_service.py @@ -4,7 +4,7 @@ from django.utils import timezone from apps.accounts.models import Staff -from apps.client.models import Client +from apps.company.models import Company from apps.job.models import CostLine, Job, LabourSubtype from apps.testing import BaseTestCase from apps.timesheet.services.weekly_timesheet_service import WeeklyTimesheetService @@ -32,8 +32,8 @@ def setUp(self): Staff.objects.filter(pk=self.staff.pk).update( date_joined=timezone.make_aware(datetime(2025, 1, 1)) ) - self.client = Client.objects.create( - name="Cost Test Client", + self.company = Company.objects.create( + name="Cost Test Company", email="cost-test@example.com", xero_last_modified="2024-01-01T00:00:00Z", ) @@ -41,7 +41,7 @@ def setUp(self): self.job = Job.objects.create( job_number=98765, name="Weekly Cost Test", - client=self.client, + company=self.company, default_xero_pay_item=self.pay_item, staff=self.test_staff, ) diff --git a/apps/timesheet/views/api.py b/apps/timesheet/views/api.py index 245b6686d..789836806 100644 --- a/apps/timesheet/views/api.py +++ b/apps/timesheet/views/api.py @@ -21,7 +21,7 @@ from apps.accounts.models import Staff from apps.accounts.utils import get_displayable_staff -from apps.client.serializers import ClientErrorResponseSerializer +from apps.company.serializers import CompanyErrorResponseSerializer from apps.job.models import Job from apps.job.models.costing import CostLine from apps.timesheet.serializers import ( @@ -216,7 +216,7 @@ def get(self, request): ) ) .select_related( - "client", + "company", "default_xero_pay_item", "latest_actual", "latest_estimate", @@ -321,8 +321,8 @@ class WeeklyTimesheetAPIView(TimesheetResponseMixin, TimesheetBaseView): ], responses={ 200: WeeklyTimesheetDataSerializer, - 400: ClientErrorResponseSerializer, - 500: ClientErrorResponseSerializer, + 400: CompanyErrorResponseSerializer, + 500: CompanyErrorResponseSerializer, }, ) def get(self, request): @@ -340,9 +340,9 @@ class CreatePayRunAPIView(TimesheetBaseView): request=CreatePayRunSerializer, responses={ 201: CreatePayRunResponseSerializer, - 400: ClientErrorResponseSerializer, - 409: ClientErrorResponseSerializer, - 500: ClientErrorResponseSerializer, + 400: CompanyErrorResponseSerializer, + 409: CompanyErrorResponseSerializer, + 500: CompanyErrorResponseSerializer, }, ) def post(self, request): @@ -421,7 +421,7 @@ def post(self, request): ) except ValueError as exc: - # Client errors (bad date, not Monday) + # Company errors (bad date, not Monday) return Response({"error": str(exc)}, status=status.HTTP_400_BAD_REQUEST) except Exception as exc: error_msg = str(exc) @@ -443,7 +443,7 @@ class PayRunListAPIView(TimesheetBaseView): summary="List pay runs", responses={ 200: PayRunListResponseSerializer, - 500: ClientErrorResponseSerializer, + 500: CompanyErrorResponseSerializer, }, ) def get(self, request): @@ -484,7 +484,7 @@ class RefreshPayRunsAPIView(TimesheetBaseView): request=None, responses={ 200: PayRunSyncResponseSerializer, - 500: ClientErrorResponseSerializer, + 500: CompanyErrorResponseSerializer, }, ) def post(self, request): @@ -523,7 +523,7 @@ class PostWeekToXeroPayrollAPIView(TimesheetBaseView): request=PostWeekToXeroSerializer, responses={ 200: PostWeekToXeroStartResponseSerializer, - 400: ClientErrorResponseSerializer, + 400: CompanyErrorResponseSerializer, }, ) def post(self, request): diff --git a/apps/workflow/accounting/provider.py b/apps/workflow/accounting/provider.py index 8a25ef0bb..77efd0469 100644 --- a/apps/workflow/accounting/provider.py +++ b/apps/workflow/accounting/provider.py @@ -8,7 +8,7 @@ from typing import TYPE_CHECKING, Protocol if TYPE_CHECKING: - from apps.client.models import Client + from apps.company.models import Company from apps.job.models import Job from .types import ( @@ -61,11 +61,11 @@ def disconnect(self) -> None: # --- Contacts/Clients --- - def create_contact(self, client: Client) -> ContactResult: - """Create a contact in the accounting system from a local Client.""" + def create_contact(self, company: Company) -> ContactResult: + """Create a contact in the accounting system from a local Company.""" ... - def update_contact(self, client: Client) -> ContactResult: + def update_contact(self, company: Company) -> ContactResult: """Update an existing contact in the accounting system.""" ... diff --git a/apps/workflow/accounting/types.py b/apps/workflow/accounting/types.py index 8a844d4d1..e872706d3 100644 --- a/apps/workflow/accounting/types.py +++ b/apps/workflow/accounting/types.py @@ -22,7 +22,7 @@ class InvoicePayload: """Data needed to create an invoice in any accounting system.""" client_external_id: str - client_name: str + company_name: str line_items: list[DocumentLineItem] date: date due_date: date @@ -38,7 +38,7 @@ class QuotePayload: """Data needed to create a quote in any accounting system.""" client_external_id: str - client_name: str + company_name: str line_items: list[DocumentLineItem] date: date expiry_date: date diff --git a/apps/workflow/accounting/xero/provider.py b/apps/workflow/accounting/xero/provider.py index 8137553ba..f63237356 100644 --- a/apps/workflow/accounting/xero/provider.py +++ b/apps/workflow/accounting/xero/provider.py @@ -11,7 +11,7 @@ from apps.workflow.services.error_persistence import persist_app_error if TYPE_CHECKING: - from apps.client.models import Client + from apps.company.models import Company from apps.job.models import Job from apps.workflow.accounting.types import ( ContactResult, @@ -125,31 +125,31 @@ def disconnect(self) -> None: # --- Contacts --- - def create_contact(self, client: Client) -> ContactResult: + def create_contact(self, company: Company) -> ContactResult: from apps.workflow.accounting.types import ContactResult - from apps.workflow.api.xero.push import create_client_contact_in_xero + from apps.workflow.api.xero.push import create_company_contact_in_xero try: - xero_contact_id = create_client_contact_in_xero(client) + xero_contact_id = create_company_contact_in_xero(company) return ContactResult( success=True, external_id=xero_contact_id, - name=client.name, + name=company.name, ) except Exception as exc: persist_app_error(exc) return ContactResult(success=False, error=str(exc)) - def update_contact(self, client: Client) -> ContactResult: + def update_contact(self, company: Company) -> ContactResult: from apps.workflow.accounting.types import ContactResult - from apps.workflow.api.xero.push import sync_client_to_xero + from apps.workflow.api.xero.push import sync_company_to_xero try: - sync_client_to_xero(client) + sync_company_to_xero(company) return ContactResult( success=True, - external_id=client.xero_contact_id, - name=client.name, + external_id=company.xero_contact_id, + name=company.name, ) except Exception as exc: persist_app_error(exc) @@ -182,7 +182,7 @@ def create_invoice(self, payload: InvoicePayload) -> DocumentResult: type="ACCREC", contact=Contact( contact_id=payload.client_external_id, - name=payload.client_name, + name=payload.company_name, ), line_items=self._build_line_items(payload.line_items), date=payload.date.isoformat(), @@ -253,7 +253,7 @@ def create_quote(self, payload: QuotePayload) -> DocumentResult: xero_quote = Quote( contact=Contact( contact_id=payload.client_external_id, - name=payload.client_name, + name=payload.company_name, ), line_items=self._build_line_items(payload.line_items), date=payload.date.isoformat(), diff --git a/apps/workflow/api/reports/job_movement.py b/apps/workflow/api/reports/job_movement.py index 8f48a7992..9e72115f5 100644 --- a/apps/workflow/api/reports/job_movement.py +++ b/apps/workflow/api/reports/job_movement.py @@ -47,7 +47,7 @@ def get_draft_jobs_created(self, start_date, end_date): """ return Job.objects.filter( created_at__gte=start_date, created_at__lte=end_date - ).select_related("client", "created_by") + ).select_related("company", "created_by") def get_quotes_submitted(self, start_date, end_date): """ @@ -195,7 +195,7 @@ def serialize_job_list(self, jobs): "id": str(job.id), "job_number": job.job_number, "name": job.name, - "client_name": job.client.name if job.client else None, + "company_name": job.company.name if job.company else None, "status": job.status, "status_display": job.get_status_display(), "created_at": job.created_at.isoformat(), @@ -216,7 +216,7 @@ def serialize_event_list(self, events): "job_id": str(event.job.id), "job_number": event.job.job_number, "job_name": event.job.name, - "client_name": event.job.client.name if event.job.client else None, + "company_name": event.job.company.name if event.job.company else None, "timestamp": event.timestamp.isoformat(), "current_status": event.job.status, "current_status_display": event.job.get_status_display(), diff --git a/apps/workflow/api/xero/__init__.py b/apps/workflow/api/xero/__init__.py index 39e132d53..4b1935ef1 100644 --- a/apps/workflow/api/xero/__init__.py +++ b/apps/workflow/api/xero/__init__.py @@ -58,11 +58,11 @@ ) from .push import ( bulk_create_contacts_in_xero, - create_client_contact_in_xero, + create_company_contact_in_xero, get_all_xero_contacts, map_costline_to_expense_entry, map_costline_to_time_entry, - sync_client_to_xero, + sync_company_to_xero, sync_costlines_to_xero, sync_expense_entries_bulk, sync_job_to_xero, @@ -71,16 +71,16 @@ from .reprocess_xero import ( reprocess_all, reprocess_bills, - reprocess_clients, + reprocess_companies, reprocess_credit_notes, reprocess_invoices, - set_client_fields, + set_company_fields, set_invoice_or_bill_fields, sync_xero_phone_methods, ) from .seed import ( fetch_xero_entity_lookup, - seed_clients_to_xero, + seed_companies_to_xero, seed_jobs_to_xero, sync_single_contact, sync_single_invoice, @@ -111,13 +111,13 @@ ) from .transforms import ( clean_json, - get_or_fetch_client, + get_or_fetch_company, process_xero_data, - resolve_client_from_xero_contact, + resolve_company_from_xero_contact, serialize_xero_object, sync_accounts, - sync_client_from_xero_contact, - sync_clients, + sync_companies, + sync_company_from_xero_contact, sync_entities, transform_bill, transform_credit_note, @@ -152,7 +152,7 @@ "bind_token_callbacks", "bulk_create_contacts_in_xero", "clean_json", - "create_client_contact_in_xero", + "create_company_contact_in_xero", "create_default_task", "create_employee_leave", "create_expense_entries", @@ -183,7 +183,7 @@ "get_last_modified_time", "get_leave_type_id_by_name", "get_leave_types", - "get_or_fetch_client", + "get_or_fetch_company", "get_pay_run", "get_pay_runs", "get_pay_runs_for_sync", @@ -212,22 +212,22 @@ "refresh_token", "reprocess_all", "reprocess_bills", - "reprocess_clients", + "reprocess_companies", "reprocess_credit_notes", "reprocess_invoices", - "resolve_client_from_xero_contact", - "seed_clients_to_xero", + "resolve_company_from_xero_contact", + "seed_companies_to_xero", "seed_jobs_to_xero", "serialize_xero_object", - "set_client_fields", + "set_company_fields", "set_invoice_or_bill_fields", "swap_active", "sync_accounts", "sync_all_local_stock_to_xero", "sync_all_xero_data", - "sync_client_from_xero_contact", - "sync_client_to_xero", - "sync_clients", + "sync_companies", + "sync_company_from_xero_contact", + "sync_company_to_xero", "sync_costlines_to_xero", "sync_entities", "sync_expense_entries_bulk", diff --git a/apps/workflow/api/xero/push.py b/apps/workflow/api/xero/push.py index e7817097e..0df6d6700 100644 --- a/apps/workflow/api/xero/push.py +++ b/apps/workflow/api/xero/push.py @@ -23,36 +23,36 @@ SLEEP_TIME = 1 # Sleep after every API call to avoid hitting rate limits -def sync_client_to_xero(client): - """Push a client to Xero""" - if not client.validate_for_xero(): - logger.error(f"Client {client.id} failed validation") +def sync_company_to_xero(company): + """Push a company to Xero""" + if not company.validate_for_xero(): + logger.error(f"Company {company.id} failed validation") return False accounting_api = AccountingApi(api_client) - contact_data = client.get_client_for_xero() + contact_data = company.get_company_for_xero() if not contact_data: - logger.error(f"Client {client.id} failed to generate Xero data") + logger.error(f"Company {company.id} failed to generate Xero data") return False - if client.xero_contact_id: + if company.xero_contact_id: response = accounting_api.update_contact( get_tenant_id(), - contact_id=client.xero_contact_id, + contact_id=company.xero_contact_id, contacts={"contacts": [contact_data]}, ) time.sleep(SLEEP_TIME) - logger.info(f"Updated client {client.name} in Xero") + logger.info(f"Updated company {company.name} in Xero") else: response = accounting_api.create_contacts( get_tenant_id(), contacts={"contacts": [contact_data]} ) time.sleep(SLEEP_TIME) - client.xero_contact_id = response.contacts[0].contact_id - client.save() + company.xero_contact_id = response.contacts[0].contact_id + company.save() logger.info( - f"Created client {client.name} in Xero with ID {client.xero_contact_id}" + f"Created company {company.name} in Xero with ID {company.xero_contact_id}" ) return True @@ -60,7 +60,7 @@ def sync_client_to_xero(client): def sync_job_to_xero(job): """Push a job to Xero Projects API""" - from apps.workflow.api.xero.transforms import get_or_fetch_client + from apps.workflow.api.xero.transforms import get_or_fetch_company if not settings.XERO_SYNC_PROJECTS: logger.info( @@ -72,25 +72,25 @@ def sync_job_to_xero(job): logger.info(f"Syncing Job {job.job_number} ({job.name}) to Xero") # Validation - if not job.client: - logger.error(f"Job {job.job_number} has no client - cannot sync to Xero") + if not job.company: + logger.error(f"Job {job.job_number} has no company - cannot sync to Xero") return False - if not job.client.xero_contact_id: + if not job.company.xero_contact_id: logger.error( - f"Job {job.job_number} client '{job.client.name}' has no xero_contact_id - sync client first" + f"Job {job.job_number} company '{job.company.name}' has no xero_contact_id - sync company first" ) return False # Validate contact exists in Xero - fail early try: - valid_client = get_or_fetch_client( - job.client.xero_contact_id, f"job {job.job_number}" + valid_client = get_or_fetch_company( + job.company.xero_contact_id, f"job {job.job_number}" ) - logger.info(f"Validated client exists in Xero: {valid_client.name}") + logger.info(f"Validated company exists in Xero: {valid_client.name}") except Exception as e: logger.error( - f"Job {job.job_number} client contact_id {job.client.xero_contact_id} does not exist in Xero: {e}" + f"Job {job.job_number} company contact_id {job.company.xero_contact_id} does not exist in Xero: {e}" ) return False @@ -104,7 +104,7 @@ def sync_job_to_xero(job): ) project_data = { "name": project_name, - "contact_id": job.client.xero_contact_id, + "contact_id": job.company.xero_contact_id, } # Add optional fields (correct field names per SDK) - defensive programming @@ -452,16 +452,16 @@ def get_all_xero_contacts(): return all_contacts -def create_client_contact_in_xero(client): - """Create a single client as Xero contact. Returns xero_contact_id on success, raises on failure.""" - if not client.validate_for_xero(): - raise ValueError(f"Client {client.id} failed Xero validation") +def create_company_contact_in_xero(company): + """Create a single company as Xero contact. Returns xero_contact_id on success, raises on failure.""" + if not company.validate_for_xero(): + raise ValueError(f"Company {company.id} failed Xero validation") accounting_api = AccountingApi(api_client) - contact_data = client.get_client_for_xero() + contact_data = company.get_company_for_xero() if not contact_data: - raise ValueError(f"Client {client.id} failed to generate Xero data") + raise ValueError(f"Company {company.id} failed to generate Xero data") response = accounting_api.create_contacts( get_tenant_id(), contacts={"contacts": [contact_data]} @@ -470,16 +470,16 @@ def create_client_contact_in_xero(client): if not response or not response.contacts: raise ValueError( - f"Xero API returned empty response when creating contact for client {client.id}" + f"Xero API returned empty response when creating contact for company {company.id}" ) - client.xero_contact_id = str(response.contacts[0].contact_id) - client.save(update_fields=["xero_contact_id"]) - return client.xero_contact_id + company.xero_contact_id = str(response.contacts[0].contact_id) + company.save(update_fields=["xero_contact_id"]) + return company.xero_contact_id def bulk_create_contacts_in_xero(clients_to_create, batch_size=50): - """Create multiple client contacts in Xero in batches of 50""" + """Create multiple company contacts in Xero in batches of 50""" if not clients_to_create: return 0 @@ -491,18 +491,18 @@ def bulk_create_contacts_in_xero(clients_to_create, batch_size=50): batch = clients_to_create[i : i + batch_size] contact_batch = [] - for client in batch: - if not client.validate_for_xero(): - logger.error(f"Client {client.name} failed Xero validation") + for company in batch: + if not company.validate_for_xero(): + logger.error(f"Company {company.name} failed Xero validation") raise ValueError( - f"Client {client.name} failed Xero validation" + f"Company {company.name} failed Xero validation" ) # FAIL EARLY - contact_data = client.get_client_for_xero() + contact_data = company.get_company_for_xero() if not contact_data: - logger.error(f"Client {client.name} failed to generate Xero data") + logger.error(f"Company {company.name} failed to generate Xero data") raise ValueError( - f"Client {client.name} failed to generate Xero data" + f"Company {company.name} failed to generate Xero data" ) # FAIL EARLY contact_batch.append(contact_data) @@ -529,22 +529,22 @@ def bulk_create_contacts_in_xero(clients_to_create, batch_size=50): # Map response back to clients by submission order. Verified # against dev Xero by scripts/integration/verify_xero_batch_order.py. - for idx, (client, created_contact) in enumerate( + for idx, (company, created_contact) in enumerate( zip(batch, response.contacts, strict=True) ): - if created_contact.name != client.name: + if created_contact.name != company.name: raise ValueError( f"Xero response order mismatch at position {idx}: " - f"sent {client.name!r} but received " + f"sent {company.name!r} but received " f"{created_contact.name!r}. Re-run " f"scripts/integration/verify_xero_batch_order.py to confirm " f"Xero is still preserving submission order." ) - client.xero_contact_id = created_contact.contact_id - client.save(update_fields=["xero_contact_id"]) + company.xero_contact_id = created_contact.contact_id + company.save(update_fields=["xero_contact_id"]) total_created += 1 logger.info( - f"Created Xero contact for client {client.name}: {client.xero_contact_id}" + f"Created Xero contact for company {company.name}: {company.xero_contact_id}" ) except Exception as e: diff --git a/apps/workflow/api/xero/reprocess_xero.py b/apps/workflow/api/xero/reprocess_xero.py index b026dae03..9b46a397a 100644 --- a/apps/workflow/api/xero/reprocess_xero.py +++ b/apps/workflow/api/xero/reprocess_xero.py @@ -16,10 +16,10 @@ Invoice, InvoiceLineItem, ) -from apps.client.models import ( - Client, +from apps.company.models import ( ClientContact, ClientContactMethod, + Company, SupplierPickupAddress, ) from apps.crm.tasks import rematch_phone_calls_task @@ -40,8 +40,8 @@ def _xero_phone_value(phone_entry: Mapping[str, str | None]) -> str: return f"{country_code} {area_code} {number}".strip() -def sync_xero_phone_methods(client: Client) -> list[str]: - """Ensure the client's Xero phone numbers exist as contact methods. +def sync_xero_phone_methods(company: Company) -> list[str]: + """Ensure the company's Xero phone numbers exist as contact methods. Xero owns the number's existence; the CRM user owns label/is_primary. Existing rows are therefore never updated here — only missing numbers are @@ -50,7 +50,7 @@ def sync_xero_phone_methods(client: Client) -> list[str]: Returns the normalized numbers that were newly created, so the caller can dispatch a call rematch for them. """ - phones = client.raw_json.get("_phones", []) if client.raw_json else [] + phones = company.raw_json.get("_phones", []) if company.raw_json else [] if not isinstance(phones, list): return [] @@ -62,15 +62,15 @@ def sync_xero_phone_methods(client: Client) -> list[str]: normalized = ClientContactMethod.normalize_phone(value) if not normalized: continue - # The "one number, one client" rule is enforced by the grandfathered + # The "one number, one company" rule is enforced by the grandfathered # ClientContactMethod.save() guard reached via get_or_create below: # re-syncing an existing number never saves (nothing to update), while - # a genuinely-new cross-client number raises and hard-fails this - # client's sync (deliberate — the data must be fixed, not skipped). + # a genuinely-new cross-company number raises and hard-fails this + # company's sync (deliberate — the data must be fixed, not skipped). phone_type = phone_entry.get("_phone_type") or "" try: _, created = ClientContactMethod.objects.get_or_create( - client=client, + company=company, contact=None, method_type=ClientContactMethod.MethodType.PHONE, normalized_value=normalized, @@ -85,7 +85,7 @@ def sync_xero_phone_methods(client: Client) -> list[str]: raise except ValidationError as exc: error = ValidationError( - f"Xero phone sync for client '{client.name}' ({client.id}) " + f"Xero phone sync for company '{company.name}' ({company.id}) " f"rejected number '{value}' ({normalized}): " f"{'; '.join(exc.messages)}" ) @@ -93,7 +93,7 @@ def sync_xero_phone_methods(client: Client) -> list[str]: error, additional_context={ "operation": "sync_xero_phone_methods_duplicate_owner", - "client_id": str(client.id), + "client_id": str(company.id), "normalized_number": normalized, }, ) @@ -169,15 +169,15 @@ def set_invoice_or_bill_fields(document, document_type, new_from_xero=False): document.xero_last_modified = document.xero_last_modified or timezone.now() document.xero_last_synced = timezone.now() - # Set or create the client/supplier + # Set or create the company/supplier contact_data = raw_data.get("_contact", {}) contact_id = contact_data.get("_contact_id") - client = Client.objects.filter(xero_contact_id=contact_id).first() - if not client: + company = Company.objects.filter(xero_contact_id=contact_id).first() + if not company: raise ValueError( - f"Client not found for {document_type.lower()} {document.number}" + f"Company not found for {document_type.lower()} {document.number}" ) - document.client = client + document.company = company document.save() @@ -243,19 +243,24 @@ def set_invoice_or_bill_fields(document, document_type, new_from_xero=False): # f"Total Incl. Tax: {line_item.line_amount_incl_tax}") -def set_client_fields(client: Client, new_from_xero: bool = False) -> None: +def set_company_fields(company: Company, new_from_xero: bool = False) -> None: """ - Set client fields from raw_json. - If new_from_xero is True, it means the client was just created from Xero data. + Set company fields from raw_json. + If new_from_xero is True, it means the company was just created from Xero data. """ - raw_json = client.raw_json + raw_json = company.raw_json if not raw_json: - logger.warning(f"Client {client.id} has no raw_json to process.") + logger.warning(f"Company {company.id} has no raw_json to process.") + # BUG BUG BUG + # Multiple breaches of 'fail early'. REMOVE. + # Do not allow 'or ' with fallbacks + # DO not allow # type + # Do not continue after logging a warning. This is a data integrity issue. # Ensure essential fields are not None if raw_json is missing - client.name = client.name or "Unnamed Client" - client.xero_last_modified = client.xero_last_modified or timezone.now() - # type ignore: Client.save is still untyped (apps/client/models.py). - client.save() # type: ignore[no-untyped-call] + company.name = company.name or "Unnamed Company" + company.xero_last_modified = company.xero_last_modified or timezone.now() + # type ignore: Company.save is still untyped (apps/company/models.py). + company.save() # type: ignore[no-untyped-call] return # Capture old values for change tracking (only for updates, not new clients) @@ -270,27 +275,27 @@ def set_client_fields(client: Client, new_from_xero: bool = False) -> None: ] old_values = {} if not new_from_xero: - old_values = {field: getattr(client, field, None) for field in tracked_fields} + old_values = {field: getattr(company, field, None) for field in tracked_fields} - client.name = raw_json.get("_name", client.name or "Unnamed Client") + company.name = raw_json.get("_name", company.name or "Unnamed Company") # This is the general email for the contact/company - client.email = raw_json.get("_email_address", client.email) + company.email = raw_json.get("_email_address", company.email) # Update xero_contact_id from raw_json if available # This ensures the link to the Xero contact is maintained or established. xero_contact_id_from_json = raw_json.get("_contact_id") if xero_contact_id_from_json: - client.xero_contact_id = xero_contact_id_from_json + company.xero_contact_id = xero_contact_id_from_json # Check for archived/merged status from raw_json contact_status = raw_json.get("_contact_status", "ACTIVE") if contact_status == "ARCHIVED": - client.xero_archived = True - client.allow_jobs = False + company.xero_archived = True + company.allow_jobs = False # FIXME: asymmetric -- un-archiving in Xero does not reset either # flag. If a contact is archived then un-archived, `xero_archived` # and `allow_jobs` stay in the archived state until an admin toggles - # `allow_jobs` back on via the client detail UI. The un-archive + # `allow_jobs` back on via the company detail UI. The un-archive # path is rare enough that we accepted the asymmetry rather than # introduce a "manually set" protection flag. If un-archive becomes # common, revisit: (a) auto-reset both flags, which overwrites any @@ -300,12 +305,14 @@ def set_client_fields(client: Client, new_from_xero: bool = False) -> None: # Check for merge information merged_to_contact_id = raw_json.get("_merged_to_contact_id") if merged_to_contact_id: - client.xero_merged_into_id = merged_to_contact_id + company.xero_merged_into_id = merged_to_contact_id # Attempt to get address from the 'STREET' address entry if available street_address = "" if isinstance(raw_json.get("_addresses"), list): for address_entry in raw_json.get("_addresses", []): + # Bug. Bare if must only be if . Anything else needs else + # isinstance looks like it's trying to check a data contract if ( isinstance(address_entry, dict) and address_entry.get("_address_type") == "STREET" @@ -324,11 +331,11 @@ def set_client_fields(client: Client, new_from_xero: bool = False) -> None: ] street_address = ", ".join(filter(None, parts)) break # Found street address - client.address = ( - street_address or client.address + company.address = ( + street_address or company.address ) # Use street_address if found, else keep existing or empty - # Create SupplierPickupAddress from Xero STREET address for any client + # Create SupplierPickupAddress from Xero STREET address for any company if isinstance(raw_json.get("_addresses"), list): for address_entry in raw_json.get("_addresses", []): if ( @@ -349,7 +356,7 @@ def set_client_fields(client: Client, new_from_xero: bool = False) -> None: # Only create if we have both street and city (required fields) if street and city: SupplierPickupAddress.objects.get_or_create( - client=client, + company=company, name="Xero Address", defaults={ "street": street, @@ -362,8 +369,8 @@ def set_client_fields(client: Client, new_from_xero: bool = False) -> None: ) break # Only process first STREET address - client.is_account_customer = raw_json.get( - "_is_customer", client.is_account_customer + company.is_account_customer = raw_json.get( + "_is_customer", company.is_account_customer ) contact_first_name = raw_json.get("_first_name", None) @@ -371,21 +378,21 @@ def set_client_fields(client: Client, new_from_xero: bool = False) -> None: match (contact_first_name, contact_last_name): case (str() as first, str() as last) if first and last: - client.primary_contact_name = f"{first} {last}" + company.primary_contact_name = f"{first} {last}" case (str() as first, None) if first: - client.primary_contact_name = first + company.primary_contact_name = first case (None, str() as last) if last: - client.primary_contact_name = last + company.primary_contact_name = last case _: - client.primary_contact_name = "Unnamed Contact" + company.primary_contact_name = "Unnamed Contact" contact_email = raw_json.get("_email_address", None) if contact_email: - client.primary_contact_email = contact_email + company.primary_contact_email = contact_email additional_persons = raw_json.get("_contact_persons", []) if len(additional_persons) > 0: - client.additional_contact_persons = [ + company.additional_contact_persons = [ { "name": (person.get("_first_name") or "") + (" " + person.get("_last_name") if person.get("_last_name") else ""), @@ -403,7 +410,7 @@ def set_client_fields(client: Client, new_from_xero: bool = False) -> None: # Skip contacts with empty names - they're pointless if not name: logger.debug( - f"Skipping contact with empty name for client {client.name}" + f"Skipping contact with empty name for company {company.name}" ) continue @@ -412,7 +419,7 @@ def set_client_fields(client: Client, new_from_xero: bool = False) -> None: try: contact, created = ClientContact.objects.get_or_create( - client=client, + company=company, name=name, defaults={"email": email}, ) @@ -426,8 +433,8 @@ def set_client_fields(client: Client, new_from_xero: bool = False) -> None: persist_and_raise( exc, additional_context={ - "operation": "set_client_fields_duplicate_contact", - "client_id": str(client.id), + "operation": "set_company_fields_duplicate_contact", + "client_id": str(company.id), "contact_name": name, }, ) @@ -441,24 +448,24 @@ def set_client_fields(client: Client, new_from_xero: bool = False) -> None: # parse_datetime returns None for malformed input instead of # raising; normalize both failure modes into the except arm. raise ValueError(f"unparseable datetime {updated_date_utc_str!r}") - client.xero_last_modified = parsed_last_modified + company.xero_last_modified = parsed_last_modified except ValueError: logger.error( f"Could not parse _updated_date_utc: {updated_date_utc_str} " - f"for client {client.id}" + f"for company {company.id}" ) - client.xero_last_modified = client.xero_last_modified or timezone.now() + company.xero_last_modified = company.xero_last_modified or timezone.now() else: - client.xero_last_modified = client.xero_last_modified or timezone.now() + company.xero_last_modified = company.xero_last_modified or timezone.now() - client.xero_last_synced = timezone.now() - # Keep the client write and its phone sync atomic: a cross-client phone - # conflict raised by sync_xero_phone_methods must roll back this client's + company.xero_last_synced = timezone.now() + # Keep the company write and its phone sync atomic: a cross-company phone + # conflict raised by sync_xero_phone_methods must roll back this company's # field update too, rather than leaving it committed without its numbers. with transaction.atomic(): - # type ignore: Client.save is still untyped (apps/client/models.py). - client.save() # type: ignore[no-untyped-call] - created_numbers = sync_xero_phone_methods(client) + # type ignore: Company.save is still untyped (apps/company/models.py). + company.save() # type: ignore[no-untyped-call] + created_numbers = sync_xero_phone_methods(company) if created_numbers: # Numbers imported from Xero must rematch historical calls just like # UI-edited numbers do. Dispatch after the DB work is committed so the @@ -467,7 +474,7 @@ def set_client_fields(client: Client, new_from_xero: bool = False) -> None: if new_from_xero: logger.info( - f"[XERO-WEBHOOK] Client {client.name} (ID: {client.id}) " + f"[XERO-WEBHOOK] Company {company.name} (ID: {company.id}) " f"created from Xero data." ) else: @@ -475,18 +482,18 @@ def set_client_fields(client: Client, new_from_xero: bool = False) -> None: changes = [] for field in tracked_fields: old_val = old_values.get(field) - new_val = getattr(client, field, None) + new_val = getattr(company, field, None) if old_val != new_val: changes.append(f"{field}: {old_val!r} → {new_val!r}") if changes: logger.info( - f"Client {client.name} (ID: {client.id}) updated from Xero data. " + f"Company {company.name} (ID: {company.id}) updated from Xero data. " f"Changes: {', '.join(changes)}" ) else: logger.info( - f"Client {client.name} (ID: {client.id}) synced from Xero (no changes)." + f"Company {company.name} (ID: {company.id}) synced from Xero (no changes)." ) @@ -522,21 +529,21 @@ def reprocess_credit_notes(): ) -def reprocess_clients(): +def reprocess_companies(): """Reprocess all existing clients to set fields based on raw JSON.""" - for client in Client.objects.all(): + for company in Company.objects.all(): try: - set_client_fields(client) - logger.info(f"Reprocessed client: {client.name}") + set_company_fields(company) + logger.info(f"Reprocessed company: {company.name}") except Exception as e: - logger.error(f"Error reprocessing client {client.name}: {str(e)}") + logger.error(f"Error reprocessing company {company.name}: {str(e)}") def reprocess_all(): """Reprocesses all data to set fields based on raw JSON.""" # NOte, we don't have a reprocess accounts because it just feels too weird. # If you break accounts, you probably want to handle it manually - reprocess_clients() + reprocess_companies() reprocess_invoices() reprocess_bills() reprocess_credit_notes() diff --git a/apps/workflow/api/xero/seed.py b/apps/workflow/api/xero/seed.py index f9b017970..d1d4408aa 100644 --- a/apps/workflow/api/xero/seed.py +++ b/apps/workflow/api/xero/seed.py @@ -6,7 +6,7 @@ from xero_python.accounting import AccountingApi from apps.accounting.models import Bill, Invoice -from apps.client.models import Client +from apps.company.models import Company from apps.workflow.api.xero.auth import api_client, get_tenant_id from apps.workflow.api.xero.push import ( bulk_create_contacts_in_xero, @@ -14,7 +14,7 @@ sync_job_to_xero, ) from apps.workflow.api.xero.reprocess_xero import ( - set_client_fields, + set_company_fields, set_invoice_or_bill_fields, ) from apps.workflow.api.xero.transforms import process_xero_data, transform_pay_run @@ -24,7 +24,7 @@ logger = logging.getLogger("xero") -def seed_clients_to_xero(clients): +def seed_companies_to_xero(clients): """Bulk process clients: link existing contacts + create missing ones in batches of 50""" # Get all existing Xero contacts (one API call) try: @@ -47,28 +47,28 @@ def seed_clients_to_xero(clients): clients_to_link = [] clients_to_create = [] - for client in clients: - candidates = existing_by_name.get(client.name.lower()) + for company in clients: + candidates = existing_by_name.get(company.name.lower()) if candidates: - # Pop one candidate so a second local client with the same name + # Pop one candidate so a second local company with the same name # doesn't race onto the same xero_contact_id; it falls through # to create instead. existing_contact_id = candidates.pop(0) - clients_to_link.append((client, existing_contact_id)) + clients_to_link.append((company, existing_contact_id)) else: - clients_to_create.append(client) + clients_to_create.append(company) # Process linking (fast, no API calls) - for client, existing_contact_id in clients_to_link: + for company, existing_contact_id in clients_to_link: try: - client.xero_contact_id = existing_contact_id - client.save(update_fields=["xero_contact_id"]) + company.xero_contact_id = existing_contact_id + company.save(update_fields=["xero_contact_id"]) results["linked"] += 1 logger.info( - f"Linked client {client.name} to existing Xero contact: {existing_contact_id}" + f"Linked company {company.name} to existing Xero contact: {existing_contact_id}" ) except Exception as e: - logger.error(f"Error linking client {client.name}: {e}") + logger.error(f"Error linking company {company.name}: {e}") raise # FAIL EARLY # Process creation in batches using dedicated function. @@ -124,7 +124,7 @@ def sync_single_contact(sync_service, contact_id): contact = response.contacts[0] raw_json = process_xero_data(contact) - client, created = Client.objects.update_or_create( + company, created = Company.objects.update_or_create( xero_contact_id=contact.contact_id, defaults={ "raw_json": raw_json, @@ -134,34 +134,34 @@ def sync_single_contact(sync_service, contact_id): }, ) - set_client_fields(client, new_from_xero=created) + set_company_fields(company, new_from_xero=created) # Handle merge if needed — set pointer, then move stranded FK records - # onto the terminal client. - if client.xero_merged_into_id and not client.merged_into: - merged_into = Client.objects.filter( - xero_contact_id=client.xero_merged_into_id + # onto the terminal company. + if company.xero_merged_into_id and not company.merged_into: + merged_into = Company.objects.filter( + xero_contact_id=company.xero_merged_into_id ).first() if not merged_into: logger.warning( - "Deferred merge: client %s points at unsynced " + "Deferred merge: company %s points at unsynced " "xero_contact_id=%s; reassignment will retry on next sync", - client.id, - client.xero_merged_into_id, + company.id, + company.xero_merged_into_id, ) else: from apps.accounts.models import Staff - from apps.client.services.client_merge_service import ( - reassign_client_fk_records, + from apps.company.services.company_merge_service import ( + reassign_company_fk_records, ) - client.merged_into = merged_into - client.allow_jobs = False - client.save() - destination = client.get_final_client() - if destination.id != client.id: - reassign_client_fk_records( - client, + company.merged_into = merged_into + company.allow_jobs = False + company.save() + destination = company.get_final_company() + if destination.id != company.id: + reassign_company_fk_records( + company, destination, Staff.get_automation_user(), logger_prefix="[webhook] ", diff --git a/apps/workflow/api/xero/sync.py b/apps/workflow/api/xero/sync.py index 66b9dc553..d614c8ad2 100644 --- a/apps/workflow/api/xero/sync.py +++ b/apps/workflow/api/xero/sync.py @@ -11,7 +11,7 @@ from xero_python.accounting import AccountingApi from apps.accounting.models import Bill, CreditNote, Invoice, Quote -from apps.client.models import Client +from apps.company.models import Company from apps.purchasing.models import PurchaseOrder, Stock from apps.workflow.api.xero.auth import api_client, get_tenant_id, get_valid_token from apps.workflow.api.xero.client import quota_floor_breached @@ -21,25 +21,25 @@ ) from apps.workflow.api.xero.push import ( # noqa: F401 bulk_create_contacts_in_xero, - create_client_contact_in_xero, + create_company_contact_in_xero, get_all_xero_contacts, map_costline_to_expense_entry, map_costline_to_time_entry, - sync_client_to_xero, + sync_company_to_xero, sync_costlines_to_xero, sync_expense_entries_bulk, sync_job_to_xero, sync_time_entries_bulk, ) from apps.workflow.api.xero.seed import ( # noqa: F401 - seed_clients_to_xero, + seed_companies_to_xero, seed_jobs_to_xero, sync_single_contact, sync_single_invoice, sync_single_pay_run, ) from apps.workflow.api.xero.transforms import process_xero_data # noqa: F401 -from apps.workflow.api.xero.transforms import sync_clients # noqa: F401 +from apps.workflow.api.xero.transforms import sync_companies # noqa: F401 from apps.workflow.api.xero.transforms import ( sync_accounts, sync_entities, @@ -343,9 +343,9 @@ def sync_xero_data( "contacts": ( "contacts", "contacts", - Client, + Company, "get_contacts", - sync_clients, + sync_companies, {"include_archived": True}, "page", ), diff --git a/apps/workflow/api/xero/transforms.py b/apps/workflow/api/xero/transforms.py index 18b74d03d..58851c73e 100644 --- a/apps/workflow/api/xero/transforms.py +++ b/apps/workflow/api/xero/transforms.py @@ -9,7 +9,7 @@ from xero_python.accounting import AccountingApi from apps.accounting.models import Bill, CreditNote, Invoice, Quote -from apps.client.models import Client +from apps.company.models import Company from apps.purchasing.models import PurchaseOrder, PurchaseOrderLine, Stock from apps.purchasing.tasks import ( enqueue_stock_metadata_parse, @@ -17,7 +17,7 @@ ) from apps.workflow.api.xero.auth import api_client, get_tenant_id from apps.workflow.api.xero.reprocess_xero import ( - set_client_fields, + set_company_fields, set_invoice_or_bill_fields, ) from apps.workflow.exceptions import XeroValidationError @@ -119,11 +119,11 @@ def process_xero_data(xero_obj): return clean_json(serialize_xero_object(xero_obj)) -def get_or_fetch_client(contact_id, reference=None): - """Get client by Xero contact_id, fetching from API if needed""" - client = Client.objects.filter(xero_contact_id=contact_id).first() - if client: - return client.get_final_client() +def get_or_fetch_company(contact_id, reference=None): + """Get company by Xero contact_id, fetching from API if needed""" + company = Company.objects.filter(xero_contact_id=contact_id).first() + if company: + return company.get_final_company() response = AccountingApi(api_client).get_contacts( get_tenant_id(), i_ds=[contact_id], include_archived=True @@ -131,17 +131,17 @@ def get_or_fetch_client(contact_id, reference=None): time.sleep(SLEEP_TIME) if not response.contacts: - raise ValueError(f"Client not found for {reference or contact_id}") + raise ValueError(f"Company not found for {reference or contact_id}") - synced = sync_clients([response.contacts[0]]) + synced = sync_companies([response.contacts[0]]) if not synced: - raise ValueError(f"Failed to sync client for {reference or contact_id}") + raise ValueError(f"Failed to sync company for {reference or contact_id}") - return synced[0].get_final_client() + return synced[0].get_final_company() -def sync_client_from_xero_contact(contact, reference=None): - """Resolve a local client from embedded Xero contact data. +def sync_company_from_xero_contact(contact, reference=None): + """Resolve a local company from embedded Xero contact data. Use embedded contact payloads from parent Xero documents first. Only the caller decides when the payload is too incomplete and a separate API fetch @@ -149,33 +149,33 @@ def sync_client_from_xero_contact(contact, reference=None): """ contact_id = getattr(contact, "contact_id", None) if not contact_id: - raise ValueError(f"Client not found for {reference or 'missing contact'}") + raise ValueError(f"Company not found for {reference or 'missing contact'}") - client = Client.objects.filter(xero_contact_id=contact_id).first() - if client: - return client.get_final_client() + company = Company.objects.filter(xero_contact_id=contact_id).first() + if company: + return company.get_final_company() - synced = sync_clients([contact]) + synced = sync_companies([contact]) if not synced: - raise ValueError(f"Failed to sync client for {reference or contact_id}") + raise ValueError(f"Failed to sync company for {reference or contact_id}") - return synced[0].get_final_client() + return synced[0].get_final_company() -def resolve_client_from_xero_contact(contact, reference=None): - """Resolve a client from embedded contact data, falling back to GET only if absent.""" +def resolve_company_from_xero_contact(contact, reference=None): + """Resolve a company from embedded contact data, falling back to GET only if absent.""" if contact is None: - raise ValueError(f"Client not found for {reference or 'missing contact'}") + raise ValueError(f"Company not found for {reference or 'missing contact'}") contact_id = getattr(contact, "contact_id", None) if contact_id is None: - raise ValueError(f"Client not found for {reference or 'missing contact id'}") + raise ValueError(f"Company not found for {reference or 'missing contact id'}") contact_attrs = getattr(contact, "__dict__", None) or {} if len(contact_attrs) > 1: - return sync_client_from_xero_contact(contact, reference) + return sync_company_from_xero_contact(contact, reference) - return get_or_fetch_client(contact_id, reference) + return get_or_fetch_company(contact_id, reference) def sync_entities( @@ -259,7 +259,7 @@ def _extract_required_fields_xero(doc_type, xero_obj, xero_id): Mapping of required field names to values. """ number = _resolve_document_number(doc_type, xero_obj, xero_id) - client = resolve_client_from_xero_contact( + company = resolve_company_from_xero_contact( getattr(xero_obj, "contact", None), number ) date = getattr(xero_obj, "date", None) @@ -275,7 +275,7 @@ def _extract_required_fields_xero(doc_type, xero_obj, xero_id): raw_json = process_xero_data(xero_obj) required_fields = { - "client": client, + "company": company, "date": date, "number": number, "total_excl_tax": total_excl_tax, @@ -550,7 +550,7 @@ def transform_quote(xero_quote, xero_id): Returns: The saved Quote model. """ - client = resolve_client_from_xero_contact( + company = resolve_company_from_xero_contact( getattr(xero_quote, "contact", None), f"quote {xero_id}" ) raw_json = process_xero_data(xero_quote) @@ -560,7 +560,7 @@ def transform_quote(xero_quote, xero_id): validate_required_fields({"status": status}, "quote", xero_id) defaults = { - "client": client, + "company": company, "date": raw_json.get("_date"), "number": getattr(xero_quote, "quote_number", None), "status": status, @@ -595,7 +595,7 @@ def transform_purchase_order(xero_po, xero_id): "BILLED": "fully_received", "VOIDED": "deleted", } - supplier = resolve_client_from_xero_contact( + supplier = resolve_company_from_xero_contact( getattr(xero_po, "contact", None), xero_po.purchase_order_number ) @@ -868,32 +868,32 @@ def transform_pay_slip(xero_pay_slip, xero_id): return pay_slip, _build_sync_status(created, changed_fields) -def sync_clients(xero_contacts): - """Sync Xero contacts to Client model""" +def sync_companies(xero_contacts): + """Sync Xero contacts to Company model""" clients = [] for contact in xero_contacts: raw_json = process_xero_data(contact) - # Check if we already have a client with this xero_contact_id - existing_client = Client.objects.filter( + # Check if we already have a company with this xero_contact_id + existing_client = Company.objects.filter( xero_contact_id=contact.contact_id ).first() if existing_client: # Already linked - just update with latest Xero data - client = existing_client - client.raw_json = raw_json - client.xero_last_modified = timezone.now() - client.xero_archived = contact.contact_status == "ARCHIVED" - client.xero_merged_into_id = getattr(contact, "merged_to_contact_id", None) - client.save() + company = existing_client + company.raw_json = raw_json + company.xero_last_modified = timezone.now() + company.xero_archived = contact.contact_status == "ARCHIVED" + company.xero_merged_into_id = getattr(contact, "merged_to_contact_id", None) + company.save() created = False else: # Not linked yet - check if name already exists in our database contact_name = raw_json.get("_name", "").strip() if contact_name: - matching_client = Client.objects.filter(name=contact_name).first() + matching_client = Company.objects.filter(name=contact_name).first() if matching_client: if matching_client.xero_contact_id is None: @@ -909,22 +909,22 @@ def sync_clients(xero_contacts): ) matching_client.save() logger.info( - f"Linked existing client '{contact_name}' (ID: {matching_client.id}) to Xero contact {contact.contact_id}" + f"Linked existing company '{contact_name}' (ID: {matching_client.id}) to Xero contact {contact.contact_id}" ) - client = matching_client + company = matching_client created = False else: if contact.contact_status == "ARCHIVED": - # Archived contact with same name as an existing client + # Archived contact with same name as an existing company # linked to a different (active) Xero contact. This # commonly happens when Xero merges contacts — the old - # record is archived. Create a separate archived client. + # record is archived. Create a separate archived company. logger.warning( f"Archived Xero contact '{contact_name}' ({contact.contact_id}) " - f"has same name as client already linked to {matching_client.xero_contact_id}. " - f"Creating separate archived client record." + f"has same name as company already linked to {matching_client.xero_contact_id}. " + f"Creating separate archived company record." ) - client = Client.objects.create( + company = Company.objects.create( xero_contact_id=contact.contact_id, raw_json=raw_json, xero_last_modified=timezone.now(), @@ -940,8 +940,8 @@ def sync_clients(xero_contacts): f"Name '{contact_name}' already linked to Xero ID {matching_client.xero_contact_id}, cannot link to {contact.contact_id}" ) else: - # No existing client with this name - safe to create new one - client = Client.objects.create( + # No existing company with this name - safe to create new one + company = Company.objects.create( xero_contact_id=contact.contact_id, raw_json=raw_json, xero_last_modified=timezone.now(), @@ -953,7 +953,7 @@ def sync_clients(xero_contacts): created = True else: # No name in contact - create anyway - client = Client.objects.create( + company = Company.objects.create( xero_contact_id=contact.contact_id, raw_json=raw_json, xero_last_modified=timezone.now(), @@ -962,34 +962,34 @@ def sync_clients(xero_contacts): ) created = True - set_client_fields(client, new_from_xero=created) - clients.append(client) + set_company_fields(company, new_from_xero=created) + clients.append(company) - # Resolve merges and move stranded FK records onto the terminal client. - from apps.client.services.client_merge_service import reassign_client_fk_records + # Resolve merges and move stranded FK records onto the terminal company. + from apps.company.services.company_merge_service import reassign_company_fk_records - for client in clients: - if client.xero_merged_into_id and not client.merged_into: - merged_into = Client.objects.filter( - xero_contact_id=client.xero_merged_into_id + for company in clients: + if company.xero_merged_into_id and not company.merged_into: + merged_into = Company.objects.filter( + xero_contact_id=company.xero_merged_into_id ).first() if not merged_into: logger.warning( - "Deferred merge: client %s points at unsynced " + "Deferred merge: company %s points at unsynced " "xero_contact_id=%s; reassignment will retry next sync", - client.id, - client.xero_merged_into_id, + company.id, + company.xero_merged_into_id, ) continue - client.merged_into = merged_into - client.allow_jobs = False - client.save() - destination = client.get_final_client() - if destination.id != client.id: + company.merged_into = merged_into + company.allow_jobs = False + company.save() + destination = company.get_final_company() + if destination.id != company.id: from apps.accounts.models import Staff - reassign_client_fk_records( - client, + reassign_company_fk_records( + company, destination, Staff.get_automation_user(), logger_prefix="[batch-sync] ", diff --git a/apps/workflow/fixtures/company_defaults.json b/apps/workflow/fixtures/company_defaults.json index 0d6542400..f9880f264 100644 --- a/apps/workflow/fixtures/company_defaults.json +++ b/apps/workflow/fixtures/company_defaults.json @@ -1,6 +1,6 @@ [ { - "model": "client.client", + "model": "company.company", "pk": "00000000-0000-0000-0000-000000000001", "fields": { "xero_contact_id": null, @@ -75,8 +75,8 @@ "updated_at": "2024-01-01T00:00:00Z", "last_xero_sync": null, "last_xero_deep_sync": null, - "shop_client": "00000000-0000-0000-0000-000000000001", - "test_client_name": "ABC Carpet Cleaning TEST IGNORE", + "shop_company": "00000000-0000-0000-0000-000000000001", + "test_company_name": "ABC Carpet Cleaning TEST IGNORE", "kpi_daily_billable_hours_green": "45.00", "kpi_daily_billable_hours_amber": "35.00", "kpi_daily_gp_target": "1250.00", diff --git a/apps/workflow/fixtures/company_defaults_prospect.json b/apps/workflow/fixtures/company_defaults_prospect.json index a81e97c0a..eee5b85d7 100644 --- a/apps/workflow/fixtures/company_defaults_prospect.json +++ b/apps/workflow/fixtures/company_defaults_prospect.json @@ -1,6 +1,6 @@ [ { - "model": "client.client", + "model": "company.company", "pk": "00000000-0000-0000-0000-000000000001", "fields": { "xero_contact_id": null, @@ -75,8 +75,8 @@ "updated_at": "2024-01-01T00:00:00Z", "last_xero_sync": null, "last_xero_deep_sync": null, - "shop_client": "00000000-0000-0000-0000-000000000001", - "test_client_name": "ABC Carpet Cleaning TEST IGNORE", + "shop_company": "00000000-0000-0000-0000-000000000001", + "test_company_name": "ABC Carpet Cleaning TEST IGNORE", "kpi_daily_billable_hours_green": "45.00", "kpi_daily_billable_hours_amber": "35.00", "kpi_daily_gp_target": "1250.00", diff --git a/apps/workflow/fixtures/initial_data.json b/apps/workflow/fixtures/initial_data.json index 4b2e85335..69fae2b95 100644 --- a/apps/workflow/fixtures/initial_data.json +++ b/apps/workflow/fixtures/initial_data.json @@ -1,6 +1,6 @@ [ { - "model": "client.client", + "model": "company.company", "pk": "00000000-0000-0000-0000-000000000001", "fields": { "xero_contact_id": null, diff --git a/apps/workflow/management/commands/e2e_cleanup.py b/apps/workflow/management/commands/e2e_cleanup.py index 255039dde..20b5bd4f4 100644 --- a/apps/workflow/management/commands/e2e_cleanup.py +++ b/apps/workflow/management/commands/e2e_cleanup.py @@ -15,7 +15,7 @@ from django.db import transaction from apps.accounting.models import Invoice, Quote -from apps.client.models import Client, ClientContact +from apps.company.models import ClientContact, Company from apps.job.models import Job, QuoteSpreadsheet from apps.purchasing.models import PurchaseOrder, PurchaseOrderLine @@ -42,11 +42,11 @@ def handle(self, *args, **options): # Collect what to delete test_jobs = Job.objects.filter(name__startswith=TEST_DATA_PREFIX) test_contacts = ClientContact.objects.filter(name__startswith=TEST_DATA_PREFIX) - test_clients = Client.objects.filter(name__startswith=TEST_DATA_PREFIX) - # Swept so Job.client PROTECT doesn't block the [TEST] client delete. - test_prefix_client_jobs = Job.objects.filter(client__in=test_clients) + test_clients = Company.objects.filter(name__startswith=TEST_DATA_PREFIX) + # Swept so Job.company PROTECT doesn't block the [TEST] company delete. + test_prefix_client_jobs = Job.objects.filter(company__in=test_clients) test_prefix_client_contacts = ClientContact.objects.filter( - client__in=test_clients + company__in=test_clients ) # Legacy E2E-prefixed clients and their data @@ -55,14 +55,16 @@ def handle(self, *args, **options): legacy_q = Q() for prefix in LEGACY_E2E_PREFIXES: legacy_q |= Q(name__startswith=prefix) - legacy_clients = Client.objects.filter(legacy_q) - legacy_client_jobs = Job.objects.filter(client__in=legacy_clients) - legacy_client_contacts = ClientContact.objects.filter(client__in=legacy_clients) + legacy_clients = Company.objects.filter(legacy_q) + legacy_client_jobs = Job.objects.filter(company__in=legacy_clients) + legacy_client_contacts = ClientContact.objects.filter( + company__in=legacy_clients + ) - # Test client data (all jobs/contacts on the test client are test artifacts) - test_client_qs = Client.objects.filter(name=TEST_CLIENT_NAME) - test_client_jobs = Job.objects.filter(client__in=test_client_qs) - test_client_contacts = ClientContact.objects.filter(client__in=test_client_qs) + # Test company data (all jobs/contacts on the test company are test artifacts) + test_client_qs = Company.objects.filter(name=TEST_CLIENT_NAME) + test_client_jobs = Job.objects.filter(company__in=test_client_qs) + test_client_contacts = ClientContact.objects.filter(company__in=test_client_qs) # Report self.stdout.write("\n=== E2E Test Data ===\n") @@ -71,15 +73,15 @@ def handle(self, *args, **options): self._report_queryset("[TEST]-prefixed contacts", test_contacts, "name") self._report_queryset("[TEST]-prefixed clients", test_clients, "name") self._report_queryset("Legacy E2E clients", legacy_clients, "name") - self._report_queryset("Legacy E2E client jobs", legacy_client_jobs, "name") + self._report_queryset("Legacy E2E company jobs", legacy_client_jobs, "name") self._report_queryset( - "Legacy E2E client contacts", legacy_client_contacts, "name" + "Legacy E2E company contacts", legacy_client_contacts, "name" ) self._report_queryset( - f"Jobs on test client ({TEST_CLIENT_NAME})", test_client_jobs, "name" + f"Jobs on test company ({TEST_CLIENT_NAME})", test_client_jobs, "name" ) self._report_queryset( - f"Contacts on test client ({TEST_CLIENT_NAME})", + f"Contacts on test company ({TEST_CLIENT_NAME})", test_client_contacts, "name", ) @@ -203,20 +205,20 @@ def handle(self, *args, **options): if count: self.stdout.write(f" Quote spreadsheets: {count} objects ({details})") - # 4. Jobs on test client (cascade deletes cost sets, cost lines, etc.) + # 4. Jobs on test company (cascade deletes cost sets, cost lines, etc.) count, details = test_client_jobs.delete() - self.stdout.write(f" Test client jobs: {count} objects ({details})") + self.stdout.write(f" Test company jobs: {count} objects ({details})") - # 5. Contacts on test client + # 5. Contacts on test company count, details = test_client_contacts.delete() - self.stdout.write(f" Test client contacts: {count} objects ({details})") + self.stdout.write(f" Test company contacts: {count} objects ({details})") - # 6. Legacy client data + # 6. Legacy company data count, details = legacy_client_jobs.delete() - self.stdout.write(f" Legacy client jobs: {count} objects ({details})") + self.stdout.write(f" Legacy company jobs: {count} objects ({details})") count, details = legacy_client_contacts.delete() - self.stdout.write(f" Legacy client contacts: {count} objects ({details})") + self.stdout.write(f" Legacy company contacts: {count} objects ({details})") count, details = legacy_clients.delete() self.stdout.write(f" Legacy clients: {count} objects ({details})") @@ -228,7 +230,7 @@ def handle(self, *args, **options): count, details = test_contacts.delete() self.stdout.write(f" [TEST] contacts: {count} objects ({details})") - # 8. Jobs/contacts on [TEST]-prefixed clients (unblocks client delete) + # 8. Jobs/contacts on [TEST]-prefixed clients (unblocks company delete) count, details = test_prefix_client_jobs.delete() self.stdout.write(f" Jobs on [TEST] clients: {count} objects ({details})") diff --git a/apps/workflow/management/commands/relabel_client_app.py b/apps/workflow/management/commands/relabel_client_app.py new file mode 100644 index 000000000..d52395475 --- /dev/null +++ b/apps/workflow/management/commands/relabel_client_app.py @@ -0,0 +1,84 @@ +"""Relabel the historical `client` app to `company` (KAN-278 one-time surgery). + +Runs before `migrate` (deploy.sh calls it for every instance). Idempotent: +keys off django_migrations rows still recorded under the old label. + +The historic pre-squash rows (0001_initial .. 0023_drop_scalar_phone_fields) +are DELETED rather than relabelled: with the squash's `replaces` lists gone, +a blanket relabel would strand ("company", "0001_initial")-style ghost rows +that collide with any future company migration reusing one of those names +and can never be pruned. Only the baseline row carries state worth keeping. +""" + +from django.core.management.base import BaseCommand, CommandError +from django.db import connection, transaction + +TABLE_RENAMES = [ + ("client_client", "company_client"), + ("client_clientcontact", "company_clientcontact"), + ("client_clientcontactmethod", "company_clientcontactmethod"), + ("client_suppliersearchalias", "company_suppliersearchalias"), + ("client_supplierpickupaddress", "company_supplierpickupaddress"), +] + + +class Command(BaseCommand): + help = ( + "Relabel the 'client' app to 'company' in django_migrations/" + "content types/tables." + ) + + def handle( + self, + *args: object, # object: Django's untyped pass-through args; unused here + **options: object, # object: Django's untyped pass-through args; unused here + ) -> None: + # Atomic so a crash mid-surgery rolls back everything. The idempotence + # guard keys off django_migrations, so a half-applied state (UPDATE done, + # ALTERs not) must be impossible; Postgres DDL is transactional. + with transaction.atomic(), connection.cursor() as cursor: + cursor.execute( + "SELECT COUNT(*) FROM django_migrations WHERE app = 'client'" + ) + row = cursor.fetchone() + if row is None: + raise RuntimeError("django_migrations COUNT(*) query returned no row") + (stale_rows,) = row + if stale_rows == 0: + self.stdout.write("Already relabelled; nothing to do.") + return + cursor.execute( + "SELECT COUNT(*) FROM django_migrations " + "WHERE app = 'client' AND name = '0001_baseline'" + ) + baseline_row = cursor.fetchone() + if baseline_row is None: + raise RuntimeError("django_migrations COUNT(*) query returned no row") + (baseline_count,) = baseline_row + if baseline_count == 0: + raise CommandError( + "django_migrations has app='client' rows but no " + "('client', '0001_baseline') row - this database predates " + "the migration squash. Deploy a pre-squash release, run " + "migrate, then retry this deploy." + ) + cursor.execute( + "DELETE FROM django_migrations " + "WHERE app = 'client' AND name <> '0001_baseline'" + ) + cursor.execute( + "UPDATE django_migrations SET app = 'company' WHERE app = 'client'" + ) + cursor.execute( + "UPDATE django_content_type SET app_label = 'company' " + "WHERE app_label = 'client'" + ) + for old, new in TABLE_RENAMES: + cursor.execute(f'ALTER TABLE "{old}" RENAME TO "{new}"') + self.stdout.write( + self.style.SUCCESS( + f"Relabelled the client app: dropped {stale_rows - 1} historic " + "ledger rows, kept 0001_baseline, renamed content types and " + f"{len(TABLE_RENAMES)} tables." + ) + ) diff --git a/apps/workflow/management/commands/seed_xero_from_database.py b/apps/workflow/management/commands/seed_xero_from_database.py index fc73f68c7..965cdc6a3 100644 --- a/apps/workflow/management/commands/seed_xero_from_database.py +++ b/apps/workflow/management/commands/seed_xero_from_database.py @@ -14,7 +14,7 @@ from apps.accounting.models import Invoice, Quote from apps.accounts.models import Staff -from apps.client.models import Client +from apps.company.models import Company from apps.job.models import Job from apps.purchasing.models import Stock from apps.timesheet.services.payroll_employee_sync import PayrollEmployeeSyncService @@ -22,7 +22,7 @@ from apps.workflow.api.xero.payroll import sync_xero_pay_items from apps.workflow.api.xero.seed import ( fetch_xero_entity_lookup, - seed_clients_to_xero, + seed_companies_to_xero, seed_jobs_to_xero, ) from apps.workflow.api.xero.stock_sync import sync_all_local_stock_to_xero @@ -233,34 +233,34 @@ def process_accounts(self, dry_run): return updated + created def process_contacts(self, dry_run): - """Phase 1: Link/Create contacts for all clients with jobs + test client""" - # Validate test client exists - required for testing Xero flows + """Phase 1: Link/Create contacts for all clients with jobs + test company""" + # Validate test company exists - required for testing Xero flows cd = CompanyDefaults.get_solo() - if not cd.test_client_name: + if not cd.test_company_name: raise ValueError( - "CompanyDefaults.test_client_name is not set. " + "CompanyDefaults.test_company_name is not set. " "This is required for Xero sync testing." ) - test_client = Client.objects.filter(name=cd.test_client_name).first() + test_client = Company.objects.filter(name=cd.test_company_name).first() if not test_client: raise ValueError( - f"Test client '{cd.test_client_name}' not found in database. " - "Ensure the test client is created before running Xero sync." + f"Test company '{cd.test_company_name}' not found in database. " + "Ensure the test company is created before running Xero sync." ) # Find clients with jobs that need xero_contact_id client_ids_needing_sync = set( - Client.objects.filter( + Company.objects.filter( jobs__isnull=False, xero_contact_id__isnull=True ).values_list("id", flat=True) ) - # Also include test client if it needs syncing + # Also include test company if it needs syncing if not test_client.xero_contact_id: client_ids_needing_sync.add(test_client.id) - self.stdout.write(f"Including test client: {cd.test_client_name}") + self.stdout.write(f"Including test company: {cd.test_company_name}") - clients_needing_sync = Client.objects.filter(id__in=client_ids_needing_sync) + clients_needing_sync = Company.objects.filter(id__in=client_ids_needing_sync) self.stdout.write( f"Found {clients_needing_sync.count()} clients needing Xero contact IDs" @@ -271,10 +271,10 @@ def process_contacts(self, dry_run): return 0 if dry_run: - for client in clients_needing_sync[:10]: # Show first 10 - job_count = client.jobs.count() + for company in clients_needing_sync[:10]: # Show first 10 + job_count = company.jobs.count() self.stdout.write( - f" • Would process: {client.name} ({job_count} jobs)" + f" • Would process: {company.name} ({job_count} jobs)" ) if clients_needing_sync.count() > 10: self.stdout.write(f" ... and {clients_needing_sync.count() - 10} more") @@ -282,7 +282,7 @@ def process_contacts(self, dry_run): # Call sync module for bulk processing self.stdout.write("Processing clients with Xero sync module...") - results = seed_clients_to_xero(clients_needing_sync) + results = seed_companies_to_xero(clients_needing_sync) # Report results self.stdout.write( @@ -302,7 +302,7 @@ def process_projects(self, dry_run): """Phase 2: Create projects for all jobs whose clients have xero_contact_id""" # Find jobs that need xero_project_id jobs_needing_sync = Job.objects.filter( - client__xero_contact_id__isnull=False, xero_project_id__isnull=True + company__xero_contact_id__isnull=False, xero_project_id__isnull=True ) self.stdout.write( @@ -318,7 +318,7 @@ def process_projects(self, dry_run): if dry_run: for job in jobs_needing_sync[:10]: # Show first 10 self.stdout.write( - f" • Would create project: {job.name} (Client: {job.client.name})" + f" • Would create project: {job.name} (Company: {job.company.name})" ) if jobs_needing_sync.count() > 10: self.stdout.write(f" ... and {jobs_needing_sync.count() - 10} more") @@ -353,7 +353,7 @@ def process_invoices(self, dry_run): if dry_run: for inv in orphaned[:5]: self.stdout.write( - f" • Would delete: {inv.number} ({inv.client.name})" + f" • Would delete: {inv.number} ({inv.company.name})" ) if orphan_count > 5: self.stdout.write(f" ... and {orphan_count - 5} more") @@ -369,7 +369,7 @@ def process_invoices(self, dry_run): job_invoices = ( Invoice.objects.filter(job__isnull=False) .exclude(xero_tenant_id=xero_tenant_id) - .select_related("job", "client") + .select_related("job", "company") ) already_seeded = Invoice.objects.filter( job__isnull=False, xero_tenant_id=xero_tenant_id @@ -385,18 +385,18 @@ def process_invoices(self, dry_run): if dry_run: for inv in job_invoices[:10]: self.stdout.write( - f" • Would seed: {inv.number} - {inv.client.name} " + f" • Would seed: {inv.number} - {inv.company.name} " f"(${inv.total_excl_tax})" ) if job_invoices.count() > 10: self.stdout.write(f" ... and {job_invoices.count() - 10} more") return result - # Skip invoices whose client has no xero_contact_id (contacts not seeded) + # Skip invoices whose company has no xero_contact_id (contacts not seeded) skipped_no_contact = 0 invoices_to_seed = [] for inv in job_invoices: - if not inv.client.xero_contact_id: + if not inv.company.xero_contact_id: skipped_no_contact += 1 continue invoices_to_seed.append(inv) @@ -404,7 +404,7 @@ def process_invoices(self, dry_run): if skipped_no_contact > 0: self.stdout.write( f"Skipping {skipped_no_contact} invoices " - f"(client missing xero_contact_id - run contacts first)" + f"(company missing xero_contact_id - run contacts first)" ) # Fetch existing invoices from Xero to detect interrupted previous runs @@ -429,7 +429,7 @@ def process_invoices(self, dry_run): ) linked += 1 self.stdout.write( - f" ↳ Linked existing: {inv.number} ({inv.client.name})" + f" ↳ Linked existing: {inv.number} ({inv.company.name})" ) else: invoices_to_create.append(inv) @@ -450,8 +450,8 @@ def _build_invoice_payload(self, invoice): account_code = XeroAccount.objects.get(account_name="Sales").account_code contact = XeroContact( - contact_id=invoice.client.xero_contact_id, - name=invoice.client.name, + contact_id=invoice.company.xero_contact_id, + name=invoice.company.name, ) line_items = [] @@ -562,7 +562,7 @@ def _batch_create_invoices( ) created += 1 self.stdout.write( - f" ↳ Seeded: {local_inv.number} ({local_inv.client.name})" + f" ↳ Seeded: {local_inv.number} ({local_inv.company.name})" ) return created @@ -592,7 +592,9 @@ def process_quotes(self, dry_run): if orphan_count > 0: if dry_run: for q in orphaned[:5]: - self.stdout.write(f" - Would delete: {q.number} ({q.client.name})") + self.stdout.write( + f" - Would delete: {q.number} ({q.company.name})" + ) if orphan_count > 5: self.stdout.write(f" ... and {orphan_count - 5} more") else: @@ -607,7 +609,7 @@ def process_quotes(self, dry_run): job_quotes = ( Quote.objects.filter(job__isnull=False) .exclude(xero_tenant_id=xero_tenant_id) - .select_related("job", "client") + .select_related("job", "company") ) already_seeded = Quote.objects.filter( job__isnull=False, xero_tenant_id=xero_tenant_id @@ -623,18 +625,18 @@ def process_quotes(self, dry_run): if dry_run: for q in job_quotes[:10]: self.stdout.write( - f" - Would seed: {q.number} - {q.client.name} " + f" - Would seed: {q.number} - {q.company.name} " f"(${q.total_excl_tax})" ) if job_quotes.count() > 10: self.stdout.write(f" ... and {job_quotes.count() - 10} more") return result - # Skip quotes whose client has no xero_contact_id (contacts not seeded) + # Skip quotes whose company has no xero_contact_id (contacts not seeded) skipped_no_contact = 0 quotes_to_seed = [] for q in job_quotes: - if not q.client.xero_contact_id: + if not q.company.xero_contact_id: skipped_no_contact += 1 continue quotes_to_seed.append(q) @@ -657,7 +659,7 @@ def process_quotes(self, dry_run): q.xero_tenant_id = xero_tenant_id q.save(update_fields=["xero_id", "xero_tenant_id"]) linked += 1 - self.stdout.write(f" ↳ Linked existing: {q.number} ({q.client.name})") + self.stdout.write(f" ↳ Linked existing: {q.number} ({q.company.name})") else: quotes_to_create.append(q) @@ -677,8 +679,8 @@ def _build_quote_payload(self, quote): account_code = XeroAccount.objects.get(account_name="Sales").account_code contact = XeroContact( - contact_id=quote.client.xero_contact_id, - name=quote.client.name, + contact_id=quote.company.xero_contact_id, + name=quote.company.name, ) description = f"Job: {quote.job.job_number}" @@ -756,7 +758,7 @@ def _batch_create_quotes( local_q.save(update_fields=["xero_id", "xero_tenant_id"]) created += 1 self.stdout.write( - f" ↳ Seeded: {local_q.number} ({local_q.client.name})" + f" ↳ Seeded: {local_q.number} ({local_q.company.name})" ) return created @@ -915,7 +917,7 @@ def process_employees(self, dry_run): def clear_production_xero_ids(self, dry_run): """Clear production Xero IDs from all relevant tables.""" # Refuse if the configured DB is a prod instance. The naming - # standard `dw__` (`scripts/server/instance.sh:171`) + # standard `dw__` (`scripts/server/instance.sh:171`) # validates env against {dev,uat,staging,prod}, so the `_prod` # suffix is a deterministic instance-scoped signal — works on # multi-instance servers where `/etc/machine-id` is shared. @@ -943,17 +945,19 @@ def clear_production_xero_ids(self, dry_run): tables_cleared = [] with connection.cursor() as cursor: - # Clear client contact IDs - allows re-linking by name - self.stdout.write("Clearing client xero_contact_id values...") - if self._table_exists(cursor, "client_client"): + # Clear company contact IDs - allows re-linking by name + self.stdout.write("Clearing company xero_contact_id values...") + if self._table_exists(cursor, "company_company"): cursor.execute( - "UPDATE client_client SET xero_contact_id = NULL WHERE xero_contact_id IS NOT NULL" + "UPDATE company_company SET xero_contact_id = NULL WHERE xero_contact_id IS NOT NULL" ) - client_count = cursor.rowcount - if client_count > 0: - tables_cleared.append(f"client_client: {client_count} records") + company_count = cursor.rowcount + if company_count > 0: + tables_cleared.append(f"company_company: {company_count} records") else: - self.stdout.write(" WARNING: client_client table not found - skipping") + self.stdout.write( + " WARNING: company_company table not found - skipping" + ) # Clear job project IDs - allows fresh project sync self.stdout.write("Clearing job xero_project_id values...") diff --git a/apps/workflow/middleware.py b/apps/workflow/middleware.py index c4fb6443f..518aabc25 100644 --- a/apps/workflow/middleware.py +++ b/apps/workflow/middleware.py @@ -234,8 +234,8 @@ def __init__(self, get_response: Callable[[HttpRequest], HttpResponse]) -> None: self.exempt_url_prefixes.append("/api/schema/") def __call__(self, request: HttpRequest) -> HttpResponse: - # Debug logging for client create endpoint - if request.path_info == "/api/clients/create/": + # Debug logging for company create endpoint + if request.path_info == "/api/companies/create/": access_logger.info( f"DEBUG LoginRequiredMiddleware: Processing {request.path_info}" ) @@ -248,7 +248,7 @@ def __call__(self, request: HttpRequest) -> HttpResponse: # In DEBUG mode, skip login requirements entirely if settings.DEBUG: - if request.path_info == "/api/clients/create/": + if request.path_info == "/api/companies/create/": access_logger.info( "DEBUG LoginRequiredMiddleware: Skipping due to DEBUG mode" ) @@ -279,7 +279,7 @@ def __call__(self, request: HttpRequest) -> HttpResponse: if any( request.path_info.startswith(endpoint) for endpoint in drf_endpoints ): - if request.path_info == "/api/clients/create/": + if request.path_info == "/api/companies/create/": access_logger.info( f"DEBUG LoginRequiredMiddleware: Allowing DRF endpoint {request.path_info}" ) diff --git a/apps/workflow/migrations/0001_baseline.py b/apps/workflow/migrations/0001_baseline.py index 09e54e6ba..47bdaa354 100644 --- a/apps/workflow/migrations/0001_baseline.py +++ b/apps/workflow/migrations/0001_baseline.py @@ -14,7 +14,7 @@ class Migration(migrations.Migration): initial = True dependencies = [ - ("client", "0001_baseline"), + ("company", "0001_baseline"), migrations.swappable_dependency(settings.AUTH_USER_MODEL), ] @@ -1018,7 +1018,7 @@ class Migration(migrations.Migration): help_text="Internal client used for tracking shop work.", on_delete=django.db.models.deletion.PROTECT, related_name="+", - to="client.client", + to="company.client", ), ), ], diff --git a/apps/workflow/migrations/0005_rename_shop_client_fields.py b/apps/workflow/migrations/0005_rename_shop_client_fields.py new file mode 100644 index 000000000..826459764 --- /dev/null +++ b/apps/workflow/migrations/0005_rename_shop_client_fields.py @@ -0,0 +1,20 @@ +from django.db import migrations + + +class Migration(migrations.Migration): + dependencies = [ + ("workflow", "0004_drop_orphaned_company_name_index"), + ] + + operations = [ + migrations.RenameField( + model_name="companydefaults", + old_name="shop_client", + new_name="shop_company", + ), + migrations.RenameField( + model_name="companydefaults", + old_name="test_client_name", + new_name="test_company_name", + ), + ] diff --git a/apps/workflow/migrations/0006_alter_companydefaults_shop_company_and_more.py b/apps/workflow/migrations/0006_alter_companydefaults_shop_company_and_more.py new file mode 100644 index 000000000..e673b5cc1 --- /dev/null +++ b/apps/workflow/migrations/0006_alter_companydefaults_shop_company_and_more.py @@ -0,0 +1,35 @@ +# Generated by Django 6.0.4 on 2026-07-06 10:20 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("company", "0003_alter_clientcontact_company_and_more"), + ("workflow", "0005_rename_shop_client_fields"), + ] + + operations = [ + migrations.AlterField( + model_name="companydefaults", + name="shop_company", + field=models.ForeignKey( + help_text="Internal company used for tracking shop work.", + on_delete=django.db.models.deletion.PROTECT, + related_name="+", + to="company.company", + ), + ), + migrations.AlterField( + model_name="companydefaults", + name="test_company_name", + field=models.CharField( + blank=True, + help_text="Name of the test company used for testing (e.g., 'ABC Carpet Cleaning TEST IGNORE'). This company's name is preserved during data backports.", + max_length=255, + null=True, + ), + ), + ] diff --git a/apps/workflow/models/company_defaults.py b/apps/workflow/models/company_defaults.py index ecc14b336..567a75d54 100644 --- a/apps/workflow/models/company_defaults.py +++ b/apps/workflow/models/company_defaults.py @@ -250,19 +250,19 @@ class CompanyDefaults(SingletonModel): help_text="Wide company logo for letterheads and PDFs", ) - shop_client = models.ForeignKey( - "client.Client", + shop_company = models.ForeignKey( + "company.Company", on_delete=models.PROTECT, related_name="+", - help_text="Internal client used for tracking shop work.", + help_text="Internal company used for tracking shop work.", ) - # Test client configuration - test_client_name = models.CharField( + # Test company configuration + test_company_name = models.CharField( max_length=255, null=True, blank=True, - help_text="Name of the test client used for testing (e.g., 'ABC Carpet Cleaning TEST IGNORE'). This client's name is preserved during data backports.", + help_text="Name of the test company used for testing (e.g., 'ABC Carpet Cleaning TEST IGNORE'). This company's name is preserved during data backports.", ) # KPI thresholds — all daily unless noted otherwise diff --git a/apps/workflow/models/search_telemetry_event.py b/apps/workflow/models/search_telemetry_event.py index 5d0e2c7fa..e0972e8b2 100644 --- a/apps/workflow/models/search_telemetry_event.py +++ b/apps/workflow/models/search_telemetry_event.py @@ -6,7 +6,7 @@ class SearchTelemetryEvent(models.Model): - """Search/click telemetry shared by client, Kanban, and stock search.""" + """Search/click telemetry shared by company, Kanban, and stock search.""" class EventType(models.TextChoices): SEARCH = "search", "Search" diff --git a/apps/workflow/models/settings_metadata.py b/apps/workflow/models/settings_metadata.py index 1bab5e9c8..2b6b59b95 100644 --- a/apps/workflow/models/settings_metadata.py +++ b/apps/workflow/models/settings_metadata.py @@ -135,8 +135,8 @@ def get_section_info(cls, key: str) -> tuple[str, str, int] | None: "starting_job_number": "setup", "starting_po_number": "setup", "po_prefix": "setup", - "shop_client": "setup", - "test_client_name": "setup", + "shop_company": "setup", + "test_company_name": "setup", # Xero integration "accounting_provider": "xero", "xero_tenant_id": "xero", @@ -165,8 +165,8 @@ def get_ui_type_for_field(field: "models.Field[Any, Any]") -> str: """ related_model = getattr(field.remote_field, "model", None) related_label = getattr(getattr(related_model, "_meta", None), "label", None) - if isinstance(field, models.ForeignKey) and related_label == "client.Client": - return "client" + if isinstance(field, models.ForeignKey) and related_label == "company.Company": + return "company" for field_class, ui_type in DJANGO_TO_UI_TYPE.items(): if isinstance(field, field_class): diff --git a/apps/workflow/serializers.py b/apps/workflow/serializers.py index 2dd2f1c98..6eb2d1dc7 100644 --- a/apps/workflow/serializers.py +++ b/apps/workflow/serializers.py @@ -93,7 +93,7 @@ class XeroAppSerializer(serializers.ModelSerializer): """List / detail / PATCH serializer for XeroApp. client_secret and webhook_key are write-only — never returned. The - webhook signing key is comparable in sensitivity to the client secret + webhook signing key is comparable in sensitivity to the company secret (anyone holding it can forge webhook deliveries that we'd verify as authentic), so it gets the same treatment. access_token / refresh_token are not surfaced at all; instead a derived has_tokens @@ -330,7 +330,7 @@ class XeroDocumentSuccessResponseSerializer(serializers.Serializer): ) # Fields returned by Invoice/Quote managers - client = serializers.CharField(required=False, help_text="Name of the client.") + company = serializers.CharField(required=False, help_text="Name of the company.") total_excl_tax = serializers.DecimalField( max_digits=12, decimal_places=2, required=False ) @@ -630,7 +630,7 @@ class GroupedErrorResolveRequestSerializer(serializers.Serializer): iterates the unresolved rows, computes each message's hash, and cascades the resolve across every row whose hash matches. - Using a fingerprint (not the message string) avoids client-side + Using a fingerprint (not the message string) avoids company-side whitespace mangling: the frontend's global axios interceptor calls trimStringsDeep on outbound payloads, which would strip trailing whitespace and prevent a later exact match. diff --git a/apps/workflow/services/db_scrubber.py b/apps/workflow/services/db_scrubber.py index c8916ff8c..f2810afd0 100644 --- a/apps/workflow/services/db_scrubber.py +++ b/apps/workflow/services/db_scrubber.py @@ -31,7 +31,7 @@ ) from apps.accounts.models import SYSTEM_AUTOMATION_EMAIL, Staff from apps.accounts.staff_anonymization import create_staff_profile -from apps.client.models import Client, ClientContact, ClientContactMethod +from apps.company.models import ClientContact, ClientContactMethod, Company from apps.workflow.models import CompanyDefaults from apps.workflow.services.error_persistence import persist_app_error @@ -83,15 +83,15 @@ def _scrub_staff() -> None: ) -def _preserved_client_names() -> set[str]: - """Names that must survive scrubbing: shop, test client, scraper suppliers. +def _preserved_company_names() -> set[str]: + """Names that must survive scrubbing: shop, test company, scraper suppliers. - Mirrors legacy backport_data_backup._get_preserved_client_names() exactly. + Mirrors legacy backport_data_backup._get_preserved_company_names() exactly. """ preserved: set[str] = set() cd = CompanyDefaults.objects.using(SCRUB_ALIAS).get() - preserved.add(cd.shop_client.name) - preserved.add(cd.test_client_name) + preserved.add(cd.shop_company.name) + preserved.add(cd.test_company_name) from apps.quoting.models import SupplierScraperConfig @@ -115,7 +115,7 @@ def _unique_scrub_value( no two scrubbed contact methods can share a normalized value. That keeps the scrub clear of the per-owner unique constraints even against a real, not-yet-scrubbed number still in the table, without relying on the - one-number-one-client guard in ``ClientContactMethod.save()`` (which + one-number-one-company guard in ``ClientContactMethod.save()`` (which ``bulk_update`` deliberately bypasses). """ for _ in range(1000): @@ -130,8 +130,8 @@ def _unique_scrub_value( ) -def _scrub_clients() -> None: - """Mirror legacy PII_CONFIG entries for client.client and client.clientcontact. +def _scrub_companies() -> None: + """Mirror legacy PII_CONFIG entries for company.company and company.clientcontact. Top-level fields touched: name (with allow-list), primary_contact_name, primary_contact_email, email. @@ -143,10 +143,10 @@ def _scrub_clients() -> None: other raw_json keys) is left untouched — matches today's behaviour exactly. """ fake = Faker() - preserved = _preserved_client_names() + preserved = _preserved_company_names() used_company_names: set[str] = set() - for client in Client.objects.using(SCRUB_ALIAS).exclude(name__in=preserved): + for company in Company.objects.using(SCRUB_ALIAS).exclude(name__in=preserved): for _ in range(1000): candidate = fake.company() if candidate not in used_company_names: @@ -156,12 +156,12 @@ def _scrub_clients() -> None: raise RuntimeError( "Failed to generate unique company name after 1000 attempts" ) - client.name = candidate - client.primary_contact_name = fake.name() - client.primary_contact_email = fake.email() - client.email = fake.email() + company.name = candidate + company.primary_contact_name = fake.name() + company.primary_contact_email = fake.email() + company.email = fake.email() - rj = client.raw_json or {} + rj = company.raw_json or {} if "_name" in rj: rj["_name"] = candidate if "_email_address" in rj: @@ -178,8 +178,8 @@ def _scrub_clients() -> None: bp["_bank_account_number"] = fake.iban() if "_bank_account_name" in bp: bp["_bank_account_name"] = fake.name() - client.raw_json = rj - client.save(using=SCRUB_ALIAS) + company.raw_json = rj + company.save(using=SCRUB_ALIAS) for contact in ClientContact.objects.using(SCRUB_ALIAS).all(): contact.name = fake.name() @@ -189,15 +189,15 @@ def _scrub_clients() -> None: update_fields=["name", "email"], ) - # Preserved clients (shop, test, enabled scrapers) keep their real contact + # Preserved companies (shop, test, enabled scrapers) keep their real contact # methods, matching the name/email exclusion above. A method is preserved - # whether it is owned directly by the client or via one of its contacts. + # whether it is owned directly by the company or via one of its contacts. used_method_values: set[tuple[str, str]] = set() methods_to_update: list[ClientContactMethod] = [] for method in ( ClientContactMethod.objects.using(SCRUB_ALIAS) - .exclude(client__name__in=preserved) - .exclude(contact__client__name__in=preserved) + .exclude(company__name__in=preserved) + .exclude(contact__company__name__in=preserved) ): if method.method_type == ClientContactMethod.MethodType.PHONE: generate = fake.phone_number @@ -211,7 +211,7 @@ def _scrub_clients() -> None: method.value = value method.normalized_value = normalized methods_to_update.append(method) - # bulk_update bypasses ClientContactMethod.save(), so the one-number-one-client + # bulk_update bypasses ClientContactMethod.save(), so the one-number-one-company # guard and primary-demotion logic (neither of which is a business operation # during a scrub) never run and cannot abort the transaction on a collision. ClientContactMethod.objects.using(SCRUB_ALIAS).bulk_update( @@ -305,7 +305,7 @@ def scrub() -> None: with transaction.atomic(using=SCRUB_ALIAS): # Per-step helpers added by subsequent tasks. _scrub_staff() - _scrub_clients() + _scrub_companies() _scrub_accounting_contacts() _delete_unlinked_accounting() _truncate_excluded_tables() diff --git a/apps/workflow/tests/test_api_schema_coverage.py b/apps/workflow/tests/test_api_schema_coverage.py index 9a36cc624..5a8028023 100644 --- a/apps/workflow/tests/test_api_schema_coverage.py +++ b/apps/workflow/tests/test_api_schema_coverage.py @@ -35,7 +35,7 @@ "api/enums/", # DRF router roots (meta-endpoints listing sub-routes, not real APIs) "api/workflow/", - "api/clients/", + "api/companies/", "api/crm/", "api/purchasing/", "api/quoting/", diff --git a/apps/workflow/tests/test_company_defaults_api.py b/apps/workflow/tests/test_company_defaults_api.py index 6eca7a986..28859f61c 100644 --- a/apps/workflow/tests/test_company_defaults_api.py +++ b/apps/workflow/tests/test_company_defaults_api.py @@ -12,24 +12,24 @@ def setUp(self): self.client = APIClient() self.client.force_authenticate(user=self.test_staff) - def test_get_returns_shop_client_fk_without_name_alias(self): + def test_get_returns_shop_company_fk_without_name_alias(self): response = self.client.get("/api/company-defaults/") self.assertEqual(response.status_code, status.HTTP_200_OK) - self.assertIn("shop_client", response.data) - self.assertNotIn("shop_client_name", response.data) + self.assertIn("shop_company", response.data) + self.assertNotIn("shop_company_name", response.data) - def test_get_does_not_query_client_for_shop_client_display_name(self): + def test_get_does_not_query_company_for_shop_company_display_name(self): with CaptureQueriesContext(connection) as captured: response = self.client.get("/api/company-defaults/") self.assertEqual(response.status_code, status.HTTP_200_OK) - client_queries = [ + company_queries = [ query["sql"] for query in captured.captured_queries - if 'FROM "client_client"' in query["sql"] + if 'FROM "company_company"' in query["sql"] ] - self.assertEqual(client_queries, []) + self.assertEqual(company_queries, []) def test_patch_canonicalizes_blank_optional_urls_to_null(self): response = self.client.patch( diff --git a/apps/workflow/tests/test_data_versions_view.py b/apps/workflow/tests/test_data_versions_view.py index e3351ab4e..ada90278b 100644 --- a/apps/workflow/tests/test_data_versions_view.py +++ b/apps/workflow/tests/test_data_versions_view.py @@ -1,5 +1,5 @@ """Guards against stale-data regressions: any write to Stock, Job, Staff -assignments, or Client contacts must bump the corresponding data-version +assignments, or Company contacts must bump the corresponding data-version string so the frontend invalidates its cache and re-fetches. """ @@ -10,7 +10,7 @@ from rest_framework.test import APIClient from apps.accounts.models import Staff -from apps.client.models import Client, ClientContact +from apps.company.models import ClientContact, Company from apps.crm.models import PhoneCallRecord, PhoneCallRecording from apps.job.models import Job from apps.job.services.job_service import JobStaffService @@ -51,10 +51,10 @@ def _stock(**overrides): @pytest.fixture def kanban_prerequisites(db): - shop_client = _client(name="Shop Client") + shop_company = _client(name="Shop Company") CompanyDefaults.objects.get_or_create( company_name="Test Co", - defaults={"shop_client": shop_client}, + defaults={"shop_company": shop_company}, ) XeroPayItem.objects.get_or_create( name="Ordinary Time", @@ -64,15 +64,15 @@ def kanban_prerequisites(db): def _client(**overrides): - defaults = dict(name="Kanban Client", xero_last_modified=timezone.now()) + defaults = dict(name="Kanban Company", xero_last_modified=timezone.now()) defaults.update(overrides) - return Client.objects.create(**defaults) + return Company.objects.create(**defaults) def _job(staff, **overrides): defaults = dict( staff=staff, - client=_client(), + company=_client(), name="Kanban Job", pricing_methodology="time_materials", ) @@ -201,9 +201,9 @@ def test_assigning_staff_changes_kanban_version( def test_related_display_changes_change_kanban_version( auth_client, office_staff, kanban_prerequisites ): - client = _client(name="Original Client") - contact = ClientContact.objects.create(client=client, name="Original Contact") - _job(office_staff, client=client, contact=contact) + company = _client(name="Original Company") + contact = ClientContact.objects.create(company=company, name="Original Contact") + _job(office_staff, company=company, contact=contact) before = auth_client.get("/api/data-versions/").json()["kanban"] contact.name = "Updated Contact" contact.save(update_fields=["name"]) @@ -214,11 +214,11 @@ def test_related_display_changes_change_kanban_version( def test_client_partial_save_changes_kanban_version( auth_client: APIClient, office_staff: Staff, kanban_prerequisites: None ) -> None: - client = _client(name="Original Client") - _job(office_staff, client=client) + company = _client(name="Original Company") + _job(office_staff, company=company) before = auth_client.get("/api/data-versions/").json()["kanban"] - client.name = "Updated Client" - client.save(update_fields=["name"]) + company.name = "Updated Company" + company.save(update_fields=["name"]) after = auth_client.get("/api/data-versions/").json()["kanban"] assert before != after diff --git a/apps/workflow/tests/test_db_scrubber.py b/apps/workflow/tests/test_db_scrubber.py index f254323c1..af622759d 100644 --- a/apps/workflow/tests/test_db_scrubber.py +++ b/apps/workflow/tests/test_db_scrubber.py @@ -7,7 +7,7 @@ from django.test import SimpleTestCase -from apps.client.models import ClientContactMethod +from apps.company.models import ClientContactMethod from apps.workflow.services.db_scrubber import _unique_scrub_value PHONE = ClientContactMethod.MethodType.PHONE diff --git a/apps/workflow/tests/test_dev_demo_export.py b/apps/workflow/tests/test_dev_demo_export.py index 48b50472a..1dfb0a96a 100644 --- a/apps/workflow/tests/test_dev_demo_export.py +++ b/apps/workflow/tests/test_dev_demo_export.py @@ -8,7 +8,7 @@ from django.utils import timezone from apps.accounts.models import Staff -from apps.client.models import Client, ClientContactMethod +from apps.company.models import ClientContactMethod, Company from apps.crm.models import ( PhoneCallRecord, PhoneCallRecording, @@ -42,21 +42,21 @@ def test_dev_demo_scrub_preserves_business_signal_and_redacts_risk(): first_name="Demo", last_name="Staff", ) - client = Client.objects.create( - name="Realistic Client Ltd", - email="client@example.test", + company = Company.objects.create( + name="Realistic Company Ltd", + email="company@example.test", xero_last_modified=timezone.now(), - raw_json={"_name": "Realistic Client Ltd"}, + raw_json={"_name": "Realistic Company Ltd"}, ) ClientContactMethod.objects.create( - client=client, + company=company, method_type=ClientContactMethod.MethodType.PHONE, value="+64211234567", is_primary=True, ) CompanyDefaults.objects.create( company_name="Demo Co", - shop_client=client, + shop_company=company, ) PhoneProviderSettings.objects.update_or_create( pk=1, @@ -109,7 +109,7 @@ def test_dev_demo_scrub_preserves_business_signal_and_redacts_risk(): destination="+64212222222", duration_seconds=180, charge=Decimal("1.2300"), - client=client, + company=company, raw_json={"phone": "+64212222222", "provider": "payload"}, ) PhoneCallRecording.objects.create( @@ -129,7 +129,7 @@ def test_dev_demo_scrub_preserves_business_signal_and_redacts_risk(): ) recording = SessionReplayRecording.objects.create( user=staff, - initial_path="/clients?email=client@example.test", + initial_path="/clients?email=company@example.test", latest_path="/jobs/secret", user_agent="Browser", ) @@ -147,11 +147,11 @@ def test_dev_demo_scrub_preserves_business_signal_and_redacts_risk(): SearchTelemetryEvent.objects.create( event_type=SearchTelemetryEvent.EventType.CLICK, domain=SearchTelemetryEvent.Domain.CLIENT, - query="client@example.test", - normalized_query="client@example.test", - selected_result_id=str(client.id), - selected_label="Realistic Client Ltd", - metadata={"email": "client@example.test"}, + query="company@example.test", + normalized_query="company@example.test", + selected_result_id=str(company.id), + selected_label="Realistic Company Ltd", + metadata={"email": "company@example.test"}, ) pay_run = XeroPayRun.objects.create( xero_id=uuid.uuid4(), @@ -181,9 +181,9 @@ def test_dev_demo_scrub_preserves_business_signal_and_redacts_risk(): results = scrub_dev_demo_export(using="default") assert {result.name for result in results} - client.refresh_from_db() - assert client.name == "Realistic Client Ltd" - assert client.email == "client@example.test" + company.refresh_from_db() + assert company.name == "Realistic Company Ltd" + assert company.email == "company@example.test" xero_app = XeroApp.objects.get() assert xero_app.client_id == "client-id" @@ -205,7 +205,7 @@ def test_dev_demo_scrub_preserves_business_signal_and_redacts_risk(): call.refresh_from_db() assert call.duration_seconds == 180 assert call.charge == Decimal("1.2300") - assert call.client == client + assert call.company == company assert call.origin.startswith("demo-number-") assert call.destination.startswith("demo-number-") assert call.raw_json == {} diff --git a/apps/workflow/tests/test_localdate_regression.py b/apps/workflow/tests/test_localdate_regression.py index f953d2073..1c1382ded 100644 --- a/apps/workflow/tests/test_localdate_regression.py +++ b/apps/workflow/tests/test_localdate_regression.py @@ -25,16 +25,16 @@ UTC_DATE = datetime.date(2026, 4, 27) -def _make_client(name="Localdate Test Client"): - from apps.client.models import Client +def _make_client(name="Localdate Test Company"): + from apps.company.models import Company - return Client.objects.create(name=name, xero_last_modified=timezone.now()) + return Company.objects.create(name=name, xero_last_modified=timezone.now()) -def _make_job(client, staff, name="Localdate Test Job", **extra): +def _make_job(company, staff, name="Localdate Test Job", **extra): from apps.job.models import Job - job = Job(client=client, name=name, **extra) + job = Job(company=company, name=name, **extra) job.save(staff=staff) return job @@ -145,8 +145,8 @@ class JobAgingLocalDateTests(BaseTestCase): def _make_aged_job(self): from apps.job.models import Job - client = _make_client("Aging Test Client") - job = _make_job(client, self.test_staff, name="Aging Test Job") + company = _make_client("Aging Test Company") + job = _make_job(company, self.test_staff, name="Aging Test Job") # Frozen "now" = UTC 2026-04-27 23:30 = NZ 2026-04-28 11:30 (UTC date # April 27, NZ date April 28). For the bug to actually change the # answer, the past timestamp must NOT also straddle midnight in the @@ -221,16 +221,16 @@ class XeroInvoiceLocalDateTests(BaseTestCase): def _make_manager(self, *, is_account_customer): from apps.workflow.views.xero.xero_invoice_manager import XeroInvoiceManager - client = _make_client("Xero Invoice Test Client") - client.is_account_customer = is_account_customer - client.save() + company = _make_client("Xero Invoice Test Company") + company.is_account_customer = is_account_customer + company.save() job = _make_job( - client, + company, self.test_staff, name="Xero Invoice Test", pricing_methodology="fixed_price", ) - return XeroInvoiceManager(client=client, job=job, staff=self.test_staff) + return XeroInvoiceManager(company=company, job=job, staff=self.test_staff) def test_build_payload_uses_nz_local_date(self): manager = self._make_manager(is_account_customer=False) @@ -244,7 +244,7 @@ def test_build_payload_uses_nz_local_date(self): self.assertEqual(payload.date, NZ_DATE) def test_account_customer_due_date_is_20th_of_next_month(self): - """`Client.is_account_customer=True` → due on the 20th of next month.""" + """`Company.is_account_customer=True` → due on the 20th of next month.""" manager = self._make_manager(is_account_customer=True) with ( @@ -257,7 +257,7 @@ def test_account_customer_due_date_is_20th_of_next_month(self): self.assertEqual(payload.due_date, datetime.date(2026, 5, 20)) def test_cash_customer_due_date_is_same_day(self): - """`Client.is_account_customer=False` → due same-day.""" + """`Company.is_account_customer=False` → due same-day.""" manager = self._make_manager(is_account_customer=False) with ( @@ -309,14 +309,14 @@ class XeroQuoteLocalDateTests(BaseTestCase): def test_build_payload_uses_nz_local_date(self): from apps.workflow.views.xero.xero_quote_manager import XeroQuoteManager - client = _make_client("Xero Quote Test Client") + company = _make_client("Xero Quote Test Company") job = _make_job( - client, + company, self.test_staff, name="Xero Quote Test", pricing_methodology="fixed_price", ) - manager = XeroQuoteManager(client=client, job=job, staff=self.test_staff) + manager = XeroQuoteManager(company=company, job=job, staff=self.test_staff) with ( freeze_time(FROZEN_UTC_MOMENT), @@ -337,8 +337,8 @@ def test_consume_creates_cost_line_with_nz_local_accounting_date(self): from apps.purchasing.models import Stock from apps.purchasing.services.stock_service import consume_stock - client = _make_client("Stock Test Client") - job = _make_job(client, self.test_staff, name="Stock Test Job") + company = _make_client("Stock Test Company") + job = _make_job(company, self.test_staff, name="Stock Test Job") item = Stock.objects.create( description="Test material", quantity=Decimal("10.00"), @@ -368,10 +368,10 @@ def test_archived_date_uses_nz_local(self): ArchivedJobsComplianceService, ) - client = _make_client("DQ Test Client") + company = _make_client("DQ Test Company") # Archived, not invoiced, not paid → produces a non-compliant row. job = _make_job( - client, + company, self.test_staff, name="DQ Test Job", status="archived", diff --git a/apps/workflow/tests/test_push_contacts.py b/apps/workflow/tests/test_push_contacts.py index 96772ef39..84c856a3d 100644 --- a/apps/workflow/tests/test_push_contacts.py +++ b/apps/workflow/tests/test_push_contacts.py @@ -14,7 +14,7 @@ from django.utils import timezone from xero_python.accounting.models import Contact, Contacts -from apps.client.models import Client, ClientContactMethod +from apps.company.models import ClientContactMethod, Company from apps.workflow.tests.fixtures.xero_responses import make_create_contacts_response @@ -27,15 +27,15 @@ def _make_client(**overrides): } phone = overrides.pop("phone", None) defaults.update(overrides) - client = Client.objects.create(**defaults) + company = Company.objects.create(**defaults) if phone is not None: ClientContactMethod.objects.create( - client=client, + company=company, method_type=ClientContactMethod.MethodType.PHONE, value=phone, is_primary=True, ) - return client + return company def _create_response(contact_id, name): @@ -48,20 +48,20 @@ def _create_response(contact_id, name): @patch("apps.workflow.api.xero.push.get_tenant_id", return_value="tenant-1") @patch("apps.workflow.api.xero.push.AccountingApi") class SyncClientToXeroPushTests(TestCase): - """sync_client_to_xero — both branches must pass Contact instances.""" + """sync_company_to_xero — both branches must pass Contact instances.""" def test_new_client_calls_create_contacts_with_sdk_contact( self, mock_api_class, _mock_tenant, _mock_sleep ): - client = _make_client(phone="027 351 8326") + company = _make_client(phone="027 351 8326") mock_api = mock_api_class.return_value mock_api.create_contacts.return_value = _create_response( - "00000000-0000-0000-0000-000000000001", client.name + "00000000-0000-0000-0000-000000000001", company.name ) - from apps.workflow.api.xero.push import sync_client_to_xero + from apps.workflow.api.xero.push import sync_company_to_xero - result = sync_client_to_xero(client) + result = sync_company_to_xero(company) self.assertTrue(result) mock_api.create_contacts.assert_called_once() @@ -75,13 +75,13 @@ def test_existing_client_calls_update_contact_with_sdk_contact( self, mock_api_class, _mock_tenant, _mock_sleep ): xero_id = "9568adbc-aaaa-bbbb-cccc-000000000001" - client = _make_client(xero_contact_id=xero_id, phone="027 351 8327") + company = _make_client(xero_contact_id=xero_id, phone="027 351 8327") mock_api = mock_api_class.return_value - mock_api.update_contact.return_value = _create_response(xero_id, client.name) + mock_api.update_contact.return_value = _create_response(xero_id, company.name) - from apps.workflow.api.xero.push import sync_client_to_xero + from apps.workflow.api.xero.push import sync_company_to_xero - result = sync_client_to_xero(client) + result = sync_company_to_xero(company) self.assertTrue(result) mock_api.update_contact.assert_called_once() @@ -97,30 +97,30 @@ def test_existing_client_calls_update_contact_with_sdk_contact( @patch("apps.workflow.api.xero.push.get_tenant_id", return_value="tenant-1") @patch("apps.workflow.api.xero.push.AccountingApi") class CreateClientContactInXeroTests(TestCase): - """create_client_contact_in_xero — passes Contact, saves xero_contact_id.""" + """create_company_contact_in_xero — passes Contact, saves xero_contact_id.""" def test_passes_sdk_contact_and_saves_id( self, mock_api_class, _mock_tenant, _mock_sleep ): - client = _make_client(phone="027 351 8328") + company = _make_client(phone="027 351 8328") new_id = "00000000-0000-0000-0000-000000000042" mock_api = mock_api_class.return_value # Use the captured-from-Xero response fixture so the mock matches the # real shape (contact_status, updated_date_utc, etc.); only override # the fields this test actually asserts on. mock_api.create_contacts.return_value = make_create_contacts_response( - contact_id=new_id, name=client.name + contact_id=new_id, name=company.name ) - from apps.workflow.api.xero.push import create_client_contact_in_xero + from apps.workflow.api.xero.push import create_company_contact_in_xero - result = create_client_contact_in_xero(client) + result = create_company_contact_in_xero(company) self.assertEqual(result, new_id) contact = mock_api.create_contacts.call_args.kwargs["contacts"]["contacts"][0] self.assertIsInstance(contact, Contact) - client.refresh_from_db() - self.assertEqual(client.xero_contact_id, new_id) + company.refresh_from_db() + self.assertEqual(company.xero_contact_id, new_id) @patch("time.sleep") @@ -133,7 +133,7 @@ def test_passes_list_of_sdk_contacts( self, mock_api_class, _mock_tenant, _mock_sleep ): clients = [ - _make_client(name=f"Bulk Client {i}", phone=f"027 351 84{i:02d}") + _make_client(name=f"Bulk Company {i}", phone=f"027 351 84{i:02d}") for i in range(3) ] mock_api = mock_api_class.return_value @@ -160,13 +160,13 @@ def test_no_name_dict_key_workaround_in_payload( Anyone re-introducing the dict path "for safety" trips this immediately. """ - client = _make_client(phone="027 351 8329") + company = _make_client(phone="027 351 8329") mock_api = mock_api_class.return_value - mock_api.create_contacts.return_value = _create_response("id-1", client.name) + mock_api.create_contacts.return_value = _create_response("id-1", company.name) from apps.workflow.api.xero.push import bulk_create_contacts_in_xero - bulk_create_contacts_in_xero([client]) + bulk_create_contacts_in_xero([company]) captured = mock_api.create_contacts.call_args.kwargs["contacts"]["contacts"][0] self.assertIsInstance(captured, Contact) @@ -199,7 +199,7 @@ def test_duplicate_names_get_distinct_ids_when_response_in_order( ): """Regression: two clients with identical names in one batch must each receive their own xero_contact_id. The previous name-keyed mapping - silently overwrote the first client's mapping with the second's.""" + silently overwrote the first company's mapping with the second's.""" client_a = _make_client( name="Same Name", email="a@example.test", diff --git a/apps/workflow/tests/test_relabel_client_app.py b/apps/workflow/tests/test_relabel_client_app.py new file mode 100644 index 000000000..0544fe2dc --- /dev/null +++ b/apps/workflow/tests/test_relabel_client_app.py @@ -0,0 +1,163 @@ +"""Tests for the KAN-278 relabel_client_app one-time DB surgery. + +The fully-migrated test DB is post-rename (tables are ``company_company`` +etc., ledger rows say app='company'), so each test stages the legacy state +it needs with raw DDL/DML and restores the live table names afterwards - +the surgery's net DDL is deliberately NOT zero (it only does the app-label +half of the table renames; the rename migration does the model half). +""" + +from django.core.management import call_command +from django.core.management.base import CommandError +from django.db import connection +from django.test import TransactionTestCase + +# The pre-squash client migration names every production ledger carried +# (verified against the dev ledger, which mirrored prod at the squash). +HISTORIC_CLIENT_MIGRATIONS = [ + "0001_initial", + "0002_clientcontact", + "0003_add_xero_merge_tracking", + "0004_populate_merge_fields", + "0005_client_is_supplier", + "0006_alter_client_name", + "0007_delete_empty_name_contacts", + "0008_merge_duplicate_contacts", + "0009_clientcontact_unique_client_contact_name", + "0010_add_is_active_to_clientcontact", + "0011_convert_empty_strings_to_null", + "0012_supplierpickupaddress", + "0013_add_google_fields_to_pickup_address", + "0014_add_suburb_to_pickup_address", + "0015_populate_xero_addresses", + "0016_alter_client_table_alter_clientcontact_table_and_more", + "0017_reassign_stranded_merged_client_fks", + "0018_client_allow_jobs", + "0019_client_name_fts_index", + "0020_suppliersearchalias_and_more", + "0021_clientcontactmethod", + "0022_client_name_trgm_index", + "0023_drop_scalar_phone_fields", +] + +# live table name -> pre-surgery (legacy) table name +LEGACY_TABLE_NAMES = [ + ("company_company", "client_client"), + ("company_clientcontact", "client_clientcontact"), + ("company_clientcontactmethod", "client_clientcontactmethod"), + ("company_suppliersearchalias", "client_suppliersearchalias"), + ("company_supplierpickupaddress", "client_supplierpickupaddress"), +] + + +def _insert_ghost_rows(cursor) -> None: + for name in HISTORIC_CLIENT_MIGRATIONS: + cursor.execute( + "INSERT INTO django_migrations (app, name, applied) " + "VALUES ('client', %s, NOW())", + [name], + ) + + +class RelabelClientAppTests(TransactionTestCase): + def test_noop_on_fresh_db(self) -> None: + # The migrated test DB has no app='client' rows: the command must + # no-op without touching tables. + call_command("relabel_client_app") + with connection.cursor() as cursor: + cursor.execute( + "SELECT COUNT(*) FROM django_migrations WHERE app = 'client'" + ) + self.assertEqual(cursor.fetchone()[0], 0) + cursor.execute("SELECT to_regclass('company_company')") + self.assertIsNotNone(cursor.fetchone()[0]) + + def test_relabels_postsquash_ledger(self) -> None: + # Stage the real pre-deploy state: legacy table names, the baseline + # ledger row under app='client', 23 historic ghost rows, content + # types under app_label='client'. + with connection.cursor() as cursor: + for live, legacy in LEGACY_TABLE_NAMES: + cursor.execute(f'ALTER TABLE "{live}" RENAME TO "{legacy}"') + cursor.execute( + "UPDATE django_migrations SET app = 'client' " + "WHERE app = 'company' AND name = '0001_baseline'" + ) + _insert_ghost_rows(cursor) + cursor.execute( + "UPDATE django_content_type SET app_label = 'client' " + "WHERE app_label = 'company'" + ) + + try: + call_command("relabel_client_app") + with connection.cursor() as cursor: + cursor.execute( + "SELECT COUNT(*) FROM django_migrations WHERE app = 'client'" + ) + self.assertEqual(cursor.fetchone()[0], 0) + cursor.execute( + "SELECT COUNT(*) FROM django_migrations " + "WHERE app = 'company' AND name = '0001_baseline'" + ) + self.assertEqual(cursor.fetchone()[0], 1) + # The historic ghosts are deleted, not relabelled. + cursor.execute( + "SELECT COUNT(*) FROM django_migrations " + "WHERE app = 'company' AND name = ANY(%s)", + [HISTORIC_CLIENT_MIGRATIONS], + ) + self.assertEqual(cursor.fetchone()[0], 0) + cursor.execute( + "SELECT COUNT(*) FROM django_content_type " + "WHERE app_label = 'client'" + ) + self.assertEqual(cursor.fetchone()[0], 0) + cursor.execute( + "SELECT COUNT(*) FROM django_content_type " + "WHERE app_label = 'company'" + ) + self.assertGreater(cursor.fetchone()[0], 0) + # Surgery does the label half only: client_client ends up at + # company_client; the rename migration owns the model half. + cursor.execute("SELECT to_regclass('company_client')") + self.assertIsNotNone(cursor.fetchone()[0]) + cursor.execute("SELECT to_regclass('client_client')") + self.assertIsNone(cursor.fetchone()[0]) + + # Second run must be a clean no-op. + call_command("relabel_client_app") + with connection.cursor() as cursor: + cursor.execute( + "SELECT COUNT(*) FROM django_migrations WHERE app = 'client'" + ) + self.assertEqual(cursor.fetchone()[0], 0) + finally: + # Restore the live schema for subsequent tests: only the main + # table sits at its label-half name (company_client). + with connection.cursor() as cursor: + cursor.execute("SELECT to_regclass('company_client')") + if cursor.fetchone()[0] is not None: + cursor.execute( + "ALTER TABLE company_client RENAME TO company_company" + ) + + def test_aborts_on_presquash_ledger(self) -> None: + # app='client' rows without a ('client', '0001_baseline') row mean a + # pre-squash database: the command must abort loudly and leave every + # staged row in place (its transaction rolls back atomically). + with connection.cursor() as cursor: + _insert_ghost_rows(cursor) + try: + with self.assertRaises(CommandError): + call_command("relabel_client_app") + with connection.cursor() as cursor: + cursor.execute( + "SELECT COUNT(*) FROM django_migrations WHERE app = 'client'" + ) + self.assertEqual(cursor.fetchone()[0], len(HISTORIC_CLIENT_MIGRATIONS)) + cursor.execute("SELECT to_regclass('company_company')") + self.assertIsNotNone(cursor.fetchone()[0]) + finally: + with connection.cursor() as cursor: + cursor.execute("DELETE FROM django_migrations WHERE app = 'client'") diff --git a/apps/workflow/tests/test_seed_xero_from_database.py b/apps/workflow/tests/test_seed_xero_from_database.py index 11972ce55..e45192ba7 100644 --- a/apps/workflow/tests/test_seed_xero_from_database.py +++ b/apps/workflow/tests/test_seed_xero_from_database.py @@ -3,7 +3,7 @@ Regression coverage for Trello #309 — the production-DB guard must prevent ``clear_production_xero_ids`` from wiping live ``xero_contact_id`` values when the configured DB name belongs to a -prod instance. The DB name pattern is ``dw__`` +prod instance. The DB name pattern is ``dw__`` (``scripts/server/instance.sh:171``); env is validated against ``dev``/``uat``/``staging``/``prod`` (``scripts/server/common.sh:13``), so the ``_prod`` suffix is a deterministic signal of a prod DB. @@ -16,7 +16,7 @@ from django.test import TestCase from django.utils import timezone -from apps.client.models import Client +from apps.company.models import Company from apps.workflow.management.commands.seed_xero_from_database import Command SENTINEL_XERO_CONTACT_ID = "11111111-1111-1111-1111-111111111111" @@ -28,7 +28,7 @@ def test_refuses_when_db_name_ends_with_prod(self): ends with ``_prod``, ``clear_production_xero_ids`` must not touch the DB. Wiping live ``xero_contact_id``s breaks Xero sync until every contact is manually re-linked.""" - client = Client.objects.create( + company = Company.objects.create( name="Acme Ltd", email="info@acme.test", address="123 Test Street", @@ -42,5 +42,5 @@ def test_refuses_when_db_name_ends_with_prod(self): with patch.dict(settings.DATABASES["default"], {"NAME": "dw_msm_prod"}): cmd.clear_production_xero_ids(dry_run=False) - client.refresh_from_db() - self.assertEqual(client.xero_contact_id, SENTINEL_XERO_CONTACT_ID) + company.refresh_from_db() + self.assertEqual(company.xero_contact_id, SENTINEL_XERO_CONTACT_ID) diff --git a/apps/workflow/tests/test_sync_clients.py b/apps/workflow/tests/test_sync_clients.py index 00223db2c..ebbcdcee2 100644 --- a/apps/workflow/tests/test_sync_clients.py +++ b/apps/workflow/tests/test_sync_clients.py @@ -1,4 +1,4 @@ -"""Tests for sync_clients handling of archived Xero contacts.""" +"""Tests for sync_companies handling of archived Xero contacts.""" from types import SimpleNamespace from unittest.mock import patch @@ -8,12 +8,12 @@ from django.test.utils import CaptureQueriesContext from django.utils import timezone -from apps.client.models import Client, ClientContactMethod +from apps.company.models import ClientContactMethod, Company from apps.crm.models import PhoneCallRecord from apps.crm.services.phone_call_service import rematch_calls_for_numbers from apps.workflow.api.xero.reprocess_xero import ( _xero_phone_value, - set_client_fields, + set_company_fields, sync_xero_phone_methods, ) from apps.workflow.exceptions import AlreadyLoggedException @@ -133,7 +133,7 @@ def _make_raw_json(contact_id, name, status="ACTIVE", merged_to=None): def _make_xero_contact(contact_id, name, status="ACTIVE", merged_to=None): """Build a fake Xero SDK contact object. - The real xero_python Contact has many attributes, but sync_clients() + The real xero_python Contact has many attributes, but sync_companies() only accesses contact_id, contact_status, and merged_to_contact_id. """ contact = SimpleNamespace( @@ -150,17 +150,17 @@ class SyncClientsArchivedContactTests(TestCase): Reproduces the production scenario where Xero merges two contacts: the surviving contact stays ACTIVE, the old one becomes ARCHIVED with - the same name. sync_clients must handle both without crashing. + the same name. sync_companies must handle both without crashing. """ def setUp(self): self.active_xero_id = "9568adbc-aaaa-bbbb-cccc-000000000001" self.archived_xero_id = "17aa5e1e-aaaa-bbbb-cccc-000000000002" - self.client_name = "Powder Coating Group NZ Limited" + self.company_name = "Powder Coating Group NZ Limited" - # Pre-existing client linked to the active Xero contact - self.existing_client = Client.objects.create( - name=self.client_name, + # Pre-existing company linked to the active Xero contact + self.existing_client = Company.objects.create( + name=self.company_name, xero_contact_id=self.active_xero_id, xero_last_modified=timezone.now(), ) @@ -169,30 +169,30 @@ def _mock_process_xero_data(self, contact): """Substitute for process_xero_data that returns realistic raw_json.""" return _make_raw_json( contact_id=contact.contact_id, - name=self.client_name, + name=self.company_name, status=contact.contact_status, merged_to=getattr(contact, "merged_to_contact_id", None), ) - @patch("apps.workflow.api.xero.transforms.set_client_fields") + @patch("apps.workflow.api.xero.transforms.set_company_fields") @patch("apps.workflow.api.xero.transforms.process_xero_data") def test_archived_contact_creates_separate_record( self, mock_process, mock_set_fields ): """An archived Xero contact with a duplicate name should create a - separate client record instead of raising ValueError.""" + separate company record instead of raising ValueError.""" mock_process.side_effect = self._mock_process_xero_data archived_contact = _make_xero_contact( self.archived_xero_id, - self.client_name, + self.company_name, status="ARCHIVED", merged_to=self.active_xero_id, ) - from apps.workflow.api.xero.sync import sync_clients + from apps.workflow.api.xero.sync import sync_companies - result = sync_clients([archived_contact]) + result = sync_companies([archived_contact]) self.assertEqual(len(result), 1) new_client = result[0] @@ -203,34 +203,34 @@ def test_archived_contact_creates_separate_record( self.assertTrue(new_client.xero_archived) self.assertEqual(new_client.xero_merged_into_id, self.active_xero_id) - # Original client unchanged + # Original company unchanged self.existing_client.refresh_from_db() self.assertEqual(self.existing_client.xero_contact_id, self.active_xero_id) self.assertFalse(self.existing_client.xero_archived) - @patch("apps.workflow.api.xero.transforms.set_client_fields") + @patch("apps.workflow.api.xero.transforms.set_company_fields") @patch("apps.workflow.api.xero.transforms.process_xero_data") def test_active_contact_name_collision_still_raises( self, mock_process, mock_set_fields ): - """An active Xero contact whose name collides with an existing client + """An active Xero contact whose name collides with an existing company linked to a different Xero ID should still raise ValueError.""" mock_process.side_effect = self._mock_process_xero_data conflicting_contact = _make_xero_contact( "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee", - self.client_name, + self.company_name, status="ACTIVE", ) - from apps.workflow.api.xero.sync import sync_clients + from apps.workflow.api.xero.sync import sync_companies with self.assertRaises(ValueError) as ctx: - sync_clients([conflicting_contact]) + sync_companies([conflicting_contact]) self.assertIn(self.active_xero_id, str(ctx.exception)) - @patch("apps.workflow.api.xero.transforms.set_client_fields") + @patch("apps.workflow.api.xero.transforms.set_company_fields") @patch("apps.workflow.api.xero.transforms.process_xero_data") def test_archived_contact_with_existing_xero_id_updates_in_place( self, mock_process, mock_set_fields @@ -239,16 +239,16 @@ def test_archived_contact_with_existing_xero_id_updates_in_place( it should update that record (the normal 'already linked' path).""" mock_process.side_effect = self._mock_process_xero_data - # Contact whose ID matches the existing client — just now archived + # Contact whose ID matches the existing company — just now archived same_id_contact = _make_xero_contact( self.active_xero_id, - self.client_name, + self.company_name, status="ARCHIVED", ) - from apps.workflow.api.xero.sync import sync_clients + from apps.workflow.api.xero.sync import sync_companies - result = sync_clients([same_id_contact]) + result = sync_companies([same_id_contact]) self.assertEqual(len(result), 1) self.assertEqual(result[0].id, self.existing_client.id) @@ -263,8 +263,8 @@ def _client_with_phone( name: str, number: str = "021 555 123", phone_type: str = "DEFAULT", - ) -> Client: - return Client.objects.create( + ) -> Company: + return Company.objects.create( name=name, xero_last_modified=timezone.now(), raw_json={ @@ -280,13 +280,13 @@ def _client_with_phone( ) def test_duplicate_phone_owner_crashes_sync_and_persists_app_error(self) -> None: - existing = Client.objects.create( + existing = Company.objects.create( name="Existing Phone Owner", xero_last_modified=timezone.now(), ) imported = self._client_with_phone("Imported Phone Owner") ClientContactMethod.objects.create( - client=existing, + company=existing, method_type=ClientContactMethod.MethodType.PHONE, value="021 555 123", ) @@ -301,22 +301,22 @@ def test_duplicate_phone_owner_crashes_sync_and_persists_app_error(self) -> None self.assertEqual(AppError.objects.count(), before + 1) app_error = AppError.objects.order_by("-timestamp").first() assert app_error is not None - # The persisted message must name the syncing client, the number, and + # The persisted message must name the syncing company, the number, and # the conflicting owner so the operator can fix the data. self.assertIn("Imported Phone Owner", app_error.message) self.assertIn("+6421555123", app_error.message) self.assertIn("Existing Phone Owner", app_error.message) def test_resync_of_existing_number_is_grandfathered(self) -> None: - """Re-syncing a client's own already-stored number must not raise.""" + """Re-syncing a company's own already-stored number must not raise.""" owner = self._client_with_phone("Phone Owner") - # A cross-client legacy row exists for the same number (grandfathered), + # A cross-company legacy row exists for the same number (grandfathered), # inserted bypassing the guard as pre-guard data was. - other = Client.objects.create( + other = Company.objects.create( name="Legacy Other Owner", xero_last_modified=timezone.now() ) legacy = ClientContactMethod( - client=other, + company=other, method_type=ClientContactMethod.MethodType.PHONE, value="021 555 123", ) @@ -324,7 +324,7 @@ def test_resync_of_existing_number_is_grandfathered(self) -> None: ClientContactMethod.objects.bulk_create([legacy]) # owner already stores the number too (its own row). owner_method = ClientContactMethod( - client=owner, + company=owner, method_type=ClientContactMethod.MethodType.PHONE, value="021 555 123", ) @@ -344,7 +344,7 @@ def test_user_edited_label_and_primary_survive_resync(self) -> None: owner = self._client_with_phone("Phone Owner") created = sync_xero_phone_methods(owner) self.assertEqual(created, ["+6421555123"]) - imported_method = ClientContactMethod.objects.get(client=owner) + imported_method = ClientContactMethod.objects.get(company=owner) self.assertEqual(imported_method.label, "DEFAULT") self.assertTrue(imported_method.is_primary) @@ -352,7 +352,7 @@ def test_user_edited_label_and_primary_survive_resync(self) -> None: imported_method.label = "Reception" imported_method.save() local_primary = ClientContactMethod.objects.create( - client=owner, + company=owner, method_type=ClientContactMethod.MethodType.PHONE, value="09 555 000", label="After hours", @@ -372,7 +372,7 @@ def test_user_edited_label_and_primary_survive_resync(self) -> None: def test_unchanged_resync_does_not_write_the_method_row(self) -> None: owner = self._client_with_phone("Phone Owner") sync_xero_phone_methods(owner) - method = ClientContactMethod.objects.get(client=owner) + method = ClientContactMethod.objects.get(company=owner) updated_at_before = method.updated_at with CaptureQueriesContext(connection) as ctx: @@ -389,7 +389,7 @@ def test_unchanged_resync_does_not_write_the_method_row(self) -> None: def test_new_xero_number_dispatches_rematch_of_historical_calls(self) -> None: """Numbers imported from Xero must attach existing calls, like UI edits.""" - client = self._client_with_phone("Rematch Client") + company = self._client_with_phone("Rematch Company") call_datetime = timezone.now() call = PhoneCallRecord.objects.create( provider_call_id="account:xero-rematch", @@ -407,15 +407,15 @@ def test_new_xero_number_dispatches_rematch_of_historical_calls(self) -> None: side_effect=rematch_calls_for_numbers, ) as rematch: with self.captureOnCommitCallbacks(execute=True): - set_client_fields(client) + set_company_fields(company) rematch.assert_called_once_with(["+6421555123"]) call.refresh_from_db() - self.assertEqual(call.client, client) + self.assertEqual(call.company, company) # An unchanged re-sync creates nothing and must not dispatch a rematch. with self.captureOnCommitCallbacks() as callbacks: - set_client_fields(client) + set_company_fields(company) self.assertEqual(callbacks, []) diff --git a/apps/workflow/tests/test_xero_document_raw_json.py b/apps/workflow/tests/test_xero_document_raw_json.py index 5009ef9bb..f9f41b018 100644 --- a/apps/workflow/tests/test_xero_document_raw_json.py +++ b/apps/workflow/tests/test_xero_document_raw_json.py @@ -3,7 +3,7 @@ from django.utils import timezone -from apps.client.models import Client +from apps.company.models import Company from apps.job.models import Job from apps.testing import BaseTestCase from apps.workflow.accounting.types import DocumentResult @@ -33,13 +33,13 @@ def add_history_note_to_quote(self, external_id, note): class XeroDocumentRawJsonTests(BaseTestCase): def setUp(self): - self.client_obj = Client.objects.create( - name="Raw JSON Client", + self.client_obj = Company.objects.create( + name="Raw JSON Company", xero_contact_id=str(uuid.uuid4()), xero_last_modified=timezone.now(), ) self.job = Job.objects.create( - client=self.client_obj, + company=self.client_obj, name="Raw JSON Job", pricing_methodology="fixed_price", staff=self.test_staff, @@ -47,7 +47,7 @@ def setUp(self): def test_created_invoice_stores_canonical_raw_json_dict(self): raw_response = { - "_contact": {"_name": "Raw JSON Client"}, + "_contact": {"_name": "Raw JSON Company"}, "_invoice_id": str(uuid.uuid4()), "_invoice_number": "INV-RAW-1", "_sub_total": "100.00", @@ -66,7 +66,7 @@ def test_created_invoice_stores_canonical_raw_json_dict(self): ) manager = XeroInvoiceManager( - client=self.client_obj, + company=self.client_obj, job=self.job, staff=self.test_staff, ) @@ -78,7 +78,7 @@ def test_created_invoice_stores_canonical_raw_json_dict(self): self.assertTrue(result["success"]) invoice = self.job.invoices.get() self.assertIsInstance(invoice.raw_json, dict) - self.assertEqual(invoice.raw_json["_contact"]["_name"], "Raw JSON Client") + self.assertEqual(invoice.raw_json["_contact"]["_name"], "Raw JSON Company") self.assertNotIn("full", invoice.raw_json) self.assertNotIn("contact", invoice.raw_json) self.assertEqual(invoice.total_excl_tax, Decimal("100.00")) @@ -90,7 +90,7 @@ def test_created_quote_stores_canonical_raw_json_dict(self): self.job.latest_quote.summary = {"cost": 0.0, "rev": 250.0, "hours": 0.0} self.job.latest_quote.save(update_fields=["summary"]) raw_response = { - "_contact": {"_name": "Raw JSON Client"}, + "_contact": {"_name": "Raw JSON Company"}, "_quote_id": str(uuid.uuid4()), "_quote_number": "QU-RAW-1", "_sub_total": "250.00", @@ -107,7 +107,7 @@ def test_created_quote_stores_canonical_raw_json_dict(self): ) manager = XeroQuoteManager( - client=self.client_obj, + company=self.client_obj, job=self.job, staff=self.test_staff, ) @@ -118,7 +118,7 @@ def test_created_quote_stores_canonical_raw_json_dict(self): self.assertTrue(result["success"]) quote = self.job.quote self.assertIsInstance(quote.raw_json, dict) - self.assertEqual(quote.raw_json["_contact"]["_name"], "Raw JSON Client") + self.assertEqual(quote.raw_json["_contact"]["_name"], "Raw JSON Company") self.assertNotIn("contact", quote.raw_json) self.assertEqual(quote.total_excl_tax, Decimal("250.00")) self.assertEqual(quote.total_incl_tax, Decimal("287.50")) diff --git a/apps/workflow/tests/test_xero_po_manager.py b/apps/workflow/tests/test_xero_po_manager.py index 26c518f76..d66930aa3 100644 --- a/apps/workflow/tests/test_xero_po_manager.py +++ b/apps/workflow/tests/test_xero_po_manager.py @@ -20,7 +20,7 @@ from django.utils import timezone -from apps.client.models import Client +from apps.company.models import Company from apps.purchasing.models import PurchaseOrder from apps.testing import BaseAPITestCase from apps.workflow.views.xero.xero_po_manager import XeroPurchaseOrderManager @@ -34,7 +34,7 @@ def setUpTestData(cls): cls.test_staff.is_office_staff = True cls.test_staff.save(update_fields=["is_office_staff"]) - cls.supplier = Client.objects.create( + cls.supplier = Company.objects.create( name="Test Supplier", xero_contact_id="00000000-0000-0000-0000-000000000001", xero_last_modified=timezone.now(), diff --git a/apps/workflow/tests/test_xero_quota_floor.py b/apps/workflow/tests/test_xero_quota_floor.py index d68b2e105..e962dfdf5 100644 --- a/apps/workflow/tests/test_xero_quota_floor.py +++ b/apps/workflow/tests/test_xero_quota_floor.py @@ -21,7 +21,7 @@ from django.test import TestCase from django.utils import timezone as dj_timezone -from apps.client.models import Client +from apps.company.models import Company from apps.purchasing.models import Stock from apps.workflow.api.xero.client import quota_floor_breached from apps.workflow.api.xero.sync import sync_xero_data @@ -79,13 +79,13 @@ def _set_company_floor(floor=100): CompanyDefaults.clear_cache() return - shop_client = Client.objects.create( - name="Shop Client", + shop_company = Company.objects.create( + name="Shop Company", xero_last_modified=dj_timezone.now(), ) CompanyDefaults.objects.create( company_name="Demo Company", - shop_client=shop_client, + shop_company=shop_company, xero_automated_day_floor=floor, ) diff --git a/apps/workflow/tests/test_xero_transform_contact_resolution.py b/apps/workflow/tests/test_xero_transform_contact_resolution.py index 7a6d7432b..09a961451 100644 --- a/apps/workflow/tests/test_xero_transform_contact_resolution.py +++ b/apps/workflow/tests/test_xero_transform_contact_resolution.py @@ -5,10 +5,10 @@ from django.test import TestCase from django.utils import timezone as django_timezone -from apps.client.models import Client +from apps.company.models import Company from apps.workflow.api.xero.transforms import ( _extract_required_fields_xero, - resolve_client_from_xero_contact, + resolve_company_from_xero_contact, transform_purchase_order, transform_quote, ) @@ -20,41 +20,41 @@ def _make_contact(contact_id: str, **extra): class XeroTransformContactResolutionTests(TestCase): def setUp(self): - self.client = Client.objects.create( - name="Existing Client", + self.company = Company.objects.create( + name="Existing Company", xero_contact_id="contact-123", xero_last_modified=django_timezone.now(), ) - @patch("apps.workflow.api.xero.transforms.get_or_fetch_client") + @patch("apps.workflow.api.xero.transforms.get_or_fetch_company") def test_resolve_client_uses_embedded_contact_without_follow_up_get( self, mock_get_or_fetch ): - contact = _make_contact("contact-123", name="Existing Client") + contact = _make_contact("contact-123", name="Existing Company") - resolved = resolve_client_from_xero_contact(contact, "INV-001") + resolved = resolve_company_from_xero_contact(contact, "INV-001") - self.assertEqual(resolved.id, self.client.id) + self.assertEqual(resolved.id, self.company.id) mock_get_or_fetch.assert_not_called() - @patch("apps.workflow.api.xero.transforms.get_or_fetch_client") + @patch("apps.workflow.api.xero.transforms.get_or_fetch_company") def test_resolve_client_falls_back_only_when_embedded_contact_missing( self, mock_get_or_fetch ): - mock_get_or_fetch.return_value = self.client + mock_get_or_fetch.return_value = self.company - resolved = resolve_client_from_xero_contact( + resolved = resolve_company_from_xero_contact( SimpleNamespace(contact_id="contact-123"), "INV-001" ) - self.assertEqual(resolved.id, self.client.id) + self.assertEqual(resolved.id, self.company.id) mock_get_or_fetch.assert_called_once_with("contact-123", "INV-001") - @patch("apps.workflow.api.xero.transforms.resolve_client_from_xero_contact") + @patch("apps.workflow.api.xero.transforms.resolve_company_from_xero_contact") def test_invoice_extract_uses_contact_resolver(self, mock_resolve_client): - mock_resolve_client.return_value = self.client + mock_resolve_client.return_value = self.company invoice = SimpleNamespace( - contact=_make_contact("contact-123", name="Existing Client"), + contact=_make_contact("contact-123", name="Existing Company"), invoice_number="INV-001", date="2026-05-05", sub_total=100, @@ -66,16 +66,16 @@ def test_invoice_extract_uses_contact_resolver(self, mock_resolve_client): fields = _extract_required_fields_xero("invoice", invoice, "inv-xero-id") - self.assertEqual(fields["client"].id, self.client.id) + self.assertEqual(fields["company"].id, self.company.id) mock_resolve_client.assert_called_once_with(invoice.contact, "INV-001") - @patch("apps.workflow.api.xero.transforms.resolve_client_from_xero_contact") + @patch("apps.workflow.api.xero.transforms.resolve_company_from_xero_contact") @patch("apps.workflow.api.xero.transforms.process_xero_data") @patch("apps.workflow.api.xero.transforms.Quote.objects.get_or_create") def test_quote_transform_uses_contact_resolver( self, mock_get_or_create, mock_process_xero_data, mock_resolve_client ): - mock_resolve_client.return_value = self.client + mock_resolve_client.return_value = self.company mock_process_xero_data.return_value = { "_status": {"_value_": "DRAFT"}, "_date": "2026-05-05", @@ -86,7 +86,7 @@ def test_quote_transform_uses_contact_resolver( mock_quote = SimpleNamespace(number="Q-001") mock_get_or_create.return_value = (mock_quote, True) quote = SimpleNamespace( - contact=_make_contact("contact-123", name="Existing Client"), + contact=_make_contact("contact-123", name="Existing Company"), quote_number="Q-001", ) @@ -96,7 +96,7 @@ def test_quote_transform_uses_contact_resolver( quote.contact, "quote quote-xero-id" ) - @patch("apps.workflow.api.xero.transforms.resolve_client_from_xero_contact") + @patch("apps.workflow.api.xero.transforms.resolve_company_from_xero_contact") @patch("apps.workflow.api.xero.transforms.process_xero_data") @patch("apps.workflow.api.xero.transforms.PurchaseOrder.objects.filter") @patch("apps.workflow.api.xero.transforms.PurchaseOrder.objects.create") @@ -107,7 +107,7 @@ def test_purchase_order_transform_uses_contact_resolver( mock_process_xero_data, mock_resolve_client, ): - mock_resolve_client.return_value = self.client + mock_resolve_client.return_value = self.company mock_process_xero_data.return_value = {"_line_items": []} mock_filter.return_value.first.return_value = None mock_po = SimpleNamespace( @@ -117,7 +117,7 @@ def test_purchase_order_transform_uses_contact_resolver( ) mock_create.return_value = mock_po xero_po = SimpleNamespace( - contact=_make_contact("contact-123", name="Existing Client"), + contact=_make_contact("contact-123", name="Existing Company"), purchase_order_number="PO-001", date="2026-05-05", status="DRAFT", diff --git a/apps/workflow/views/data_versions_view.py b/apps/workflow/views/data_versions_view.py index e332ed5f1..aad67ad14 100644 --- a/apps/workflow/views/data_versions_view.py +++ b/apps/workflow/views/data_versions_view.py @@ -23,7 +23,7 @@ from rest_framework.views import APIView from apps.accounts.models import Staff -from apps.client.models import Client, ClientContact +from apps.company.models import ClientContact, Company from apps.crm.models import PhoneCallRecord, PhoneCallRecording from apps.job.models import Job from apps.purchasing.models import Stock @@ -48,7 +48,7 @@ def _kanban_version() -> str: return "|".join( [ _model_version(Job, "updated_at"), - _model_version(Client, "django_updated_at"), + _model_version(Company, "django_updated_at"), _model_version(ClientContact, "updated_at"), _model_version(Staff, "updated_at"), ] @@ -62,7 +62,7 @@ def _crm_calls_version() -> str: [ _model_version(PhoneCallRecord, "updated_at"), _model_version(PhoneCallRecording, "updated_at"), - _model_version(Client, "django_updated_at"), + _model_version(Company, "django_updated_at"), _model_version(ClientContact, "updated_at"), _model_version(Job, "updated_at"), ] diff --git a/apps/workflow/views/search_telemetry_view.py b/apps/workflow/views/search_telemetry_view.py index 4d065d468..c4e9d03d7 100644 --- a/apps/workflow/views/search_telemetry_view.py +++ b/apps/workflow/views/search_telemetry_view.py @@ -39,7 +39,7 @@ def _build_server_error_response(*, message: str, exc: Exception) -> Response: @extend_schema_view( post=extend_schema( summary="Log search result selection", - description="Records a selected result from client, Kanban, or stock search.", + description="Records a selected result from company, Kanban, or stock search.", request=SearchTelemetryClickRequestSerializer, responses={200: SearchTelemetryClickResponseSerializer}, tags=["Search telemetry"], diff --git a/apps/workflow/views/xero/xero_base_manager.py b/apps/workflow/views/xero/xero_base_manager.py index eec3cc374..45ed13b99 100644 --- a/apps/workflow/views/xero/xero_base_manager.py +++ b/apps/workflow/views/xero/xero_base_manager.py @@ -3,7 +3,7 @@ from abc import ABC, abstractmethod from apps.accounts.models import Staff -from apps.client.models import Client +from apps.company.models import Company # Import models used in type hints or logic from apps.job.models import Job @@ -20,15 +20,15 @@ class XeroDocumentManager(ABC): """ job: Job | None # Job is optional now - client: Client + company: Company staff: Staff - def __init__(self, client, staff: Staff, job=None): + def __init__(self, company, staff: Staff, job=None): """ Initializes the creator. Args: - client (Client): The client or supplier associated with the document. + company (Company): The company or supplier associated with the document. staff (Staff): The authenticated staff member performing the action. Used to attribute any Job audit events emitted by create/delete operations. @@ -36,11 +36,11 @@ def __init__(self, client, staff: Staff, job=None): Required for document types like Invoice/Quote. Not directly used for PurchaseOrder at this level. """ - if client is None: - raise ValueError("Client cannot be None for XeroDocumentManager") + if company is None: + raise ValueError("Company cannot be None for XeroDocumentManager") if staff is None: raise ValueError("Staff cannot be None for XeroDocumentManager") - self.client = client + self.company = company self.staff = staff self.job = job # Optional job association self.provider = get_provider() @@ -93,15 +93,15 @@ def _get_account_code(self, account_name: str = "Sales") -> str | None: """ return self.provider.get_account_code(account_name) - def validate_client(self): + def validate_company(self): """ - Ensures the client exists and is synced with Xero. + Ensures the company exists and is synced with Xero. """ - if not self.client: - raise ValueError("Client is missing") - if not self.client.validate_for_xero(): - raise ValueError("Client data is not valid for Xero") - if not self.client.xero_contact_id: + if not self.company: + raise ValueError("Company is missing") + if not self.company.validate_for_xero(): + raise ValueError("Company data is not valid for Xero") + if not self.company.xero_contact_id: raise ValueError( - f"Client {self.client.name} does not have a valid Xero contact ID. Sync the client with Xero first." + f"Company {self.company.name} does not have a valid Xero contact ID. Sync the company with Xero first." ) diff --git a/apps/workflow/views/xero/xero_invoice_manager.py b/apps/workflow/views/xero/xero_invoice_manager.py index 85fc57156..9cf09f562 100644 --- a/apps/workflow/views/xero/xero_invoice_manager.py +++ b/apps/workflow/views/xero/xero_invoice_manager.py @@ -11,7 +11,7 @@ # Import models from apps.accounting.models import Invoice from apps.accounts.models import Staff -from apps.client.models import Client +from apps.company.models import Company from apps.job.models import Job from apps.job.models.costing import CostSet from apps.job.services.workshop_pdf_service import create_workshop_pdf @@ -32,7 +32,7 @@ class XeroInvoiceManager(XeroDocumentManager): def __init__( self, - client: Client, + company: Company, job: Job, staff: Staff, xero_invoice_id: str | None = None, @@ -40,15 +40,15 @@ def __init__( """ Initializes the invoice manager. Args: - client (Client): The client associated with the document. + company (Company): The company associated with the document. job (Job): The associated job. staff (Staff): The authenticated staff member performing the action. xero_invoice_id (str, optional): A specific Xero ID to operate on, useful for deletion of a specific invoice. """ - if not client or not job: - raise ValueError("Client and Job are required for XeroInvoiceManager") - super().__init__(client=client, job=job, staff=staff) + if not company or not job: + raise ValueError("Company and Job are required for XeroInvoiceManager") + super().__init__(company=company, job=job, staff=staff) if xero_invoice_id is not None: self._xero_id_override = str(xero_invoice_id) @@ -131,19 +131,19 @@ def get_line_items( def _compute_invoice_dates(self) -> tuple[date, date]: """Return ``(invoice_date, due_date)`` per the customer's payment terms. - Account customers (``Client.is_account_customer=True``) are due on + Account customers (``Company.is_account_customer=True``) are due on the 20th of the calendar month following the invoice date. Cash customers are due same-day. """ invoice_date = timezone.localdate() - if self.client.is_account_customer: + if self.company.is_account_customer: due_date = (invoice_date + relativedelta(months=1)).replace(day=20) else: due_date = invoice_date return invoice_date, due_date def build_payload(self, total_amount: Decimal | None = None) -> InvoicePayload: - """Build a provider-agnostic invoice payload from the job and client.""" + """Build a provider-agnostic invoice payload from the job and company.""" if not self.job: raise ValueError("Job is required to build invoice payload.") @@ -151,8 +151,8 @@ def build_payload(self, total_amount: Decimal | None = None) -> InvoicePayload: invoice_date, due_date = self._compute_invoice_dates() payload = InvoicePayload( - client_external_id=self.client.xero_contact_id, - client_name=self.client.name, + client_external_id=self.company.xero_contact_id, + company_name=self.company.name, line_items=line_items, date=invoice_date, due_date=due_date, @@ -202,7 +202,7 @@ def create_document( billing_metadata: Calculation metadata to persist on the Invoice record. """ try: - self.validate_client() + self.validate_company() if not self.state_valid_for_xero(): raise ValueError("Document is not in a valid state for submission.") @@ -222,7 +222,7 @@ def create_document( invoice_kwargs = { "xero_id": result.external_id, "job": self.job, - "client": self.client, + "company": self.company, "number": result.number, "date": payload.date, "due_date": payload.due_date, @@ -294,7 +294,7 @@ def create_document( "success": True, "invoice_id": str(invoice.id), "xero_id": result.external_id, - "client": self.client.name, + "company": self.company.name, "total_excl_tax": str(invoice.total_excl_tax), "total_incl_tax": str(invoice.total_incl_tax), "online_url": result.online_url, @@ -319,7 +319,7 @@ def create_document( def delete_document(self): """Deletes an invoice via the provider and removes the local record.""" try: - self.validate_client() + self.validate_company() xero_id = self.get_xero_id() if not xero_id: raise ValueError("Cannot delete invoice without a Xero ID.") diff --git a/apps/workflow/views/xero/xero_po_manager.py b/apps/workflow/views/xero/xero_po_manager.py index 847ad7389..8bc9a72e7 100644 --- a/apps/workflow/views/xero/xero_po_manager.py +++ b/apps/workflow/views/xero/xero_po_manager.py @@ -19,7 +19,7 @@ class XeroPurchaseOrderManager(XeroDocumentManager): """Xero PO sync handler using the accounting provider.""" def __init__(self, purchase_order: PurchaseOrder, staff: Staff): - super().__init__(client=purchase_order.supplier, staff=staff, job=None) + super().__init__(company=purchase_order.supplier, staff=staff, job=None) self.purchase_order = purchase_order def can_sync_to_xero(self) -> bool: @@ -151,8 +151,8 @@ def build_payload(self) -> POPayload: delivery_date = date.fromisoformat(delivery_date) return POPayload( - supplier_external_id=self.client.xero_contact_id, - supplier_name=self.client.name, + supplier_external_id=self.company.xero_contact_id, + supplier_name=self.company.name, po_number=self.purchase_order.po_number, line_items=line_items, date=order_date, diff --git a/apps/workflow/views/xero/xero_quote_manager.py b/apps/workflow/views/xero/xero_quote_manager.py index 158c0b38d..c5ec23088 100644 --- a/apps/workflow/views/xero/xero_quote_manager.py +++ b/apps/workflow/views/xero/xero_quote_manager.py @@ -25,18 +25,18 @@ class XeroQuoteManager(XeroDocumentManager): Handles Quote creation and syncing via the accounting provider. """ - def __init__(self, client, job, staff): + def __init__(self, company, job, staff): """ - Initializes the quote manager. Client, job, and staff are all required. + Initializes the quote manager. Company, job, and staff are all required. Args: - client: The client associated with the quote. + company: The company associated with the quote. job: The associated job. staff: The authenticated staff member performing the action. """ - if not client or not job: - raise ValueError("Client and Job are required for XeroQuoteManager") - super().__init__(client=client, job=job, staff=staff) + if not company or not job: + raise ValueError("Company and Job are required for XeroQuoteManager") + super().__init__(company=company, job=job, staff=staff) def get_xero_id(self): return ( @@ -122,7 +122,7 @@ def get_line_items(self, breakdown: bool = True) -> list[DocumentLineItem]: ] def build_payload(self, breakdown: bool = True) -> QuotePayload: - """Build a provider-agnostic quote payload from the job and client.""" + """Build a provider-agnostic quote payload from the job and company.""" if not self.job: raise ValueError("Job is required to build quote payload.") @@ -130,8 +130,8 @@ def build_payload(self, breakdown: bool = True) -> QuotePayload: today = timezone.localdate() return QuotePayload( - client_external_id=self.client.xero_contact_id, - client_name=self.client.name, + client_external_id=self.company.xero_contact_id, + company_name=self.company.name, line_items=line_items, date=today, expiry_date=today + timedelta(days=30), @@ -150,7 +150,7 @@ def create_document(self, breakdown: bool = True): breakdown: If True, sends detailed line items. If False, sends single total. """ try: - self.validate_client() + self.validate_company() if not self.state_valid_for_xero(): raise ValueError("Document is not in a valid state for submission.") @@ -170,7 +170,7 @@ def create_document(self, breakdown: bool = True): quote = Quote.objects.create( xero_id=result.external_id, job=self.job, - client=self.client, + company=self.company, date=timezone.localdate(), status=QuoteStatus.DRAFT, number=result.number, @@ -209,7 +209,7 @@ def create_document(self, breakdown: bool = True): return { "success": True, "xero_id": result.external_id, - "client": self.client.name, + "company": self.company.name, "online_url": result.online_url, } @@ -227,7 +227,7 @@ def create_document(self, breakdown: bool = True): def delete_document(self): """Deletes a quote via the provider and removes the local record.""" try: - self.validate_client() + self.validate_company() xero_id = self.get_xero_id() if not xero_id: raise ValueError("Cannot delete quote without a Xero ID.") diff --git a/apps/workflow/views/xero/xero_view.py b/apps/workflow/views/xero/xero_view.py index 592881840..663c279a9 100644 --- a/apps/workflow/views/xero/xero_view.py +++ b/apps/workflow/views/xero/xero_view.py @@ -447,7 +447,7 @@ def create_xero_invoice(request: Request, job_id: uuid.UUID) -> Response: if calc_result.requested_amount is not None: billing_metadata["requested_amount"] = str(calc_result.requested_amount) - manager = XeroInvoiceManager(client=job.client, job=job, staff=request.user) + manager = XeroInvoiceManager(company=job.company, job=job, staff=request.user) result_data = manager.create_document( total_amount=calc_result.calculated_amount, billing_metadata=billing_metadata, @@ -693,7 +693,7 @@ def create_xero_quote(request: Request, job_id: uuid.UUID) -> Response: try: job = Job.objects.get(id=job_id) - manager = XeroQuoteManager(client=job.client, job=job, staff=request.user) + manager = XeroQuoteManager(company=job.company, job=job, staff=request.user) result_data = manager.create_document(breakdown=breakdown) if result_data.get("success"): @@ -766,7 +766,7 @@ def delete_xero_invoice(request: Request, job_id: uuid.UUID) -> Response: job = Job.objects.get(id=job_id) invoice = Invoice.objects.get(xero_id=xero_invoice_id, job=job) manager = XeroInvoiceManager( - client=job.client, + company=job.company, job=job, staff=request.user, xero_invoice_id=invoice.xero_id, @@ -827,7 +827,7 @@ def delete_xero_quote(request: Request, job_id: uuid.UUID) -> Response: try: job = Job.objects.get(id=job_id) - manager = XeroQuoteManager(client=job.client, job=job, staff=request.user) + manager = XeroQuoteManager(company=job.company, job=job, staff=request.user) result_data: dict = manager.delete_document() if result_data.get("success"): diff --git a/docketworks/settings.py b/docketworks/settings.py index c939403b3..68d841498 100644 --- a/docketworks/settings.py +++ b/docketworks/settings.py @@ -187,7 +187,7 @@ def validate_required_settings() -> None: "apps.timesheet.apps.TimesheetConfig", "apps.job.apps.JobConfig", "apps.quoting.apps.QuotingConfig", - "apps.client.apps.ClientConfig", + "apps.company.apps.CompanyConfig", "apps.crm.apps.CrmConfig", "apps.purchasing.apps.PurchasingConfig", "apps.process.apps.ProcessConfig", @@ -415,7 +415,7 @@ def require_positive_int_env(name: str) -> int: os.path.join(BASE_DIR, "apps/accounts/templates"), os.path.join(BASE_DIR, "apps/timesheet/templates"), os.path.join(BASE_DIR, "apps/job/templates"), - os.path.join(BASE_DIR, "apps/client/templates"), + os.path.join(BASE_DIR, "apps/company/templates"), os.path.join(BASE_DIR, "apps/purchasing/templates"), os.path.join(BASE_DIR, "apps/accounting/templates"), os.path.join(BASE_DIR, "apps/quoting/templates"), diff --git a/docketworks/urls.py b/docketworks/urls.py index 382e28118..56524f778 100644 --- a/docketworks/urls.py +++ b/docketworks/urls.py @@ -11,7 +11,7 @@ path( "api/quoting/", include(("apps.quoting.urls", "quoting"), namespace="quoting") ), - path("api/clients/", include("apps.client.urls_rest", namespace="clients")), + path("api/companies/", include("apps.company.urls_rest", namespace="companies")), path("api/crm/", include("apps.crm.urls", namespace="crm")), path("api/purchasing/", include("apps.purchasing.urls", namespace="purchasing")), path("api/accounting/", include("apps.accounting.urls", namespace="accounting")), diff --git a/docs/architecture.md b/docs/architecture.md index 3dd32d18e..2bdb0182b 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -78,10 +78,10 @@ The system is organized into focused Django apps, each handling specific busines - Password strength validation (minimum 10 characters) - Role-based permissions and authentication -#### **`client`** - (Minimal) Customer Relationship Management +#### **`company`** - (Minimal) Customer Relationship Management - **Purpose**: Customer data and (future) contact management -- **Key Models**: Client +- **Key Models**: Company - **Responsibilities**: - Bidirectional Xero contact synchronization - Contact person and communication history @@ -135,7 +135,7 @@ erDiagram JobPricing ||--o{ TimeEntry : "tracks time" JobPricing ||--o{ MaterialEntry : "uses materials" Staff ||--o{ TimeEntry : "logs time" - Client ||--o{ Job : "requests work" + Company ||--o{ Job : "requests work" PurchaseOrder ||--o{ PurchaseOrderLine : "contains items" PurchaseOrderLine ||--o{ Stock : "creates inventory" Stock ||--o{ MaterialEntry : "used in jobs" @@ -145,7 +145,7 @@ erDiagram string status text description datetime created_at - uuid client_id FK + uuid company_id FK } JobPricing { @@ -218,7 +218,7 @@ Located in `frontend/`: ``` /api/jobs/ - Job CRUD operations -/api/clients/ - Client management +/api/companies/ - Company management /api/timesheets/ - Time entry operations /api/purchasing/ - Purchase order management /api/xero/ - Xero integration endpoints diff --git a/docs/restore-prod-to-nonprod.md b/docs/restore-prod-to-nonprod.md index 1d3a7b62d..e7a2e2868 100644 --- a/docs/restore-prod-to-nonprod.md +++ b/docs/restore-prod-to-nonprod.md @@ -87,7 +87,7 @@ PGPASSWORD="$DB_PASSWORD" pg_restore --no-owner --no-privileges --exit-on-error PGPASSWORD="$DB_PASSWORD" psql -U "$DB_USER" "$DB_NAME" -c " SELECT 'job_job' as table_name, COUNT(*) as count FROM job_job UNION ALL SELECT 'accounts_staff', COUNT(*) FROM accounts_staff -UNION ALL SELECT 'client_client', COUNT(*) FROM client_client +UNION ALL SELECT 'company_company', COUNT(*) FROM company_company UNION ALL SELECT 'job_costset', COUNT(*) FROM job_costset UNION ALL SELECT 'job_costline', COUNT(*) FROM job_costline; " @@ -100,7 +100,7 @@ UNION ALL SELECT 'job_costline', COUNT(*) FROM job_costline; ----------------+------- job_job | 1054 accounts_staff | 20 - client_client | 3739 + company_company | 3739 job_costset | 3162 job_costline | 10334 ``` @@ -116,7 +116,7 @@ python scripts/restore_checks/check_django_orm.py ``` Jobs: ~1400 Staff: ~22 -Clients: ~4800 +Companies: ~4800 Sample job: [any real job name] (#XXXXX) Contact: [any real contact name] ``` @@ -175,21 +175,21 @@ Creates a default admin user and resets all staff passwords to defaults. python scripts/recreate_jobfiles.py ``` -#### Fix Shop Client Name +#### Fix Shop Company Name ```bash -python scripts/restore_checks/fix_shop_client.py +python scripts/restore_checks/fix_shop_company.py ``` -#### Create Test Client +#### Create Test Company -Creates the test client named per `CompanyDefaults.test_client_name` (e.g. `ABC Carpet Cleaning TEST IGNORE`) if it isn't already there. Idempotent. Required by Seed Database to Xero, which crashes if the client is missing. +Creates the test company named per `CompanyDefaults.test_company_name` (e.g. `ABC Carpet Cleaning TEST IGNORE`) if it isn't already there. Idempotent. Required by Seed Database to Xero, which crashes if the company is missing. ```bash -python scripts/fix_test_client.py +python scripts/fix_test_company.py ``` -**Expected output:** `Test client already exists: …` or `Created test client: …`. +**Expected output:** `Test company already exists: …` or `Created test company: …`. #### Connect to Xero OAuth @@ -246,10 +246,10 @@ echo "Background process started, PID: $!" ``` **What this does:** -1. Clears production Xero IDs (clients, jobs, stock, purchase orders, staff) +1. Clears production Xero IDs (companies, jobs, stock, purchase orders, staff) 2. Updates XeroAccount xero_ids from prod to dev Xero tenant (fetches from dev Xero, upserts by account_name) 3. Remaps XeroPayItem FK references (Job.default_xero_pay_item, CostLine.xero_pay_item) from prod UUIDs to dev UUIDs by matching pay item names -4. Links/creates contacts in Xero for all clients +4. Links/creates contacts in Xero for all companies 5. Creates projects in Xero for all jobs 6. Deletes orphaned invoices, re-creates job-linked invoices in dev Xero 7. Deletes orphaned quotes, re-creates job-linked quotes in dev Xero @@ -297,7 +297,7 @@ Must show `active (running)`. The Beat startup banner shows the loaded schedule; for s in scripts/restore_checks/check_*.py; do python "$s"; done ``` -**Expected output:** Each script prints its own success line and exits zero. Covers: Django ORM (`check_django_orm.py`), admin user (`check_admin_user.py`), company defaults (`check_company_defaults.py`), AI providers (`check_ai_providers.py`), JobFiles (`check_jobfiles.py`), shop client (`check_shop_client.py`), test client (`check_test_client.py`), Xero app (`check_xero_app.py`), Xero accounts (`check_xero_accounts.py`), Xero seed (`check_xero_seed.py`). +**Expected output:** Each script prints its own success line and exits zero. Covers: Django ORM (`check_django_orm.py`), admin user (`check_admin_user.py`), company defaults (`check_company_defaults.py`), AI providers (`check_ai_providers.py`), JobFiles (`check_jobfiles.py`), shop company (`check_shop_company.py`), test company (`check_test_company.py`), Xero app (`check_xero_app.py`), Xero accounts (`check_xero_accounts.py`), Xero seed (`check_xero_seed.py`). Any non-zero exit means the upstream mutation step that should have produced that state silently failed — fix the underlying problem, do not re-run just the failing check. diff --git a/docs/urls/company.md b/docs/urls/company.md new file mode 100644 index 000000000..6ec6bd2f7 --- /dev/null +++ b/docs/urls/company.md @@ -0,0 +1,56 @@ +# Company URLs Documentation + + + +### Addresses Management +| URL Pattern | View | Name | Description | +|-------------|------|------|-------------| +| `/addresses/validate/` | `address_views.AddressValidateView` | `companies:address_validate` | Validate and clean an address using Google Address Validation API. | + +### All Management +| URL Pattern | View | Name | Description | +|-------------|------|------|-------------| +| `/all/` | `company_rest_views.CompanyListAllRestView` | `companies:company_list_all_rest` | REST view for listing all companies. | + +### Company-Defaults Management +| URL Pattern | View | Name | Description | +|-------------|------|------|-------------| +| `/company-defaults/schema/` | `company_defaults_schema_api.CompanyDefaultsSchemaAPIView` | `api_company_defaults_schema` | API endpoint that returns field metadata for CompanyDefaults. | +| `/company-defaults/upload-logo/` | `company_defaults_logo_api.CompanyDefaultsLogoAPIView` | `api_company_defaults_upload_logo` | API view for uploading and deleting company logo images. | + +### Create Management +| URL Pattern | View | Name | Description | +|-------------|------|------|-------------| +| `/create/` | `company_rest_views.CompanyCreateRestView` | `companies:company_create_rest` | REST view for creating new companies. | + +### Jobs Management +| URL Pattern | View | Name | Description | +|-------------|------|------|-------------| +| `//jobs/` | `company_rest_views.CompanyJobsRestView` | `companies:company_jobs_rest` | REST view for fetching all jobs for a specific company. | +| `/jobs//contact/` | `company_rest_views.JobContactRestView` | `companies:job_contact_rest` | REST view for contact information operations for a job. | + +### Other +| URL Pattern | View | Name | Description | +|-------------|------|------|-------------| +| `//` | `company_rest_views.CompanyRetrieveRestView` | `companies:company_retrieve_rest` | REST view for retrieving a specific company by ID. | + +### Search Management +| URL Pattern | View | Name | Description | +|-------------|------|------|-------------| +| `/search/` | `company_rest_views.CompanySearchRestView` | `companies:company_search_rest` | REST view for company search with pagination and sorting. | + +### Supplier-Aliases Management +| URL Pattern | View | Name | Description | +|-------------|------|------|-------------| +| `//supplier-aliases/` | `supplier_search_alias_views.CompanySupplierAliasListCreateView` | `companies:company_supplier_aliases_rest` | List and create search aliases for a company/supplier contact. | +| `/supplier-aliases//` | `supplier_search_alias_views.SupplierAliasDetailView` | `companies:supplier_alias_detail_rest` | Deactivate a supplier search alias. | + +### System +| URL Pattern | View | Name | Description | +|-------------|------|------|-------------| +| `/company-defaults/` | `company_defaults_api.CompanyDefaultsAPIView` | `api_company_defaults` | API view for managing company default settings. | + +### Update Management +| URL Pattern | View | Name | Description | +|-------------|------|------|-------------| +| `//update/` | `company_rest_views.CompanyUpdateRestView` | `companies:company_update_rest` | REST view for updating company information. | diff --git a/frontend/schema.yml b/frontend/schema.yml index 979a80896..35f7beca6 100644 --- a/frontend/schema.yml +++ b/frontend/schema.yml @@ -371,7 +371,7 @@ paths: date: type: string format: date - client_name: + company_name: type: string job_number: type: integer @@ -1134,21 +1134,21 @@ paths: schema: $ref: '#/components/schemas/BuildId' description: '' - /api/clients/{client_id}/: + /api/companies/{company_id}/: get: - operationId: clients_retrieve - description: Retrieve detailed information for a specific client. - summary: Get client details + operationId: companies_retrieve + description: Retrieve detailed information for a specific company. + summary: Get company details parameters: - in: path - name: client_id + name: company_id schema: type: string format: uuid - description: UUID of the client + description: UUID of the company required: true tags: - - Clients + - Companies security: - cookieAuth: [] responses: @@ -1156,35 +1156,35 @@ paths: content: application/json: schema: - $ref: '#/components/schemas/ClientDetailResponse' + $ref: '#/components/schemas/CompanyDetailResponse' description: '' '404': content: application/json: schema: - $ref: '#/components/schemas/ClientErrorResponse' + $ref: '#/components/schemas/CompanyErrorResponse' description: '' '500': content: application/json: schema: - $ref: '#/components/schemas/ClientErrorResponse' + $ref: '#/components/schemas/CompanyErrorResponse' description: '' - /api/clients/{client_id}/jobs/: + /api/companies/{company_id}/jobs/: get: - operationId: clients_jobs_retrieve - description: Retrieve all jobs for a specific client. - summary: Get client jobs + operationId: companies_jobs_retrieve + description: Retrieve all jobs for a specific company. + summary: Get company jobs parameters: - in: path - name: client_id + name: company_id schema: type: string format: uuid - description: UUID of the client + description: UUID of the company required: true tags: - - Clients + - Companies security: - cookieAuth: [] responses: @@ -1192,34 +1192,34 @@ paths: content: application/json: schema: - $ref: '#/components/schemas/ClientJobsResponse' + $ref: '#/components/schemas/CompanyJobsResponse' description: '' '404': content: application/json: schema: - $ref: '#/components/schemas/ClientErrorResponse' + $ref: '#/components/schemas/CompanyErrorResponse' description: '' '500': content: application/json: schema: - $ref: '#/components/schemas/ClientErrorResponse' + $ref: '#/components/schemas/CompanyErrorResponse' description: '' - /api/clients/{client_id}/supplier-aliases/: + /api/companies/{company_id}/supplier-aliases/: get: - operationId: clients_supplier_aliases_list - description: List and create search aliases for a client/supplier contact. + operationId: companies_supplier_aliases_list + description: List and create search aliases for a company/supplier contact. summary: List supplier search aliases parameters: - in: path - name: client_id + name: company_id schema: type: string format: uuid required: true tags: - - Clients + - Companies security: - cookieAuth: [] responses: @@ -1232,18 +1232,18 @@ paths: $ref: '#/components/schemas/SupplierSearchAlias' description: '' post: - operationId: clients_supplier_aliases_create - description: List and create search aliases for a client/supplier contact. + operationId: companies_supplier_aliases_create + description: List and create search aliases for a company/supplier contact. summary: Create supplier search alias parameters: - in: path - name: client_id + name: company_id schema: type: string format: uuid required: true tags: - - Clients + - Companies requestBody: content: application/json: @@ -1265,32 +1265,32 @@ paths: schema: $ref: '#/components/schemas/SupplierSearchAlias' description: '' - /api/clients/{client_id}/update/: + /api/companies/{company_id}/update/: put: - operationId: clients_update_update - description: Update an existing client's information. - summary: Update client + operationId: companies_update_update + description: Update an existing company's information. + summary: Update company parameters: - in: path - name: client_id + name: company_id schema: type: string format: uuid - description: UUID of the client + description: UUID of the company required: true tags: - - Clients + - Companies requestBody: content: application/json: schema: - $ref: '#/components/schemas/ClientUpdateRequest' + $ref: '#/components/schemas/CompanyUpdateRequest' application/x-www-form-urlencoded: schema: - $ref: '#/components/schemas/ClientUpdateRequest' + $ref: '#/components/schemas/CompanyUpdateRequest' multipart/form-data: schema: - $ref: '#/components/schemas/ClientUpdateRequest' + $ref: '#/components/schemas/CompanyUpdateRequest' security: - cookieAuth: [] responses: @@ -1298,51 +1298,51 @@ paths: content: application/json: schema: - $ref: '#/components/schemas/ClientUpdateResponse' + $ref: '#/components/schemas/CompanyUpdateResponse' description: '' '400': content: application/json: schema: - $ref: '#/components/schemas/ClientErrorResponse' + $ref: '#/components/schemas/CompanyErrorResponse' description: '' '404': content: application/json: schema: - $ref: '#/components/schemas/ClientErrorResponse' + $ref: '#/components/schemas/CompanyErrorResponse' description: '' '500': content: application/json: schema: - $ref: '#/components/schemas/ClientErrorResponse' + $ref: '#/components/schemas/CompanyErrorResponse' description: '' patch: - operationId: clients_update_partial_update - description: Partially update an existing client's information. - summary: Partially update client + operationId: companies_update_partial_update + description: Partially update an existing company's information. + summary: Partially update company parameters: - in: path - name: client_id + name: company_id schema: type: string format: uuid - description: UUID of the client + description: UUID of the company required: true tags: - - Clients + - Companies requestBody: content: application/json: schema: - $ref: '#/components/schemas/PatchedClientUpdateRequest' + $ref: '#/components/schemas/PatchedCompanyUpdateRequest' application/x-www-form-urlencoded: schema: - $ref: '#/components/schemas/PatchedClientUpdateRequest' + $ref: '#/components/schemas/PatchedCompanyUpdateRequest' multipart/form-data: schema: - $ref: '#/components/schemas/PatchedClientUpdateRequest' + $ref: '#/components/schemas/PatchedCompanyUpdateRequest' security: - cookieAuth: [] responses: @@ -1350,32 +1350,32 @@ paths: content: application/json: schema: - $ref: '#/components/schemas/ClientUpdateResponse' + $ref: '#/components/schemas/CompanyUpdateResponse' description: '' '400': content: application/json: schema: - $ref: '#/components/schemas/ClientErrorResponse' + $ref: '#/components/schemas/CompanyErrorResponse' description: '' '404': content: application/json: schema: - $ref: '#/components/schemas/ClientErrorResponse' + $ref: '#/components/schemas/CompanyErrorResponse' description: '' '500': content: application/json: schema: - $ref: '#/components/schemas/ClientErrorResponse' + $ref: '#/components/schemas/CompanyErrorResponse' description: '' - /api/clients/addresses/validate/: + /api/companies/addresses/validate/: post: - operationId: clients_addresses_validate_create + operationId: companies_addresses_validate_create description: Validate an address and return structured candidates. tags: - - clients + - companies requestBody: content: application/json: @@ -1434,14 +1434,14 @@ paths: schema: description: Google API unavailable or not configured description: '' - /api/clients/all/: + /api/companies/all/: get: - operationId: clients_all_list - description: Returns a list of all clients with basic information (id and name) - for dropdowns and search. - summary: List all clients + operationId: companies_all_list + description: Returns a list of all companies with basic information (id and + name) for dropdowns and search. + summary: List all companies tags: - - Clients + - Companies security: - cookieAuth: [] responses: @@ -1451,21 +1451,21 @@ paths: schema: type: array items: - $ref: '#/components/schemas/ClientNameOnly' + $ref: '#/components/schemas/CompanyNameOnly' description: '' '500': content: application/json: schema: - $ref: '#/components/schemas/ClientErrorResponse' + $ref: '#/components/schemas/CompanyErrorResponse' description: '' - /api/clients/contact-methods/: + /api/companies/contact-methods/: get: - operationId: clients_contact_methods_list - description: CRUD API for canonical client/contact phone and email methods. + operationId: companies_contact_methods_list + description: CRUD API for canonical company/contact phone and email methods. parameters: - in: query - name: client_id + name: company_id schema: type: string format: uuid @@ -1487,7 +1487,7 @@ paths: schema: type: integer tags: - - clients + - companies security: - cookieAuth: [] responses: @@ -1498,10 +1498,10 @@ paths: $ref: '#/components/schemas/PaginatedClientContactMethodList' description: '' post: - operationId: clients_contact_methods_create - description: CRUD API for canonical client/contact phone and email methods. + operationId: companies_contact_methods_create + description: CRUD API for canonical company/contact phone and email methods. tags: - - clients + - companies requestBody: content: application/json: @@ -1523,10 +1523,10 @@ paths: schema: $ref: '#/components/schemas/ClientContactMethod' description: '' - /api/clients/contact-methods/{id}/: + /api/companies/contact-methods/{id}/: get: - operationId: clients_contact_methods_retrieve - description: CRUD API for canonical client/contact phone and email methods. + operationId: companies_contact_methods_retrieve + description: CRUD API for canonical company/contact phone and email methods. parameters: - in: path name: id @@ -1536,7 +1536,7 @@ paths: description: A UUID string identifying this Client Contact Method. required: true tags: - - clients + - companies security: - cookieAuth: [] responses: @@ -1547,8 +1547,8 @@ paths: $ref: '#/components/schemas/ClientContactMethod' description: '' put: - operationId: clients_contact_methods_update - description: CRUD API for canonical client/contact phone and email methods. + operationId: companies_contact_methods_update + description: CRUD API for canonical company/contact phone and email methods. parameters: - in: path name: id @@ -1558,7 +1558,7 @@ paths: description: A UUID string identifying this Client Contact Method. required: true tags: - - clients + - companies requestBody: content: application/json: @@ -1581,8 +1581,8 @@ paths: $ref: '#/components/schemas/ClientContactMethod' description: '' patch: - operationId: clients_contact_methods_partial_update - description: CRUD API for canonical client/contact phone and email methods. + operationId: companies_contact_methods_partial_update + description: CRUD API for canonical company/contact phone and email methods. parameters: - in: path name: id @@ -1592,7 +1592,7 @@ paths: description: A UUID string identifying this Client Contact Method. required: true tags: - - clients + - companies requestBody: content: application/json: @@ -1614,8 +1614,8 @@ paths: $ref: '#/components/schemas/ClientContactMethod' description: '' delete: - operationId: clients_contact_methods_destroy - description: CRUD API for canonical client/contact phone and email methods. + operationId: companies_contact_methods_destroy + description: CRUD API for canonical company/contact phone and email methods. parameters: - in: path name: id @@ -1625,25 +1625,25 @@ paths: description: A UUID string identifying this Client Contact Method. required: true tags: - - clients + - companies security: - cookieAuth: [] responses: '204': description: No response body - /api/clients/contacts/: + /api/companies/contacts/: get: - operationId: clients_contacts_list - description: List all contacts, optionally filtered by client_id. + operationId: companies_contacts_list + description: List all contacts, optionally filtered by company_id. parameters: - in: query - name: client_id + name: company_id schema: type: string format: uuid - description: Filter contacts by client UUID + description: Filter contacts by company UUID tags: - - clients + - companies security: - cookieAuth: [] responses: @@ -1656,22 +1656,22 @@ paths: $ref: '#/components/schemas/ClientContact' description: '' post: - operationId: clients_contacts_create + operationId: companies_contacts_create description: |- ViewSet for ClientContact CRUD operations. Endpoints: - - GET /api/clients/contacts/ - list all contacts - - POST /api/clients/contacts/ - create contact - - GET /api/clients/contacts// - retrieve contact - - PUT /api/clients/contacts// - full update - - PATCH /api/clients/contacts// - partial update - - DELETE /api/clients/contacts// - soft delete (sets is_active=False) + - GET /api/companies/contacts/ - list all contacts + - POST /api/companies/contacts/ - create contact + - GET /api/companies/contacts// - retrieve contact + - PUT /api/companies/contacts// - full update + - PATCH /api/companies/contacts// - partial update + - DELETE /api/companies/contacts// - soft delete (sets is_active=False) Query Parameters: - - client_id: Filter contacts by client UUID + - company_id: Filter contacts by company UUID tags: - - clients + - companies requestBody: content: application/json: @@ -1693,22 +1693,22 @@ paths: schema: $ref: '#/components/schemas/ClientContact' description: '' - /api/clients/contacts/{id}/: + /api/companies/contacts/{id}/: get: - operationId: clients_contacts_retrieve + operationId: companies_contacts_retrieve description: |- ViewSet for ClientContact CRUD operations. Endpoints: - - GET /api/clients/contacts/ - list all contacts - - POST /api/clients/contacts/ - create contact - - GET /api/clients/contacts// - retrieve contact - - PUT /api/clients/contacts// - full update - - PATCH /api/clients/contacts// - partial update - - DELETE /api/clients/contacts// - soft delete (sets is_active=False) + - GET /api/companies/contacts/ - list all contacts + - POST /api/companies/contacts/ - create contact + - GET /api/companies/contacts// - retrieve contact + - PUT /api/companies/contacts// - full update + - PATCH /api/companies/contacts// - partial update + - DELETE /api/companies/contacts// - soft delete (sets is_active=False) Query Parameters: - - client_id: Filter contacts by client UUID + - company_id: Filter contacts by company UUID parameters: - in: path name: id @@ -1718,7 +1718,7 @@ paths: description: A UUID string identifying this Client Contact. required: true tags: - - clients + - companies security: - cookieAuth: [] responses: @@ -1729,20 +1729,20 @@ paths: $ref: '#/components/schemas/ClientContact' description: '' put: - operationId: clients_contacts_update + operationId: companies_contacts_update description: |- ViewSet for ClientContact CRUD operations. Endpoints: - - GET /api/clients/contacts/ - list all contacts - - POST /api/clients/contacts/ - create contact - - GET /api/clients/contacts// - retrieve contact - - PUT /api/clients/contacts// - full update - - PATCH /api/clients/contacts// - partial update - - DELETE /api/clients/contacts// - soft delete (sets is_active=False) + - GET /api/companies/contacts/ - list all contacts + - POST /api/companies/contacts/ - create contact + - GET /api/companies/contacts// - retrieve contact + - PUT /api/companies/contacts// - full update + - PATCH /api/companies/contacts// - partial update + - DELETE /api/companies/contacts// - soft delete (sets is_active=False) Query Parameters: - - client_id: Filter contacts by client UUID + - company_id: Filter contacts by company UUID parameters: - in: path name: id @@ -1752,7 +1752,7 @@ paths: description: A UUID string identifying this Client Contact. required: true tags: - - clients + - companies requestBody: content: application/json: @@ -1775,20 +1775,20 @@ paths: $ref: '#/components/schemas/ClientContact' description: '' patch: - operationId: clients_contacts_partial_update + operationId: companies_contacts_partial_update description: |- ViewSet for ClientContact CRUD operations. Endpoints: - - GET /api/clients/contacts/ - list all contacts - - POST /api/clients/contacts/ - create contact - - GET /api/clients/contacts// - retrieve contact - - PUT /api/clients/contacts// - full update - - PATCH /api/clients/contacts// - partial update - - DELETE /api/clients/contacts// - soft delete (sets is_active=False) + - GET /api/companies/contacts/ - list all contacts + - POST /api/companies/contacts/ - create contact + - GET /api/companies/contacts// - retrieve contact + - PUT /api/companies/contacts// - full update + - PATCH /api/companies/contacts// - partial update + - DELETE /api/companies/contacts// - soft delete (sets is_active=False) Query Parameters: - - client_id: Filter contacts by client UUID + - company_id: Filter contacts by company UUID parameters: - in: path name: id @@ -1798,7 +1798,7 @@ paths: description: A UUID string identifying this Client Contact. required: true tags: - - clients + - companies requestBody: content: application/json: @@ -1820,20 +1820,20 @@ paths: $ref: '#/components/schemas/ClientContact' description: '' delete: - operationId: clients_contacts_destroy + operationId: companies_contacts_destroy description: |- ViewSet for ClientContact CRUD operations. Endpoints: - - GET /api/clients/contacts/ - list all contacts - - POST /api/clients/contacts/ - create contact - - GET /api/clients/contacts// - retrieve contact - - PUT /api/clients/contacts// - full update - - PATCH /api/clients/contacts// - partial update - - DELETE /api/clients/contacts// - soft delete (sets is_active=False) + - GET /api/companies/contacts/ - list all contacts + - POST /api/companies/contacts/ - create contact + - GET /api/companies/contacts// - retrieve contact + - PUT /api/companies/contacts// - full update + - PATCH /api/companies/contacts// - partial update + - DELETE /api/companies/contacts// - soft delete (sets is_active=False) Query Parameters: - - client_id: Filter contacts by client UUID + - company_id: Filter contacts by company UUID parameters: - in: path name: id @@ -1843,31 +1843,31 @@ paths: description: A UUID string identifying this Client Contact. required: true tags: - - clients + - companies security: - cookieAuth: [] responses: '204': description: No response body - /api/clients/create/: + /api/companies/create/: post: - operationId: clients_create_create - description: Creates a new client in Xero first, then syncs locally. Requires + operationId: companies_create_create + description: Creates a new company in Xero first, then syncs locally. Requires valid Xero authentication. - summary: Create a new client + summary: Create a new company tags: - - Clients + - Companies requestBody: content: application/json: schema: - $ref: '#/components/schemas/ClientCreateRequest' + $ref: '#/components/schemas/CompanyCreateRequest' application/x-www-form-urlencoded: schema: - $ref: '#/components/schemas/ClientCreateRequest' + $ref: '#/components/schemas/CompanyCreateRequest' multipart/form-data: schema: - $ref: '#/components/schemas/ClientCreateRequest' + $ref: '#/components/schemas/CompanyCreateRequest' required: true security: - cookieAuth: [] @@ -1876,35 +1876,35 @@ paths: content: application/json: schema: - $ref: '#/components/schemas/ClientCreateResponse' + $ref: '#/components/schemas/CompanyCreateResponse' description: '' '400': content: application/json: schema: - $ref: '#/components/schemas/ClientErrorResponse' + $ref: '#/components/schemas/CompanyErrorResponse' description: '' '401': content: application/json: schema: - $ref: '#/components/schemas/ClientErrorResponse' + $ref: '#/components/schemas/CompanyErrorResponse' description: '' '409': content: application/json: schema: - $ref: '#/components/schemas/ClientDuplicateErrorResponse' + $ref: '#/components/schemas/CompanyDuplicateErrorResponse' description: '' '500': content: application/json: schema: - $ref: '#/components/schemas/ClientErrorResponse' + $ref: '#/components/schemas/CompanyErrorResponse' description: '' - /api/clients/jobs/{job_id}/contact/: + /api/companies/jobs/{job_id}/contact/: get: - operationId: clients_jobs_contact_retrieve + operationId: companies_jobs_contact_retrieve description: Retrieve contact information for a specific job. summary: Get job contact parameters: @@ -1916,7 +1916,7 @@ paths: description: UUID of the job required: true tags: - - Clients + - Companies security: - cookieAuth: [] responses: @@ -1930,16 +1930,16 @@ paths: content: application/json: schema: - $ref: '#/components/schemas/ClientErrorResponse' + $ref: '#/components/schemas/CompanyErrorResponse' description: '' '500': content: application/json: schema: - $ref: '#/components/schemas/ClientErrorResponse' + $ref: '#/components/schemas/CompanyErrorResponse' description: '' put: - operationId: clients_jobs_contact_update + operationId: companies_jobs_contact_update description: Update the contact person associated with a specific job. summary: Update job contact parameters: @@ -1951,7 +1951,7 @@ paths: description: UUID of the job required: true tags: - - Clients + - Companies requestBody: content: application/json: @@ -1977,23 +1977,23 @@ paths: content: application/json: schema: - $ref: '#/components/schemas/ClientErrorResponse' + $ref: '#/components/schemas/CompanyErrorResponse' description: '' '404': content: application/json: schema: - $ref: '#/components/schemas/ClientErrorResponse' + $ref: '#/components/schemas/CompanyErrorResponse' description: '' '500': content: application/json: schema: - $ref: '#/components/schemas/ClientErrorResponse' + $ref: '#/components/schemas/CompanyErrorResponse' description: '' - /api/clients/pickup-addresses/: + /api/companies/pickup-addresses/: get: - operationId: clients_pickup_addresses_list + operationId: companies_pickup_addresses_list description: List all pickup addresses, optionally filtered by supplier_id. parameters: - in: query @@ -2003,7 +2003,7 @@ paths: format: uuid description: Filter addresses by supplier UUID tags: - - clients + - companies security: - cookieAuth: [] responses: @@ -2016,22 +2016,22 @@ paths: $ref: '#/components/schemas/SupplierPickupAddress' description: '' post: - operationId: clients_pickup_addresses_create + operationId: companies_pickup_addresses_create description: |- ViewSet for SupplierPickupAddress CRUD operations. Endpoints: - - GET /api/clients/pickup-addresses/ - list all addresses - - POST /api/clients/pickup-addresses/ - create address - - GET /api/clients/pickup-addresses// - retrieve address - - PUT /api/clients/pickup-addresses// - full update - - PATCH /api/clients/pickup-addresses// - partial update - - DELETE /api/clients/pickup-addresses// - soft delete (sets is_active=False) + - GET /api/companies/pickup-addresses/ - list all addresses + - POST /api/companies/pickup-addresses/ - create address + - GET /api/companies/pickup-addresses// - retrieve address + - PUT /api/companies/pickup-addresses// - full update + - PATCH /api/companies/pickup-addresses// - partial update + - DELETE /api/companies/pickup-addresses// - soft delete (sets is_active=False) Query Parameters: - - supplier_id: Filter addresses by supplier (client) UUID + - supplier_id: Filter addresses by supplier (company) UUID tags: - - clients + - companies requestBody: content: application/json: @@ -2053,22 +2053,22 @@ paths: schema: $ref: '#/components/schemas/SupplierPickupAddress' description: '' - /api/clients/pickup-addresses/{id}/: + /api/companies/pickup-addresses/{id}/: get: - operationId: clients_pickup_addresses_retrieve + operationId: companies_pickup_addresses_retrieve description: |- ViewSet for SupplierPickupAddress CRUD operations. Endpoints: - - GET /api/clients/pickup-addresses/ - list all addresses - - POST /api/clients/pickup-addresses/ - create address - - GET /api/clients/pickup-addresses// - retrieve address - - PUT /api/clients/pickup-addresses// - full update - - PATCH /api/clients/pickup-addresses// - partial update - - DELETE /api/clients/pickup-addresses// - soft delete (sets is_active=False) + - GET /api/companies/pickup-addresses/ - list all addresses + - POST /api/companies/pickup-addresses/ - create address + - GET /api/companies/pickup-addresses// - retrieve address + - PUT /api/companies/pickup-addresses// - full update + - PATCH /api/companies/pickup-addresses// - partial update + - DELETE /api/companies/pickup-addresses// - soft delete (sets is_active=False) Query Parameters: - - supplier_id: Filter addresses by supplier (client) UUID + - supplier_id: Filter addresses by supplier (company) UUID parameters: - in: path name: id @@ -2078,7 +2078,7 @@ paths: description: A UUID string identifying this Supplier Pickup Address. required: true tags: - - clients + - companies security: - cookieAuth: [] responses: @@ -2089,20 +2089,20 @@ paths: $ref: '#/components/schemas/SupplierPickupAddress' description: '' put: - operationId: clients_pickup_addresses_update + operationId: companies_pickup_addresses_update description: |- ViewSet for SupplierPickupAddress CRUD operations. Endpoints: - - GET /api/clients/pickup-addresses/ - list all addresses - - POST /api/clients/pickup-addresses/ - create address - - GET /api/clients/pickup-addresses// - retrieve address - - PUT /api/clients/pickup-addresses// - full update - - PATCH /api/clients/pickup-addresses// - partial update - - DELETE /api/clients/pickup-addresses// - soft delete (sets is_active=False) + - GET /api/companies/pickup-addresses/ - list all addresses + - POST /api/companies/pickup-addresses/ - create address + - GET /api/companies/pickup-addresses// - retrieve address + - PUT /api/companies/pickup-addresses// - full update + - PATCH /api/companies/pickup-addresses// - partial update + - DELETE /api/companies/pickup-addresses// - soft delete (sets is_active=False) Query Parameters: - - supplier_id: Filter addresses by supplier (client) UUID + - supplier_id: Filter addresses by supplier (company) UUID parameters: - in: path name: id @@ -2112,7 +2112,7 @@ paths: description: A UUID string identifying this Supplier Pickup Address. required: true tags: - - clients + - companies requestBody: content: application/json: @@ -2135,20 +2135,20 @@ paths: $ref: '#/components/schemas/SupplierPickupAddress' description: '' patch: - operationId: clients_pickup_addresses_partial_update + operationId: companies_pickup_addresses_partial_update description: |- ViewSet for SupplierPickupAddress CRUD operations. Endpoints: - - GET /api/clients/pickup-addresses/ - list all addresses - - POST /api/clients/pickup-addresses/ - create address - - GET /api/clients/pickup-addresses// - retrieve address - - PUT /api/clients/pickup-addresses// - full update - - PATCH /api/clients/pickup-addresses// - partial update - - DELETE /api/clients/pickup-addresses// - soft delete (sets is_active=False) + - GET /api/companies/pickup-addresses/ - list all addresses + - POST /api/companies/pickup-addresses/ - create address + - GET /api/companies/pickup-addresses// - retrieve address + - PUT /api/companies/pickup-addresses// - full update + - PATCH /api/companies/pickup-addresses// - partial update + - DELETE /api/companies/pickup-addresses// - soft delete (sets is_active=False) Query Parameters: - - supplier_id: Filter addresses by supplier (client) UUID + - supplier_id: Filter addresses by supplier (company) UUID parameters: - in: path name: id @@ -2158,7 +2158,7 @@ paths: description: A UUID string identifying this Supplier Pickup Address. required: true tags: - - clients + - companies requestBody: content: application/json: @@ -2180,20 +2180,20 @@ paths: $ref: '#/components/schemas/SupplierPickupAddress' description: '' delete: - operationId: clients_pickup_addresses_destroy + operationId: companies_pickup_addresses_destroy description: |- ViewSet for SupplierPickupAddress CRUD operations. Endpoints: - - GET /api/clients/pickup-addresses/ - list all addresses - - POST /api/clients/pickup-addresses/ - create address - - GET /api/clients/pickup-addresses// - retrieve address - - PUT /api/clients/pickup-addresses// - full update - - PATCH /api/clients/pickup-addresses// - partial update - - DELETE /api/clients/pickup-addresses// - soft delete (sets is_active=False) + - GET /api/companies/pickup-addresses/ - list all addresses + - POST /api/companies/pickup-addresses/ - create address + - GET /api/companies/pickup-addresses// - retrieve address + - PUT /api/companies/pickup-addresses// - full update + - PATCH /api/companies/pickup-addresses// - partial update + - DELETE /api/companies/pickup-addresses// - soft delete (sets is_active=False) Query Parameters: - - supplier_id: Filter addresses by supplier (client) UUID + - supplier_id: Filter addresses by supplier (company) UUID parameters: - in: path name: id @@ -2203,17 +2203,17 @@ paths: description: A UUID string identifying this Supplier Pickup Address. required: true tags: - - clients + - companies security: - cookieAuth: [] responses: '204': description: No response body - /api/clients/search/: + /api/companies/search/: get: - operationId: clients_search_retrieve - description: Lists/searches clients with pagination and sorting. - summary: Search clients + operationId: companies_search_retrieve + description: Lists/searches companies with pagination and sorting. + summary: Search companies parameters: - in: query name: page @@ -2230,7 +2230,7 @@ paths: schema: type: string description: Search query (case-insensitive substring match). If empty, returns - all clients. + all companies. - in: query name: sort_by schema: @@ -2242,7 +2242,7 @@ paths: type: string description: "Sort direction: 'asc' or 'desc' (default 'asc')" tags: - - Clients + - Companies security: - cookieAuth: [] responses: @@ -2250,17 +2250,17 @@ paths: content: application/json: schema: - $ref: '#/components/schemas/ClientSearchResponse' + $ref: '#/components/schemas/CompanySearchResponse' description: '' '500': content: application/json: schema: - $ref: '#/components/schemas/ClientErrorResponse' + $ref: '#/components/schemas/CompanyErrorResponse' description: '' - /api/clients/supplier-aliases/{alias_id}/: + /api/companies/supplier-aliases/{alias_id}/: delete: - operationId: clients_supplier_aliases_destroy + operationId: companies_supplier_aliases_destroy description: Deactivate a supplier search alias. summary: Deactivate supplier search alias parameters: @@ -2271,7 +2271,7 @@ paths: format: uuid required: true tags: - - Clients + - Companies security: - cookieAuth: [] responses: @@ -2574,14 +2574,14 @@ paths: operationId: crm_phone_calls_list parameters: - in: query - name: client + name: client_match schema: type: string - format: uuid - in: query - name: client_match + name: company schema: type: string + format: uuid - in: query name: contact schema: @@ -3147,7 +3147,7 @@ paths: /api/job/data-quality/duplicate-phones/: get: operationId: check_duplicate_phones - description: List phone numbers owned by more than one client, or client numbers + description: List phone numbers owned by more than one company, or company numbers that are actually internal company lines. summary: Check duplicate phone ownership tags: @@ -4924,10 +4924,10 @@ paths: description: Endpoint for advanced job search - API endpoint. parameters: - in: query - name: client_name + name: company_name schema: type: string - description: Filter by client name + description: Filter by company name - in: query name: contact_person schema: @@ -4978,7 +4978,7 @@ paths: schema: type: string description: Universal search - searches across job number, name, description, - and client name with OR logic + and company name with OR logic - in: query name: rejected_flag schema: @@ -7659,7 +7659,7 @@ paths: description: |- Return latest price upload status per supplier. - Minimal-impact: read-only query over existing Client and SupplierPriceList + Minimal-impact: read-only query over existing Company and SupplierPriceList models. No migrations required. tags: - purchasing @@ -7868,7 +7868,7 @@ paths: /api/search-events/click/: post: operationId: search_events_click_create - description: Records a selected result from client, Kanban, or stock search. + description: Records a selected result from company, Kanban, or stock search. summary: Log search result selection tags: - Search telemetry @@ -8121,7 +8121,7 @@ paths: content: application/json: schema: - $ref: '#/components/schemas/ClientErrorResponse' + $ref: '#/components/schemas/CompanyErrorResponse' description: '' /api/timesheets/payroll/pay-runs/create: post: @@ -8155,19 +8155,19 @@ paths: content: application/json: schema: - $ref: '#/components/schemas/ClientErrorResponse' + $ref: '#/components/schemas/CompanyErrorResponse' description: '' '409': content: application/json: schema: - $ref: '#/components/schemas/ClientErrorResponse' + $ref: '#/components/schemas/CompanyErrorResponse' description: '' '500': content: application/json: schema: - $ref: '#/components/schemas/ClientErrorResponse' + $ref: '#/components/schemas/CompanyErrorResponse' description: '' /api/timesheets/payroll/pay-runs/refresh: post: @@ -8189,7 +8189,7 @@ paths: content: application/json: schema: - $ref: '#/components/schemas/ClientErrorResponse' + $ref: '#/components/schemas/CompanyErrorResponse' description: '' /api/timesheets/payroll/post-staff-week/: post: @@ -8226,7 +8226,7 @@ paths: content: application/json: schema: - $ref: '#/components/schemas/ClientErrorResponse' + $ref: '#/components/schemas/CompanyErrorResponse' description: '' /api/timesheets/staff/: get: @@ -8312,13 +8312,13 @@ paths: content: application/json: schema: - $ref: '#/components/schemas/ClientErrorResponse' + $ref: '#/components/schemas/CompanyErrorResponse' description: '' '500': content: application/json: schema: - $ref: '#/components/schemas/ClientErrorResponse' + $ref: '#/components/schemas/CompanyErrorResponse' description: '' /api/workflow/ai-providers/: get: @@ -9957,9 +9957,9 @@ components: job_number: type: string description: Job number - client_name: + company_name: type: string - description: Client name or 'Shop Job' + description: Company name or 'Shop Job' archived_date: type: string format: date @@ -9994,7 +9994,7 @@ components: description: Total job value (invoiced amount or quote/actual revenue) required: - archived_date - - client_name + - company_name - current_status - issue - job_id @@ -10187,10 +10187,10 @@ components: type: string format: uuid readOnly: true - client: + company: type: string format: uuid - description: The client this contact belongs to + description: The company this contact belongs to name: type: string description: Full name of the contact person @@ -10208,7 +10208,7 @@ components: maxLength: 255 is_primary: type: boolean - description: Indicates if this is the primary contact for the client + description: Indicates if this is the primary contact for the company notes: type: string nullable: true @@ -10227,7 +10227,7 @@ components: format: date-time readOnly: true required: - - client + - company - created_at - id - is_active @@ -10235,20 +10235,20 @@ components: - updated_at ClientContactMethod: type: object - description: Serializer for canonical client/contact phone and email methods. + description: Serializer for canonical company/contact phone and email methods. properties: id: type: string format: uuid readOnly: true - client: + company: type: string format: uuid nullable: true - owner_client: + owner_company: type: string readOnly: true - client_name: + company_name: type: string readOnly: true contact: @@ -10283,20 +10283,20 @@ components: format: date-time readOnly: true required: - - client_name + - company_name - contact_name - created_at - id - method_type - normalized_value - - owner_client + - owner_company - updated_at - value ClientContactMethodRequest: type: object - description: Serializer for canonical client/contact phone and email methods. + description: Serializer for canonical company/contact phone and email methods. properties: - client: + company: type: string format: uuid nullable: true @@ -10333,10 +10333,10 @@ components: type: object description: Serializer for ClientContact model. properties: - client: + company: type: string format: uuid - description: The client this contact belongs to + description: The company this contact belongs to name: type: string minLength: 1 @@ -10355,17 +10355,17 @@ components: maxLength: 255 is_primary: type: boolean - description: Indicates if this is the primary contact for the client + description: Indicates if this is the primary contact for the company notes: type: string nullable: true description: Additional notes about this contact required: - - client + - company - name - ClientCreateRequest: + CompanyCreateRequest: type: object - description: Serializer for client creation request + description: Serializer for company creation request properties: name: type: string @@ -10386,609 +10386,335 @@ components: default: true required: - name - ClientCreateResponse: + CompanyCreateResponse: type: object - description: Serializer for client creation response + description: Serializer for company creation response properties: success: type: boolean - client: - $ref: '#/components/schemas/ClientSearchResult' + company: + $ref: '#/components/schemas/CompanySearchResult' message: type: string required: - - client + - company - message - success - ClientDetailResponse: + CompanyDefaults: type: object - description: Serializer for client detail response properties: id: + type: integer + readOnly: true + logo_url: type: string - name: + nullable: true + readOnly: true + logo_wide_url: type: string - email: + nullable: true + readOnly: true + company_name: type: string - address: + readOnly: true + company_acronym: type: string - is_account_customer: - type: boolean - is_supplier: - type: boolean - allow_jobs: - type: boolean - xero_contact_id: + nullable: true + description: Short acronym for the company (e.g., 'MSM' for Morris Sheetmetal) + maxLength: 10 + time_markup: + type: number + format: double + maximum: 1000 + minimum: -1000 + exclusiveMaximum: true + exclusiveMinimum: true + materials_markup: + type: number + format: double + maximum: 1000 + minimum: -1000 + exclusiveMaximum: true + exclusiveMinimum: true + wage_rate: + type: number + format: double + maximum: 10000 + minimum: -10000 + exclusiveMaximum: true + exclusiveMinimum: true + annual_leave_loading: + type: number + format: double + maximum: 1000 + minimum: -1000 + exclusiveMaximum: true + exclusiveMinimum: true + description: Percentage added to base_wage_rate to get costing wage_rate + (20.00 = 20%) + workshop_efficiency_factor: + type: number + format: double + maximum: 10 + minimum: -10 + exclusiveMaximum: true + exclusiveMinimum: true + description: Fraction of clocked workshop hours that count as schedulable + productive output (e.g. 0.750 = 75%). Accounts for breaks, tool changes, + idle time. Applied to per-day capacity in the workshop scheduler after + subtracting booked time/leave. + financial_year_start_month: + type: integer + maximum: 2147483647 + minimum: -2147483648 + description: Month the financial year starts (1=January, 4=April, 7=July, + etc.) + starting_job_number: + type: integer + maximum: 2147483647 + minimum: -2147483648 + description: Helper field to set the starting job number based on the latest + paper job + starting_po_number: + type: integer + maximum: 2147483647 + minimum: -2147483648 + description: Helper field to set the starting purchase order number + po_prefix: type: string - xero_tenant_id: + description: Prefix for purchase order numbers (e.g., PO-, JO-) + maxLength: 10 + master_quote_template_url: type: string - primary_contact_name: + format: uri + nullable: true + description: URL to the master Google Sheets quote template + maxLength: 200 + master_quote_template_id: type: string - primary_contact_email: + nullable: true + description: Google Sheets ID for the quote template + maxLength: 100 + gdrive_quotes_folder_url: type: string - additional_contact_persons: - type: array - items: {} - xero_last_modified: + format: uri + nullable: true + description: URL to the Google Drive folder for storing quotes + maxLength: 200 + gdrive_quotes_folder_id: type: string - format: date-time nullable: true - xero_last_synced: + description: Google Drive folder ID for storing quotes + maxLength: 100 + google_shared_drive_id: type: string - format: date-time nullable: true - xero_archived: - type: boolean - xero_merged_into_id: + description: Google Shared Drive ID for the company shared drive + maxLength: 100 + gdrive_how_we_work_folder_id: type: string - merged_into: + nullable: true + description: Folder ID for '01 - How we work' (policies, basics) + maxLength: 100 + gdrive_sops_folder_id: type: string nullable: true - django_created_at: + description: Folder ID for '02 - SOPs' (standard operating procedures) + maxLength: 100 + gdrive_reference_library_folder_id: type: string - format: date-time - django_updated_at: + nullable: true + description: Folder ID for '03 - Reference Library' (reference documents, + forms, registers) + maxLength: 100 + accounting_provider: type: string - format: date-time - last_invoice_date: + description: "Active accounting integration: 'xero' or 'myob'" + maxLength: 20 + xero_tenant_id: type: string - format: date-time nullable: true - total_spend: - type: string - required: - - address - - allow_jobs - - django_created_at - - django_updated_at - - email - - id - - is_account_customer - - is_supplier - - last_invoice_date - - merged_into - - name - - primary_contact_email - - primary_contact_name - - total_spend - - xero_archived - - xero_contact_id - - xero_last_modified - - xero_last_synced - - xero_merged_into_id - - xero_tenant_id - ClientDuplicateErrorResponse: - type: object - description: Serializer for client duplicate error response - properties: - success: - type: boolean - default: false - error: + description: The Xero tenant ID to use for this company + maxLength: 100 + xero_shortcode: type: string - existing_client: - type: object - additionalProperties: {} - required: - - error - - existing_client - ClientErrorResponse: - type: object - description: Serializer for client error responses - properties: - success: + nullable: true + description: Xero organisation shortcode for deep linking (e.g., '!8-5Xl') + maxLength: 20 + enable_xero_sync: type: boolean - default: false - error: - type: string - details: + description: Gate for Xero sync. Defaults True (prod). Dev fixture sets + False; seed_xero_from_database sets True after prod IDs are cleared. + xero_automated_day_floor: + type: integer + maximum: 2147483647 + minimum: 0 + description: Reserve this many Xero daily API calls for user-initiated work. + Automated sync aborts when the active Xero app reports remaining daily + calls at or below this value. + xero_payroll_calendar_name: type: string - required: - - error - ClientJobHeader: - type: object - description: Serializer for job header in client jobs list. - properties: - job_id: + description: Name of Xero Payroll calendar to use (e.g., 'Weekly 2025') + maxLength: 100 + xero_payroll_calendar_id: type: string format: uuid - job_number: - type: integer - name: - type: string - client: - type: object - additionalProperties: {} nullable: true - status: - type: string - pricing_methodology: + description: Cached Xero Payroll calendar ID (set by xero --setup command) + xero_payroll_start_date: type: string + format: date nullable: true - speed_quality_tradeoff: - type: string - fully_invoiced: - type: boolean - has_quote_in_xero: + description: Date Xero payroll went live — reconciliation ignores data before + this + weekend_timesheets_enabled: type: boolean - is_fixed_price: + description: Show Saturday and Sunday in timesheet views (7-day week). Off + = 5-day Mon-Fri. + job_delta_soft_fail: type: boolean - quote_acceptance_date: + description: When enabled, job delta checksum mismatches are logged and + recorded without blocking the save. Disable to reject stale updates. + mon_start: type: string - format: date-time - nullable: true - paid: - type: boolean - rejected_flag: - type: boolean - min_people: - type: integer - max_people: - type: integer - required: - - client - - fully_invoiced - - has_quote_in_xero - - is_fixed_price - - job_id - - job_number - - max_people - - min_people - - name - - paid - - pricing_methodology - - quote_acceptance_date - - rejected_flag - - speed_quality_tradeoff - - status - ClientJobsResponse: - type: object - description: Serializer for client jobs list response - properties: - results: - type: array - items: - $ref: '#/components/schemas/ClientJobHeader' - required: - - results - ClientNameOnly: - type: object - properties: - id: + format: time + mon_end: type: string - format: uuid - readOnly: true - name: + format: time + tue_start: type: string - readOnly: true - required: - - id - - name - ClientSearchResponse: - type: object - description: Serializer for paginated client search response - properties: - results: - type: array - items: - $ref: '#/components/schemas/ClientSearchResult' - count: - type: integer - page: - type: integer - page_size: - type: integer - total_pages: - type: integer - required: - - count - - page - - page_size - - results - - total_pages - ClientSearchResult: - type: object - description: Serializer for individual client search result - properties: - id: + format: time + tue_end: type: string - name: + format: time + wed_start: type: string - email: + format: time + wed_end: type: string - address: + format: time + thu_start: type: string - is_account_customer: - type: boolean - is_supplier: - type: boolean - allow_jobs: - type: boolean - xero_contact_id: + format: time + thu_end: type: string - last_invoice_date: + format: time + fri_start: + type: string + format: time + fri_end: + type: string + format: time + created_at: + type: string + format: date-time + readOnly: true + updated_at: + type: string + format: date-time + readOnly: true + last_xero_sync: type: string format: date-time nullable: true - total_spend: + description: The last time Xero data was synchronized + last_xero_deep_sync: type: string - required: - - address - - allow_jobs - - email - - id - - is_account_customer - - is_supplier - - last_invoice_date - - name - - total_spend - - xero_contact_id - ClientUpdateRequest: - type: object - description: Serializer for client update request - properties: - name: + format: date-time + nullable: true + description: The last time a deep Xero sync was performed (looking back + 90 days) + address_line1: type: string - minLength: 1 + nullable: true + description: Street address line 1 maxLength: 255 - email: + address_line2: type: string - format: email - address: + nullable: true + description: Street address line 2 (optional) + maxLength: 255 + suburb: type: string - is_account_customer: - type: boolean - allow_jobs: - type: boolean - ClientUpdateResponse: - type: object - description: Serializer for client update response - properties: - success: - type: boolean - client: - $ref: '#/components/schemas/ClientDetailResponse' - message: + nullable: true + description: Suburb (for NZ addresses) + maxLength: 100 + city: type: string - required: - - client - - message - - success - CompanyDefaults: - type: object - properties: - id: - type: integer - readOnly: true - logo_url: + nullable: true + description: City + maxLength: 100 + post_code: type: string nullable: true - readOnly: true - logo_wide_url: + description: Postal/ZIP code + maxLength: 20 + country: + type: string + description: Country name + maxLength: 100 + company_email: type: string + format: email nullable: true - readOnly: true - company_name: + description: Company contact email address + maxLength: 254 + company_url: type: string - readOnly: true - company_acronym: + format: uri + nullable: true + description: Company website URL + maxLength: 200 + test_company_name: type: string nullable: true - description: Short acronym for the company (e.g., 'MSM' for Morris Sheetmetal) - maxLength: 10 - time_markup: + description: Name of the test company used for testing (e.g., 'ABC Carpet + Cleaning TEST IGNORE'). This company's name is preserved during data backports. + maxLength: 255 + kpi_daily_billable_hours_green: type: number format: double maximum: 1000 minimum: -1000 exclusiveMaximum: true exclusiveMinimum: true - materials_markup: + title: Daily billable hours (green) + description: Daily total billable hours across all staff above which the + day is green + kpi_daily_billable_hours_amber: type: number format: double maximum: 1000 minimum: -1000 exclusiveMaximum: true exclusiveMinimum: true - wage_rate: + title: Daily billable hours (amber) + description: Daily total billable hours across all staff above which the + day is amber + kpi_daily_gp_target: type: number format: double - maximum: 10000 - minimum: -10000 + maximum: 100000000 + minimum: -100000000 exclusiveMaximum: true exclusiveMinimum: true - annual_leave_loading: + title: Daily gross profit target + description: Daily gross profit target in dollars + kpi_daily_shop_hours_percentage: type: number format: double maximum: 1000 minimum: -1000 exclusiveMaximum: true exclusiveMinimum: true - description: Percentage added to base_wage_rate to get costing wage_rate - (20.00 = 20%) - workshop_efficiency_factor: + title: Daily shop hours percentage target + description: Target percentage of daily hours spent on shop (non-billable) + jobs + kpi_job_gp_target_percentage: type: number format: double - maximum: 10 - minimum: -10 - exclusiveMaximum: true - exclusiveMinimum: true - description: Fraction of clocked workshop hours that count as schedulable - productive output (e.g. 0.750 = 75%). Accounts for breaks, tool changes, - idle time. Applied to per-day capacity in the workshop scheduler after - subtracting booked time/leave. - financial_year_start_month: - type: integer - maximum: 2147483647 - minimum: -2147483648 - description: Month the financial year starts (1=January, 4=April, 7=July, - etc.) - starting_job_number: - type: integer - maximum: 2147483647 - minimum: -2147483648 - description: Helper field to set the starting job number based on the latest - paper job - starting_po_number: - type: integer - maximum: 2147483647 - minimum: -2147483648 - description: Helper field to set the starting purchase order number - po_prefix: - type: string - description: Prefix for purchase order numbers (e.g., PO-, JO-) - maxLength: 10 - master_quote_template_url: - type: string - format: uri - nullable: true - description: URL to the master Google Sheets quote template - maxLength: 200 - master_quote_template_id: - type: string - nullable: true - description: Google Sheets ID for the quote template - maxLength: 100 - gdrive_quotes_folder_url: - type: string - format: uri - nullable: true - description: URL to the Google Drive folder for storing quotes - maxLength: 200 - gdrive_quotes_folder_id: - type: string - nullable: true - description: Google Drive folder ID for storing quotes - maxLength: 100 - google_shared_drive_id: - type: string - nullable: true - description: Google Shared Drive ID for the company shared drive - maxLength: 100 - gdrive_how_we_work_folder_id: - type: string - nullable: true - description: Folder ID for '01 - How we work' (policies, basics) - maxLength: 100 - gdrive_sops_folder_id: - type: string - nullable: true - description: Folder ID for '02 - SOPs' (standard operating procedures) - maxLength: 100 - gdrive_reference_library_folder_id: - type: string - nullable: true - description: Folder ID for '03 - Reference Library' (reference documents, - forms, registers) - maxLength: 100 - accounting_provider: - type: string - description: "Active accounting integration: 'xero' or 'myob'" - maxLength: 20 - xero_tenant_id: - type: string - nullable: true - description: The Xero tenant ID to use for this company - maxLength: 100 - xero_shortcode: - type: string - nullable: true - description: Xero organisation shortcode for deep linking (e.g., '!8-5Xl') - maxLength: 20 - enable_xero_sync: - type: boolean - description: Gate for Xero sync. Defaults True (prod). Dev fixture sets - False; seed_xero_from_database sets True after prod IDs are cleared. - xero_automated_day_floor: - type: integer - maximum: 2147483647 - minimum: 0 - description: Reserve this many Xero daily API calls for user-initiated work. - Automated sync aborts when the active Xero app reports remaining daily - calls at or below this value. - xero_payroll_calendar_name: - type: string - description: Name of Xero Payroll calendar to use (e.g., 'Weekly 2025') - maxLength: 100 - xero_payroll_calendar_id: - type: string - format: uuid - nullable: true - description: Cached Xero Payroll calendar ID (set by xero --setup command) - xero_payroll_start_date: - type: string - format: date - nullable: true - description: Date Xero payroll went live — reconciliation ignores data before - this - weekend_timesheets_enabled: - type: boolean - description: Show Saturday and Sunday in timesheet views (7-day week). Off - = 5-day Mon-Fri. - job_delta_soft_fail: - type: boolean - description: When enabled, job delta checksum mismatches are logged and - recorded without blocking the save. Disable to reject stale updates. - mon_start: - type: string - format: time - mon_end: - type: string - format: time - tue_start: - type: string - format: time - tue_end: - type: string - format: time - wed_start: - type: string - format: time - wed_end: - type: string - format: time - thu_start: - type: string - format: time - thu_end: - type: string - format: time - fri_start: - type: string - format: time - fri_end: - type: string - format: time - created_at: - type: string - format: date-time - readOnly: true - updated_at: - type: string - format: date-time - readOnly: true - last_xero_sync: - type: string - format: date-time - nullable: true - description: The last time Xero data was synchronized - last_xero_deep_sync: - type: string - format: date-time - nullable: true - description: The last time a deep Xero sync was performed (looking back - 90 days) - address_line1: - type: string - nullable: true - description: Street address line 1 - maxLength: 255 - address_line2: - type: string - nullable: true - description: Street address line 2 (optional) - maxLength: 255 - suburb: - type: string - nullable: true - description: Suburb (for NZ addresses) - maxLength: 100 - city: - type: string - nullable: true - description: City - maxLength: 100 - post_code: - type: string - nullable: true - description: Postal/ZIP code - maxLength: 20 - country: - type: string - description: Country name - maxLength: 100 - company_email: - type: string - format: email - nullable: true - description: Company contact email address - maxLength: 254 - company_url: - type: string - format: uri - nullable: true - description: Company website URL - maxLength: 200 - test_client_name: - type: string - nullable: true - description: Name of the test client used for testing (e.g., 'ABC Carpet - Cleaning TEST IGNORE'). This client's name is preserved during data backports. - maxLength: 255 - kpi_daily_billable_hours_green: - type: number - format: double - maximum: 1000 - minimum: -1000 - exclusiveMaximum: true - exclusiveMinimum: true - title: Daily billable hours (green) - description: Daily total billable hours across all staff above which the - day is green - kpi_daily_billable_hours_amber: - type: number - format: double - maximum: 1000 - minimum: -1000 - exclusiveMaximum: true - exclusiveMinimum: true - title: Daily billable hours (amber) - description: Daily total billable hours across all staff above which the - day is amber - kpi_daily_gp_target: - type: number - format: double - maximum: 100000000 - minimum: -100000000 - exclusiveMaximum: true - exclusiveMinimum: true - title: Daily gross profit target - description: Daily gross profit target in dollars - kpi_daily_shop_hours_percentage: - type: number - format: double - maximum: 1000 - minimum: -1000 - exclusiveMaximum: true - exclusiveMinimum: true - title: Daily shop hours percentage target - description: Target percentage of daily hours spent on shop (non-billable) - jobs - kpi_job_gp_target_percentage: - type: number - format: double - maximum: 1000 - minimum: -1000 + maximum: 1000 + minimum: -1000 exclusiveMaximum: true exclusiveMinimum: true title: Target GP % per job @@ -11020,17 +10746,17 @@ components: exclusiveMinimum: true description: Target daily hours of newly-approved work flowing into the shop, used by the Sales Pipeline scoreboard - shop_client: + shop_company: type: string format: uuid - description: Internal client used for tracking shop work. + description: Internal company used for tracking shop work. required: - company_name - created_at - id - logo_url - logo_wide_url - - shop_client + - shop_company - updated_at CompanyDefaultsJobDetail: type: object @@ -11258,154 +10984,428 @@ components: format: time last_xero_sync: type: string - format: date-time - nullable: true - description: The last time Xero data was synchronized - last_xero_deep_sync: + format: date-time + nullable: true + description: The last time Xero data was synchronized + last_xero_deep_sync: + type: string + format: date-time + nullable: true + description: The last time a deep Xero sync was performed (looking back + 90 days) + address_line1: + type: string + nullable: true + description: Street address line 1 + maxLength: 255 + address_line2: + type: string + nullable: true + description: Street address line 2 (optional) + maxLength: 255 + suburb: + type: string + nullable: true + description: Suburb (for NZ addresses) + maxLength: 100 + city: + type: string + nullable: true + description: City + maxLength: 100 + post_code: + type: string + nullable: true + description: Postal/ZIP code + maxLength: 20 + country: + type: string + minLength: 1 + description: Country name + maxLength: 100 + company_email: + type: string + format: email + nullable: true + description: Company contact email address + maxLength: 254 + company_url: + type: string + format: uri + nullable: true + description: Company website URL + maxLength: 200 + test_company_name: + type: string + nullable: true + description: Name of the test company used for testing (e.g., 'ABC Carpet + Cleaning TEST IGNORE'). This company's name is preserved during data backports. + maxLength: 255 + kpi_daily_billable_hours_green: + type: number + format: double + maximum: 1000 + minimum: -1000 + exclusiveMaximum: true + exclusiveMinimum: true + title: Daily billable hours (green) + description: Daily total billable hours across all staff above which the + day is green + kpi_daily_billable_hours_amber: + type: number + format: double + maximum: 1000 + minimum: -1000 + exclusiveMaximum: true + exclusiveMinimum: true + title: Daily billable hours (amber) + description: Daily total billable hours across all staff above which the + day is amber + kpi_daily_gp_target: + type: number + format: double + maximum: 100000000 + minimum: -100000000 + exclusiveMaximum: true + exclusiveMinimum: true + title: Daily gross profit target + description: Daily gross profit target in dollars + kpi_daily_shop_hours_percentage: + type: number + format: double + maximum: 1000 + minimum: -1000 + exclusiveMaximum: true + exclusiveMinimum: true + title: Daily shop hours percentage target + description: Target percentage of daily hours spent on shop (non-billable) + jobs + kpi_job_gp_target_percentage: + type: number + format: double + maximum: 1000 + minimum: -1000 + exclusiveMaximum: true + exclusiveMinimum: true + title: Target GP % per job + description: Target gross profit percentage for individual jobs + kpi_daily_gp_green: + type: number + format: double + maximum: 100000000 + minimum: -100000000 + exclusiveMaximum: true + exclusiveMinimum: true + title: Daily GP (green) + description: Daily gross profit above which the day is green + kpi_daily_gp_amber: + type: number + format: double + maximum: 100000000 + minimum: -100000000 + exclusiveMaximum: true + exclusiveMinimum: true + title: Daily GP (amber) + description: Daily gross profit above which the day is amber + daily_approved_hours_target: + type: number + format: double + maximum: 1000 + minimum: -1000 + exclusiveMaximum: true + exclusiveMinimum: true + description: Target daily hours of newly-approved work flowing into the + shop, used by the Sales Pipeline scoreboard + shop_company: + type: string + format: uuid + description: Internal company used for tracking shop work. + required: + - shop_company + CompanyDefaultsSchema: + type: object + description: Serializer for the complete schema response. + properties: + sections: + type: array + items: + $ref: '#/components/schemas/SettingsSection' + required: + - sections + CompanyDetailResponse: + type: object + description: Serializer for company detail response + properties: + id: + type: string + name: + type: string + email: + type: string + address: + type: string + is_account_customer: + type: boolean + is_supplier: + type: boolean + allow_jobs: + type: boolean + xero_contact_id: + type: string + xero_tenant_id: + type: string + primary_contact_name: + type: string + primary_contact_email: + type: string + additional_contact_persons: + type: array + items: {} + xero_last_modified: + type: string + format: date-time + nullable: true + xero_last_synced: + type: string + format: date-time + nullable: true + xero_archived: + type: boolean + xero_merged_into_id: + type: string + merged_into: + type: string + nullable: true + django_created_at: + type: string + format: date-time + django_updated_at: + type: string + format: date-time + last_invoice_date: + type: string + format: date-time + nullable: true + total_spend: + type: string + required: + - address + - allow_jobs + - django_created_at + - django_updated_at + - email + - id + - is_account_customer + - is_supplier + - last_invoice_date + - merged_into + - name + - primary_contact_email + - primary_contact_name + - total_spend + - xero_archived + - xero_contact_id + - xero_last_modified + - xero_last_synced + - xero_merged_into_id + - xero_tenant_id + CompanyDuplicateErrorResponse: + type: object + description: Serializer for company duplicate error response + properties: + success: + type: boolean + default: false + error: + type: string + existing_company: + type: object + additionalProperties: {} + required: + - error + - existing_company + CompanyErrorResponse: + type: object + description: Serializer for company error responses + properties: + success: + type: boolean + default: false + error: + type: string + details: + type: string + required: + - error + CompanyJobHeader: + type: object + description: Serializer for job header in company jobs list. + properties: + job_id: + type: string + format: uuid + job_number: + type: integer + name: + type: string + company: + type: object + additionalProperties: {} + nullable: true + status: + type: string + pricing_methodology: + type: string + nullable: true + speed_quality_tradeoff: + type: string + fully_invoiced: + type: boolean + has_quote_in_xero: + type: boolean + is_fixed_price: + type: boolean + quote_acceptance_date: + type: string + format: date-time + nullable: true + paid: + type: boolean + rejected_flag: + type: boolean + min_people: + type: integer + max_people: + type: integer + required: + - company + - fully_invoiced + - has_quote_in_xero + - is_fixed_price + - job_id + - job_number + - max_people + - min_people + - name + - paid + - pricing_methodology + - quote_acceptance_date + - rejected_flag + - speed_quality_tradeoff + - status + CompanyJobsResponse: + type: object + description: Serializer for company jobs list response + properties: + results: + type: array + items: + $ref: '#/components/schemas/CompanyJobHeader' + required: + - results + CompanyNameOnly: + type: object + properties: + id: + type: string + format: uuid + readOnly: true + name: + type: string + readOnly: true + required: + - id + - name + CompanySearchResponse: + type: object + description: Serializer for paginated company search response + properties: + results: + type: array + items: + $ref: '#/components/schemas/CompanySearchResult' + count: + type: integer + page: + type: integer + page_size: + type: integer + total_pages: + type: integer + required: + - count + - page + - page_size + - results + - total_pages + CompanySearchResult: + type: object + description: Serializer for individual company search result + properties: + id: + type: string + name: type: string - format: date-time - nullable: true - description: The last time a deep Xero sync was performed (looking back - 90 days) - address_line1: + email: type: string - nullable: true - description: Street address line 1 - maxLength: 255 - address_line2: + address: type: string - nullable: true - description: Street address line 2 (optional) - maxLength: 255 - suburb: + is_account_customer: + type: boolean + is_supplier: + type: boolean + allow_jobs: + type: boolean + xero_contact_id: type: string - nullable: true - description: Suburb (for NZ addresses) - maxLength: 100 - city: + last_invoice_date: type: string + format: date-time nullable: true - description: City - maxLength: 100 - post_code: + total_spend: type: string - nullable: true - description: Postal/ZIP code - maxLength: 20 - country: + required: + - address + - allow_jobs + - email + - id + - is_account_customer + - is_supplier + - last_invoice_date + - name + - total_spend + - xero_contact_id + CompanyUpdateRequest: + type: object + description: Serializer for company update request + properties: + name: type: string minLength: 1 - description: Country name - maxLength: 100 - company_email: + maxLength: 255 + email: type: string format: email - nullable: true - description: Company contact email address - maxLength: 254 - company_url: - type: string - format: uri - nullable: true - description: Company website URL - maxLength: 200 - test_client_name: - type: string - nullable: true - description: Name of the test client used for testing (e.g., 'ABC Carpet - Cleaning TEST IGNORE'). This client's name is preserved during data backports. - maxLength: 255 - kpi_daily_billable_hours_green: - type: number - format: double - maximum: 1000 - minimum: -1000 - exclusiveMaximum: true - exclusiveMinimum: true - title: Daily billable hours (green) - description: Daily total billable hours across all staff above which the - day is green - kpi_daily_billable_hours_amber: - type: number - format: double - maximum: 1000 - minimum: -1000 - exclusiveMaximum: true - exclusiveMinimum: true - title: Daily billable hours (amber) - description: Daily total billable hours across all staff above which the - day is amber - kpi_daily_gp_target: - type: number - format: double - maximum: 100000000 - minimum: -100000000 - exclusiveMaximum: true - exclusiveMinimum: true - title: Daily gross profit target - description: Daily gross profit target in dollars - kpi_daily_shop_hours_percentage: - type: number - format: double - maximum: 1000 - minimum: -1000 - exclusiveMaximum: true - exclusiveMinimum: true - title: Daily shop hours percentage target - description: Target percentage of daily hours spent on shop (non-billable) - jobs - kpi_job_gp_target_percentage: - type: number - format: double - maximum: 1000 - minimum: -1000 - exclusiveMaximum: true - exclusiveMinimum: true - title: Target GP % per job - description: Target gross profit percentage for individual jobs - kpi_daily_gp_green: - type: number - format: double - maximum: 100000000 - minimum: -100000000 - exclusiveMaximum: true - exclusiveMinimum: true - title: Daily GP (green) - description: Daily gross profit above which the day is green - kpi_daily_gp_amber: - type: number - format: double - maximum: 100000000 - minimum: -100000000 - exclusiveMaximum: true - exclusiveMinimum: true - title: Daily GP (amber) - description: Daily gross profit above which the day is amber - daily_approved_hours_target: - type: number - format: double - maximum: 1000 - minimum: -1000 - exclusiveMaximum: true - exclusiveMinimum: true - description: Target daily hours of newly-approved work flowing into the - shop, used by the Sales Pipeline scoreboard - shop_client: + address: type: string - format: uuid - description: Internal client used for tracking shop work. - required: - - shop_client - CompanyDefaultsSchema: + is_account_customer: + type: boolean + allow_jobs: + type: boolean + CompanyUpdateResponse: type: object - description: Serializer for the complete schema response. + description: Serializer for company update response properties: - sections: - type: array - items: - $ref: '#/components/schemas/SettingsSection' + success: + type: boolean + company: + $ref: '#/components/schemas/CompanyDetailResponse' + message: + type: string required: - - sections + - company + - message + - success CompleteJob: type: object properties: @@ -11420,7 +11420,7 @@ components: name: type: string maxLength: 100 - client_name: + company_name: type: string readOnly: true updated_at: @@ -11430,7 +11430,7 @@ components: job_status: type: string required: - - client_name + - company_name - id - job_number - job_status @@ -12225,14 +12225,14 @@ components: description: Contact-method id owner_kind: type: string - description: "'client' or 'contact'" + description: "'company' or 'contact'" owner_name: type: string description: Human-readable owner effective_client_id: type: string nullable: true - description: Client the number resolves to + description: Company the number resolves to required: - effective_client_id - method_id @@ -12244,10 +12244,10 @@ components: properties: cross_client: type: integer - description: Numbers owned by more than one client + description: Numbers owned by more than one company internal_line: type: integer - description: Client numbers that are actually internal company lines + description: Company numbers that are actually internal company lines required: - cross_client - internal_line @@ -12728,7 +12728,7 @@ components: iterates the unresolved rows, computes each message's hash, and cascades the resolve across every row whose hash matches. - Using a fingerprint (not the message string) avoids client-side + Using a fingerprint (not the message string) avoids company-side whitespace mangling: the frontend's global axios interceptor calls trimStringsDeep on outbound payloads, which would strip trailing whitespace and prevent a later exact match. @@ -12951,11 +12951,11 @@ components: name: type: string maxLength: 100 - client_id: + company_id: type: string format: uuid nullable: true - client_name: + company_name: type: string readOnly: true nullable: true @@ -13081,7 +13081,7 @@ components: readOnly: true shop_job: type: boolean - description: Indicates if this is a shop job (belongs to shop client). + description: Indicates if this is a shop job (belongs to shop company). readOnly: true rejected_flag: type: boolean @@ -13123,7 +13123,7 @@ components: type: boolean description: Whether this job requires urgent rates and priority handling required: - - client_name + - company_name - contact_name - created_at - default_xero_pay_item_name @@ -13170,7 +13170,7 @@ components: type: integer name: type: string - client_name: + company_name: type: string status: type: string @@ -13181,7 +13181,7 @@ components: timing_data: $ref: '#/components/schemas/JobAgingTimingData' required: - - client_name + - company_name - financial_data - id - job_number @@ -13261,7 +13261,7 @@ components: type: integer job_name: type: string - client: + company: type: string hours: type: number @@ -13275,7 +13275,7 @@ components: is_billable: type: boolean required: - - client + - company - cost - hours - is_billable @@ -13388,7 +13388,7 @@ components: type: string minLength: 1 maxLength: 255 - client_id: + company_id: type: string format: uuid description: @@ -13423,7 +13423,7 @@ components: type: boolean default: false required: - - client_id + - company_id - estimated_materials - estimated_time - name @@ -13872,10 +13872,10 @@ components: name: type: string maxLength: 100 - client_name: + company_name: type: string readOnly: true - default: No Client + default: No Company status: $ref: '#/components/schemas/JobStatusEnum' is_stock_holding: @@ -13885,7 +13885,7 @@ components: type: string readOnly: true required: - - client_name + - company_name - id - is_stock_holding - job_display_name @@ -13899,12 +13899,12 @@ components: job_id: type: string format: uuid - client_id: + company_id: type: string format: uuid readOnly: true nullable: true - client_name: + company_name: type: string readOnly: true nullable: true @@ -14022,8 +14022,8 @@ components: type: boolean description: Whether this job requires urgent rates and priority handling required: - - client_id - - client_name + - company_id + - company_name - contact_id - contact_name - default_xero_pay_item_id @@ -14131,9 +14131,9 @@ components: job_name: type: string description: Job description - client_name: + company_name: type: string - description: Client name + description: Company name pricing_type: type: string description: Pricing methodology key @@ -14168,7 +14168,7 @@ components: description: Variance as % of baseline required: - actual - - client_name + - company_name - completion_date - estimate - job_id @@ -14502,11 +14502,11 @@ components: name: type: string maxLength: 100 - client_id: + company_id: type: string format: uuid nullable: true - client_name: + company_name: type: string readOnly: true nullable: true @@ -14632,7 +14632,7 @@ components: readOnly: true shop_job: type: boolean - description: Indicates if this is a shop job (belongs to shop client). + description: Indicates if this is a shop job (belongs to shop company). readOnly: true rejected_flag: type: boolean @@ -14674,7 +14674,7 @@ components: type: boolean description: Whether this job requires urgent rates and priority handling required: - - client_name + - company_name - contact_name - created_at - default_xero_pay_item_name @@ -14882,7 +14882,7 @@ components: type: string job_name: type: string - client_name: + company_name: type: string billable_hours: type: number @@ -14908,7 +14908,7 @@ components: required: - adjustment_profit - billable_hours - - client_name + - company_name - cost - job_id - job_name @@ -15115,7 +15115,7 @@ components: description: type: string nullable: true - client_name: + company_name: type: string contact_person: type: string @@ -15173,7 +15173,7 @@ components: required: - badge_color - badge_label - - client_name + - company_name - contact_person - created_at - created_by_id @@ -15225,7 +15225,7 @@ components: nullable: true job_number: type: integer - client_name: + company_name: type: string contact_person: type: string @@ -15278,7 +15278,7 @@ components: max_people: type: integer required: - - client_name + - company_name - contact_person - created_at - created_by_id @@ -15686,7 +15686,7 @@ components: name: type: string maxLength: 100 - client_name: + company_name: type: string readOnly: true nullable: true @@ -15723,7 +15723,7 @@ components: type: boolean readOnly: true required: - - client_name + - company_name - default_xero_pay_item_id - default_xero_pay_item_name - estimated_hours @@ -15822,7 +15822,7 @@ components: type: integer job_name: type: string - client_name: + company_name: type: string history: type: array @@ -15835,7 +15835,7 @@ components: type: number format: double required: - - client_name + - company_name - history - job_id - job_name @@ -16128,9 +16128,9 @@ components: update. PatchedClientContactMethodRequest: type: object - description: Serializer for canonical client/contact phone and email methods. + description: Serializer for canonical company/contact phone and email methods. properties: - client: + company: type: string format: uuid nullable: true @@ -16156,10 +16156,10 @@ components: type: object description: Serializer for ClientContact model. properties: - client: + company: type: string format: uuid - description: The client this contact belongs to + description: The company this contact belongs to name: type: string minLength: 1 @@ -16178,28 +16178,11 @@ components: maxLength: 255 is_primary: type: boolean - description: Indicates if this is the primary contact for the client + description: Indicates if this is the primary contact for the company notes: type: string nullable: true description: Additional notes about this contact - PatchedClientUpdateRequest: - type: object - description: Serializer for client update request - properties: - name: - type: string - minLength: 1 - maxLength: 255 - email: - type: string - format: email - address: - type: string - is_account_customer: - type: boolean - allow_jobs: - type: boolean PatchedCompanyDefaultsRequest: type: object properties: @@ -16457,11 +16440,11 @@ components: nullable: true description: Company website URL maxLength: 200 - test_client_name: + test_company_name: type: string nullable: true - description: Name of the test client used for testing (e.g., 'ABC Carpet - Cleaning TEST IGNORE'). This client's name is preserved during data backports. + description: Name of the test company used for testing (e.g., 'ABC Carpet + Cleaning TEST IGNORE'). This company's name is preserved during data backports. maxLength: 255 kpi_daily_billable_hours_green: type: number @@ -16538,10 +16521,27 @@ components: exclusiveMinimum: true description: Target daily hours of newly-approved work flowing into the shop, used by the Sales Pipeline scoreboard - shop_client: + shop_company: type: string format: uuid - description: Internal client used for tracking shop work. + description: Internal company used for tracking shop work. + PatchedCompanyUpdateRequest: + type: object + description: Serializer for company update request + properties: + name: + type: string + minLength: 1 + maxLength: 255 + email: + type: string + format: email + address: + type: string + is_account_customer: + type: boolean + allow_jobs: + type: boolean PatchedCostLineCreateUpdateRequest: type: object description: Serializer for CostLine creation and updates - full write capabilities @@ -17038,7 +17038,7 @@ components: type: object description: Serializer for SupplierPickupAddress model (delivery/pickup locations). properties: - client: + company: type: string format: uuid description: The supplier this pickup address belongs to @@ -17159,7 +17159,7 @@ components: List / detail / PATCH serializer for XeroApp. client_secret and webhook_key are write-only — never returned. The - webhook signing key is comparable in sensitivity to the client secret + webhook signing key is comparable in sensitivity to the company secret (anyone holding it can forge webhook deliveries that we'd verify as authentic), so it gets the same treatment. access_token / refresh_token are not surfaced at all; instead a derived has_tokens @@ -17606,12 +17606,12 @@ components: exclusiveMinimum: true readOnly: true nullable: true - client: + company: type: string format: uuid readOnly: true nullable: true - client_name: + company_name: type: string readOnly: true contact: @@ -17667,8 +17667,8 @@ components: - call_time - call_type - charge - - client - - client_name + - company + - company_name - contact - contact_name - description @@ -17846,9 +17846,9 @@ components: - number PhoneNumberAssignmentRequest: type: object - description: Request body for assigning a call's external number to a client. + description: Request body for assigning a call's external number to a company. properties: - client: + company: type: string format: uuid contact: @@ -17863,7 +17863,7 @@ components: default: '' maxLength: 255 required: - - client + - company PhoneProviderSettings: type: object properties: @@ -18617,10 +18617,10 @@ components: type: string name: type: string - client: + company: type: string required: - - client + - company - job_number - name PurchaseOrderLastNumberResponse: @@ -18713,7 +18713,7 @@ components: type: integer readOnly: true nullable: true - client_name: + company_name: type: string readOnly: true nullable: true @@ -18723,10 +18723,10 @@ components: nullable: true times_used: type: integer - description: Always emit a numeric usage count for generated client validation. + description: Always emit a numeric usage count for generated company validation. readOnly: true required: - - client_name + - company_name - description - id - job_id @@ -19212,7 +19212,7 @@ components: type: integer job_name: type: string - client_name: + company_name: type: string rdti_type: type: string @@ -19226,7 +19226,7 @@ components: type: number format: double required: - - client_name + - company_name - cost - hours - job_id @@ -19568,7 +19568,7 @@ components: type: integer name: type: string - client_name: + company_name: type: string hours: type: number @@ -19579,7 +19579,7 @@ components: days_in_stage: type: integer required: - - client_name + - company_name - days_in_stage - hours - id @@ -19733,7 +19733,7 @@ components: type: integer name: type: string - client_name: + company_name: type: string remaining_hours: type: number @@ -19769,7 +19769,7 @@ components: - anticipated_staff - anticipated_start_date - assigned_staff - - client_name + - company_name - delivery_date - id - is_late @@ -20646,7 +20646,7 @@ components: type: integer job_name: type: string - client_name: + company_name: type: string billable_hours: type: number @@ -20671,7 +20671,7 @@ components: format: double required: - billable_hours - - client_name + - company_name - cost - job_id - job_name @@ -21111,7 +21111,7 @@ components: nullable: true times_used: type: integer - description: Always emit a numeric usage count for generated client validation. + description: Always emit a numeric usage count for generated company validation. readOnly: true required: - description @@ -21275,7 +21275,7 @@ components: type: string format: uuid readOnly: true - client: + company: type: string format: uuid description: The supplier this pickup address belongs to @@ -21359,7 +21359,7 @@ components: readOnly: true required: - city - - client + - company - created_at - formatted_address - id @@ -21371,7 +21371,7 @@ components: type: object description: Serializer for SupplierPickupAddress model (delivery/pickup locations). properties: - client: + company: type: string format: uuid description: The supplier this pickup address belongs to @@ -21443,7 +21443,7 @@ components: description: Additional notes (e.g., opening hours, access instructions) required: - city - - client + - company - name - street SupplierPriceStatusItem: @@ -21490,13 +21490,13 @@ components: - total_count SupplierSearchAlias: type: object - description: Supplier search alias attached to a client/contact. + description: Supplier search alias attached to a company/contact. properties: id: type: string format: uuid readOnly: true - client: + company: type: string format: uuid readOnly: true @@ -21516,7 +21516,7 @@ components: readOnly: true required: - alias - - client + - company - created_at - id - is_active @@ -21714,7 +21714,7 @@ components: Architecture principle: Job data comes from CostSet->Job relationship, NOT from metadata. This ensures data consistency and follows SRP: - Metadata = timesheet-specific data (staff, date, billable, etc.) - - Relationship = job data (job_id, job_number, job_name, client) + - Relationship = job data (job_id, job_number, job_name, company) Benefits: - No data duplication @@ -21843,9 +21843,9 @@ components: job_name: type: string readOnly: true - client_name: + company_name: type: string - description: Get client name with safe null handling + description: Get company name with safe null handling readOnly: true charge_out_rate: type: number @@ -21867,7 +21867,7 @@ components: - accounting_date - approved - charge_out_rate - - client_name + - company_name - created_at - desc - entry_seq @@ -21957,7 +21957,7 @@ components: type: integer name: type: string - client_name: + company_name: type: string delivery_date: type: string @@ -21969,7 +21969,7 @@ components: reason: type: string required: - - client_name + - company_name - delivery_date - id - job_number @@ -22044,7 +22044,7 @@ components: type: integer name: type: string - client: + company: type: string status: type: string @@ -22084,7 +22084,7 @@ components: required: - adjust_cost - adjust_rev - - client + - company - gross_wip - invoiced - job_number @@ -22172,7 +22172,7 @@ components: type: integer name: type: string - client: + company: type: string nullable: true description: @@ -22530,7 +22530,7 @@ components: nullable: true job_number: type: integer - client_name: + company_name: type: string contact_person: type: string @@ -22540,7 +22540,7 @@ components: items: $ref: '#/components/schemas/KanbanJobPerson' required: - - client_name + - company_name - contact_person - description - id @@ -22594,7 +22594,7 @@ components: job_name: type: string readOnly: true - client_name: + company_name: type: string readOnly: true description: @@ -22652,7 +22652,7 @@ components: required: - accounting_date - bill_rate_multiplier - - client_name + - company_name - created_at - description - end_time @@ -22761,7 +22761,7 @@ components: List / detail / PATCH serializer for XeroApp. client_secret and webhook_key are write-only — never returned. The - webhook signing key is comparable in sensitivity to the client secret + webhook signing key is comparable in sensitivity to the company secret (anyone holding it can forge webhook deliveries that we'd verify as authentic), so it gets the same treatment. access_token / refresh_token are not surfaced at all; instead a derived has_tokens @@ -22961,7 +22961,7 @@ components: List / detail / PATCH serializer for XeroApp. client_secret and webhook_key are write-only — never returned. The - webhook signing key is comparable in sensitivity to the client secret + webhook signing key is comparable in sensitivity to the company secret (anyone holding it can forge webhook deliveries that we'd verify as authentic), so it gets the same treatment. access_token / refresh_token are not surfaced at all; instead a derived has_tokens @@ -23038,9 +23038,9 @@ components: items: type: string description: Informational messages. - client: + company: type: string - description: Name of the client. + description: Name of the company. total_excl_tax: type: number format: double diff --git a/frontend/src/api/generated/api.ts b/frontend/src/api/generated/api.ts index 377279b14..1ec27738e 100644 --- a/frontend/src/api/generated/api.ts +++ b/frontend/src/api/generated/api.ts @@ -10,7 +10,7 @@ const KPIJobBreakdown = z.object({ job_id: z.string(), job_number: z.string(), job_name: z.string(), - client_name: z.string(), + company_name: z.string(), billable_hours: z.number(), revenue: z.number(), cost: z.number(), @@ -121,7 +121,7 @@ const JobAgingJobData = z.object({ id: z.string(), job_number: z.number().int(), name: z.string(), - client_name: z.string(), + company_name: z.string(), status: z.string(), status_display: z.string(), financial_data: JobAgingFinancialData, @@ -209,7 +209,7 @@ const RDTISpendJobDetail = z.object({ job_id: z.string(), job_number: z.number().int(), job_name: z.string(), - client_name: z.string(), + company_name: z.string(), rdti_type: z.string(), hours: z.number(), cost: z.number(), @@ -270,7 +270,7 @@ const SalesPipelineSnapshotJob = z.object({ id: z.string(), job_number: z.number().int(), name: z.string(), - client_name: z.string(), + company_name: z.string(), hours: z.number(), value: z.number(), days_in_stage: z.number().int(), @@ -360,7 +360,7 @@ const StaffPerformanceJobBreakdown = z.object({ job_id: z.string(), job_number: z.number().int(), job_name: z.string(), - client_name: z.string(), + company_name: z.string(), billable_hours: z.number(), non_billable_hours: z.number(), total_hours: z.number(), @@ -397,7 +397,7 @@ const StaffPerformanceResponse = z.object({ const WIPJob = z.object({ job_number: z.number().int(), name: z.string(), - client: z.string(), + company: z.string(), status: z.string(), time_cost: z.number(), time_rev: z.number(), @@ -623,7 +623,7 @@ const GroupedErrorResolveRequestRequest = z.object({ }) const GroupedErrorResolveResponse = z.object({ updated: z.number().int() }) const BuildId = z.object({ build_id: z.string() }) -const ClientDetailResponse = z.object({ +const CompanyDetailResponse = z.object({ id: z.string(), name: z.string(), email: z.string(), @@ -646,16 +646,16 @@ const ClientDetailResponse = z.object({ last_invoice_date: z.string().datetime({ offset: true }).nullable(), total_spend: z.string(), }) -const ClientErrorResponse = z.object({ +const CompanyErrorResponse = z.object({ success: z.boolean().optional().default(false), error: z.string(), details: z.string().optional(), }) -const ClientJobHeader = z.object({ +const CompanyJobHeader = z.object({ job_id: z.string().uuid(), job_number: z.number().int(), name: z.string(), - client: z.object({}).partial().passthrough().nullable(), + company: z.object({}).partial().passthrough().nullable(), status: z.string(), pricing_methodology: z.string().nullable(), speed_quality_tradeoff: z.string(), @@ -668,10 +668,10 @@ const ClientJobHeader = z.object({ min_people: z.number().int(), max_people: z.number().int(), }) -const ClientJobsResponse = z.object({ results: z.array(ClientJobHeader) }) +const CompanyJobsResponse = z.object({ results: z.array(CompanyJobHeader) }) const SupplierSearchAlias = z.object({ id: z.string().uuid(), - client: z.string().uuid(), + company: z.string().uuid(), alias: z.string().max(255), is_active: z.boolean(), created_at: z.string().datetime({ offset: true }), @@ -680,7 +680,7 @@ const SupplierSearchAlias = z.object({ const SupplierSearchAliasCreateRequest = z.object({ alias: z.string().min(1).max(255), }) -const ClientUpdateRequest = z +const CompanyUpdateRequest = z .object({ name: z.string().min(1).max(255), email: z.string().email(), @@ -689,12 +689,12 @@ const ClientUpdateRequest = z allow_jobs: z.boolean(), }) .partial() -const ClientUpdateResponse = z.object({ +const CompanyUpdateResponse = z.object({ success: z.boolean(), - client: ClientDetailResponse, + company: CompanyDetailResponse, message: z.string(), }) -const PatchedClientUpdateRequest = z +const PatchedCompanyUpdateRequest = z .object({ name: z.string().min(1).max(255), email: z.string().email(), @@ -703,14 +703,14 @@ const PatchedClientUpdateRequest = z allow_jobs: z.boolean(), }) .partial() -const ClientNameOnly = z.object({ id: z.string().uuid(), name: z.string() }) +const CompanyNameOnly = z.object({ id: z.string().uuid(), name: z.string() }) const MethodTypeEnum = z.enum(['phone', 'email']) const ClientContactMethodSourceEnum = z.enum(['imported', 'local']) const ClientContactMethod = z.object({ id: z.string().uuid(), - client: z.string().uuid().nullish(), - owner_client: z.string(), - client_name: z.string(), + company: z.string().uuid().nullish(), + owner_company: z.string(), + company_name: z.string(), contact: z.string().uuid().nullish(), contact_name: z.string(), method_type: MethodTypeEnum, @@ -730,7 +730,7 @@ const PaginatedClientContactMethodList = z.object({ total_pages: z.number().int(), }) const ClientContactMethodRequest = z.object({ - client: z.string().uuid().nullish(), + company: z.string().uuid().nullish(), contact: z.string().uuid().nullish(), method_type: MethodTypeEnum, value: z.string().min(1).max(255), @@ -740,7 +740,7 @@ const ClientContactMethodRequest = z.object({ }) const PatchedClientContactMethodRequest = z .object({ - client: z.string().uuid().nullable(), + company: z.string().uuid().nullable(), contact: z.string().uuid().nullable(), method_type: MethodTypeEnum, value: z.string().min(1).max(255), @@ -751,7 +751,7 @@ const PatchedClientContactMethodRequest = z .partial() const ClientContact = z.object({ id: z.string().uuid(), - client: z.string().uuid(), + company: z.string().uuid(), name: z.string().max(255), email: z.string().max(254).email().nullish(), position: z.string().max(255).nullish(), @@ -762,7 +762,7 @@ const ClientContact = z.object({ updated_at: z.string().datetime({ offset: true }), }) const ClientContactRequest = z.object({ - client: z.string().uuid(), + company: z.string().uuid(), name: z.string().min(1).max(255), email: z.string().max(254).email().nullish(), position: z.string().max(255).nullish(), @@ -771,7 +771,7 @@ const ClientContactRequest = z.object({ }) const PatchedClientContactRequest = z .object({ - client: z.string().uuid(), + company: z.string().uuid(), name: z.string().min(1).max(255), email: z.string().max(254).email().nullable(), position: z.string().max(255).nullable(), @@ -779,14 +779,14 @@ const PatchedClientContactRequest = z notes: z.string().nullable(), }) .partial() -const ClientCreateRequest = z.object({ +const CompanyCreateRequest = z.object({ name: z.string().min(1).max(255), email: z.string().email().nullish(), address: z.string().nullish(), is_account_customer: z.boolean().optional().default(true), allow_jobs: z.boolean().optional().default(true), }) -const ClientSearchResult = z.object({ +const CompanySearchResult = z.object({ id: z.string(), name: z.string(), email: z.string(), @@ -798,15 +798,15 @@ const ClientSearchResult = z.object({ last_invoice_date: z.string().datetime({ offset: true }).nullable(), total_spend: z.string(), }) -const ClientCreateResponse = z.object({ +const CompanyCreateResponse = z.object({ success: z.boolean(), - client: ClientSearchResult, + company: CompanySearchResult, message: z.string(), }) -const ClientDuplicateErrorResponse = z.object({ +const CompanyDuplicateErrorResponse = z.object({ success: z.boolean().optional().default(false), error: z.string(), - existing_client: z.object({}).partial().passthrough(), + existing_company: z.object({}).partial().passthrough(), }) const JobContactResponse = z.object({ id: z.string().uuid(), @@ -826,7 +826,7 @@ const JobContactUpdateRequest = z.object({ }) const SupplierPickupAddress = z.object({ id: z.string().uuid(), - client: z.string().uuid(), + company: z.string().uuid(), name: z.string().max(255), street: z.string().max(255), suburb: z.string().max(100).nullish(), @@ -845,7 +845,7 @@ const SupplierPickupAddress = z.object({ formatted_address: z.string(), }) const SupplierPickupAddressRequest = z.object({ - client: z.string().uuid(), + company: z.string().uuid(), name: z.string().min(1).max(255), street: z.string().min(1).max(255), suburb: z.string().max(100).nullish(), @@ -861,7 +861,7 @@ const SupplierPickupAddressRequest = z.object({ }) const PatchedSupplierPickupAddressRequest = z .object({ - client: z.string().uuid(), + company: z.string().uuid(), name: z.string().min(1).max(255), street: z.string().min(1).max(255), suburb: z.string().max(100).nullable(), @@ -876,8 +876,8 @@ const PatchedSupplierPickupAddressRequest = z notes: z.string().nullable(), }) .partial() -const ClientSearchResponse = z.object({ - results: z.array(ClientSearchResult), +const CompanySearchResponse = z.object({ + results: z.array(CompanySearchResult), count: z.number().int(), page: z.number().int(), page_size: z.number().int(), @@ -938,7 +938,7 @@ const CompanyDefaults = z.object({ country: z.string().max(100).optional(), company_email: z.string().max(254).email().nullish(), company_url: z.string().max(200).url().nullish(), - test_client_name: z.string().max(255).nullish(), + test_company_name: z.string().max(255).nullish(), kpi_daily_billable_hours_green: z.number().gt(-1000).lt(1000).optional(), kpi_daily_billable_hours_amber: z.number().gt(-1000).lt(1000).optional(), kpi_daily_gp_target: z.number().gt(-100000000).lt(100000000).optional(), @@ -947,7 +947,7 @@ const CompanyDefaults = z.object({ kpi_daily_gp_green: z.number().gt(-100000000).lt(100000000).optional(), kpi_daily_gp_amber: z.number().gt(-100000000).lt(100000000).optional(), daily_approved_hours_target: z.number().gt(-1000).lt(1000).optional(), - shop_client: z.string().uuid(), + shop_company: z.string().uuid(), }) const CompanyDefaultsRequest = z.object({ logo: z.instanceof(File).nullish(), @@ -1000,7 +1000,7 @@ const CompanyDefaultsRequest = z.object({ country: z.string().min(1).max(100).optional(), company_email: z.string().max(254).email().nullish(), company_url: z.string().max(200).url().nullish(), - test_client_name: z.string().max(255).nullish(), + test_company_name: z.string().max(255).nullish(), kpi_daily_billable_hours_green: z.number().gt(-1000).lt(1000).optional(), kpi_daily_billable_hours_amber: z.number().gt(-1000).lt(1000).optional(), kpi_daily_gp_target: z.number().gt(-100000000).lt(100000000).optional(), @@ -1009,7 +1009,7 @@ const CompanyDefaultsRequest = z.object({ kpi_daily_gp_green: z.number().gt(-100000000).lt(100000000).optional(), kpi_daily_gp_amber: z.number().gt(-100000000).lt(100000000).optional(), daily_approved_hours_target: z.number().gt(-1000).lt(1000).optional(), - shop_client: z.string().uuid(), + shop_company: z.string().uuid(), }) const PatchedCompanyDefaultsRequest = z .object({ @@ -1063,7 +1063,7 @@ const PatchedCompanyDefaultsRequest = z country: z.string().min(1).max(100), company_email: z.string().max(254).email().nullable(), company_url: z.string().max(200).url().nullable(), - test_client_name: z.string().max(255).nullable(), + test_company_name: z.string().max(255).nullable(), kpi_daily_billable_hours_green: z.number().gt(-1000).lt(1000), kpi_daily_billable_hours_amber: z.number().gt(-1000).lt(1000), kpi_daily_gp_target: z.number().gt(-100000000).lt(100000000), @@ -1072,7 +1072,7 @@ const PatchedCompanyDefaultsRequest = z kpi_daily_gp_green: z.number().gt(-100000000).lt(100000000), kpi_daily_gp_amber: z.number().gt(-100000000).lt(100000000), daily_approved_hours_target: z.number().gt(-1000).lt(1000), - shop_client: z.string().uuid(), + shop_company: z.string().uuid(), }) .partial() const SettingsField = z.object({ @@ -1130,8 +1130,8 @@ const PhoneCallRecord = z.object({ destination_endpoint_label: z.string(), duration_seconds: z.number().int(), charge: z.number().gt(-100000000).lt(100000000).nullable(), - client: z.string().uuid().nullable(), - client_name: z.string(), + company: z.string().uuid().nullable(), + company_name: z.string(), contact: z.string().uuid().nullable(), contact_name: z.string(), job: z.string().uuid().nullable(), @@ -1152,7 +1152,7 @@ const PaginatedPhoneCallRecordList = z.object({ total_pages: z.number().int(), }) const PhoneNumberAssignmentRequest = z.object({ - client: z.string().uuid(), + company: z.string().uuid(), contact: z.string().uuid().nullish(), is_primary: z.boolean().optional().default(false), label: z.string().max(255).optional().default(''), @@ -1319,7 +1319,7 @@ const DataIntegrityResponse = z.object({ const ArchivedJobIssue = z.object({ job_id: z.string(), job_number: z.string(), - client_name: z.string(), + company_name: z.string(), archived_date: z.string(), current_status: z.string(), issue: z.string(), @@ -1370,7 +1370,7 @@ const CompleteJob = z.object({ id: z.string().uuid(), job_number: z.number().int().gte(-2147483648).lte(2147483647), name: z.string().max(100), - client_name: z.string(), + company_name: z.string(), updated_at: z.string().datetime({ offset: true }), job_status: z.string(), }) @@ -1389,7 +1389,7 @@ const ArchiveJobsResponse = z.object({ }) const JobCreateRequest = z.object({ name: z.string().min(1).max(255), - client_id: z.string().uuid(), + company_id: z.string().uuid(), description: z.string().optional(), order_number: z.string().optional(), notes: z.string().optional(), @@ -1486,8 +1486,8 @@ const NullEnum = z.unknown() const Job = z.object({ id: z.string().uuid(), name: z.string().max(100), - client_id: z.string().uuid().nullish(), - client_name: z.string().nullable(), + company_id: z.string().uuid().nullish(), + company_name: z.string().nullable(), contact_id: z.string().uuid().nullish(), contact_name: z.string().nullable(), job_number: z.number().int().gte(-2147483648).lte(2147483647), @@ -1699,8 +1699,8 @@ const JobStatusEnum = z.enum([ ]) const JobHeaderResponse = z.object({ job_id: z.string().uuid(), - client_id: z.string().uuid().nullable(), - client_name: z.string().nullable(), + company_id: z.string().uuid().nullable(), + company_name: z.string().nullable(), contact_id: z.string().uuid().nullable(), contact_name: z.string().nullable(), quoted: z.boolean(), @@ -1825,8 +1825,8 @@ const CostSetSummaryOnly = z.object({ const JobSummary = z.object({ id: z.string().uuid(), name: z.string().max(100), - client_id: z.string().uuid().nullish(), - client_name: z.string().nullable(), + company_id: z.string().uuid().nullish(), + company_name: z.string().nullable(), contact_id: z.string().uuid().nullish(), contact_name: z.string().nullable(), job_number: z.number().int().gte(-2147483648).lte(2147483647), @@ -1967,7 +1967,7 @@ const KanbanJob = z.object({ name: z.string(), description: z.string().nullable(), job_number: z.number().int(), - client_name: z.string(), + company_name: z.string(), contact_person: z.string(), people: z.array(KanbanJobPerson), status: z.string(), @@ -2035,7 +2035,7 @@ const KanbanColumnJob = z.object({ job_number: z.number().int(), name: z.string(), description: z.string().nullable(), - client_name: z.string(), + company_name: z.string(), contact_person: z.string(), people: z.array(KanbanJobPerson), status: z.string(), @@ -2093,7 +2093,7 @@ const WeeklyMetrics = z.object({ job_id: z.string().uuid(), job_number: z.number().int(), name: z.string(), - client: z.string().nullish(), + company: z.string().nullish(), description: z.string().nullish(), status: z.string(), people: z.array(z.object({}).partial().passthrough()), @@ -2106,7 +2106,7 @@ const WorkshopJob = z.object({ name: z.string(), description: z.string().nullable(), job_number: z.number().int(), - client_name: z.string(), + company_name: z.string(), contact_person: z.string().nullable(), people: z.array(KanbanJobPerson), }) @@ -2154,7 +2154,7 @@ const MonthEndJob = z.object({ job_id: z.string().uuid(), job_number: z.number().int(), job_name: z.string(), - client_name: z.string(), + company_name: z.string(), history: z.array(MonthEndJobHistory), total_hours: z.number(), total_dollars: z.number(), @@ -2190,7 +2190,7 @@ const JobProfitabilityItem = z.object({ job_id: z.string(), job_number: z.number().int(), job_name: z.string(), - client_name: z.string(), + company_name: z.string(), pricing_type: z.string(), pricing_type_display: z.string(), completion_date: z.string().nullable(), @@ -2253,7 +2253,7 @@ const TimesheetCostLine = z.object({ job_id: z.string(), job_number: z.number().int(), job_name: z.string(), - client_name: z.string(), + company_name: z.string(), charge_out_rate: z.number(), wage_rate: z.number(), xero_pay_item_name: z.string().min(1), @@ -2312,7 +2312,7 @@ const WorkshopTimesheetEntry = z.object({ job_id: z.string().uuid(), job_number: z.number().int(), job_name: z.string(), - client_name: z.string(), + company_name: z.string(), description: z.string(), hours: z.number().gt(-100000).lt(100000), accounting_date: z.string(), @@ -2372,7 +2372,7 @@ const ScheduledJob = z.object({ id: z.string().uuid(), job_number: z.number().int(), name: z.string(), - client_name: z.string(), + company_name: z.string(), remaining_hours: z.number(), delivery_date: z.string().nullable(), anticipated_start_date: z.string().nullable(), @@ -2387,7 +2387,7 @@ const UnscheduledJob = z.object({ id: z.string().uuid(), job_number: z.number().int(), name: z.string(), - client_name: z.string(), + company_name: z.string(), delivery_date: z.string().nullable(), remaining_hours: z.number(), reason: z.string(), @@ -2600,7 +2600,7 @@ const JobForPurchasing = z.object({ id: z.string().uuid(), job_number: z.number().int().gte(-2147483648).lte(2147483647), name: z.string().max(100), - client_name: z.string().default('No Client'), + company_name: z.string().default('No Company'), status: JobStatusEnum.optional(), is_stock_holding: z.boolean(), job_display_name: z.string(), @@ -2680,7 +2680,7 @@ const ProductMappingValidateResponse = z.object({ const PurchaseOrderJob = z.object({ job_number: z.string(), name: z.string(), - client: z.string(), + company: z.string(), }) const PurchaseOrderList = z.object({ id: z.string().uuid(), @@ -2761,7 +2761,7 @@ const PurchaseOrderLine = z.object({ location: z.string().max(255).nullish(), job_id: z.string().uuid().nullable(), job_number: z.number().int().nullable(), - client_name: z.string().nullable(), + company_name: z.string().nullable(), job_name: z.string().nullable(), times_used: z.number().int(), }) @@ -3135,7 +3135,7 @@ const JobBreakdown = z.object({ job_id: z.string(), job_number: z.number().int(), job_name: z.string(), - client: z.string(), + company: z.string(), hours: z.number(), revenue: z.number(), cost: z.number(), @@ -3194,7 +3194,7 @@ const ModernTimesheetJob = z.object({ id: z.string().uuid(), job_number: z.number().int().gte(-2147483648).lte(2147483647), name: z.string().max(100), - client_name: z.string().nullable(), + company_name: z.string().nullable(), status: JobStatusEnum.optional(), labour_rates: z.array(JobLabourRate), has_actual_costset: z.boolean(), @@ -3479,7 +3479,7 @@ const XeroDocumentSuccessResponse = z.object({ xero_id: z.string().uuid(), online_url: z.string().url().optional(), messages: z.array(z.string()).optional(), - client: z.string().optional(), + company: z.string().optional(), total_excl_tax: z.number().gt(-10000000000).lt(10000000000).optional(), total_incl_tax: z.number().gt(-10000000000).lt(10000000000).optional(), action: z.string().optional(), @@ -3581,16 +3581,16 @@ export const schemas = { GroupedErrorResolveRequestRequest, GroupedErrorResolveResponse, BuildId, - ClientDetailResponse, - ClientErrorResponse, - ClientJobHeader, - ClientJobsResponse, + CompanyDetailResponse, + CompanyErrorResponse, + CompanyJobHeader, + CompanyJobsResponse, SupplierSearchAlias, SupplierSearchAliasCreateRequest, - ClientUpdateRequest, - ClientUpdateResponse, - PatchedClientUpdateRequest, - ClientNameOnly, + CompanyUpdateRequest, + CompanyUpdateResponse, + PatchedCompanyUpdateRequest, + CompanyNameOnly, MethodTypeEnum, ClientContactMethodSourceEnum, ClientContactMethod, @@ -3600,16 +3600,16 @@ export const schemas = { ClientContact, ClientContactRequest, PatchedClientContactRequest, - ClientCreateRequest, - ClientSearchResult, - ClientCreateResponse, - ClientDuplicateErrorResponse, + CompanyCreateRequest, + CompanySearchResult, + CompanyCreateResponse, + CompanyDuplicateErrorResponse, JobContactResponse, JobContactUpdateRequest, SupplierPickupAddress, SupplierPickupAddressRequest, PatchedSupplierPickupAddressRequest, - ClientSearchResponse, + CompanySearchResponse, CompanyDefaults, CompanyDefaultsRequest, PatchedCompanyDefaultsRequest, @@ -4223,7 +4223,7 @@ Returns: z .object({ date: z.string(), - client_name: z.string(), + company_name: z.string(), job_number: z.number().int().nullable(), job_name: z.string().nullable(), invoice_numbers: z.string().nullable(), @@ -4768,63 +4768,63 @@ Endpoint: /api/app-errors/<id>/`, }, { method: 'get', - path: '/api/clients/:client_id/', - alias: 'clients_retrieve', - description: `Retrieve detailed information for a specific client.`, + path: '/api/companies/:company_id/', + alias: 'companies_retrieve', + description: `Retrieve detailed information for a specific company.`, requestFormat: 'json', parameters: [ { - name: 'client_id', + name: 'company_id', type: 'Path', schema: z.string().uuid(), }, ], - response: ClientDetailResponse, + response: CompanyDetailResponse, errors: [ { status: 404, - schema: ClientErrorResponse, + schema: CompanyErrorResponse, }, { status: 500, - schema: ClientErrorResponse, + schema: CompanyErrorResponse, }, ], }, { method: 'get', - path: '/api/clients/:client_id/jobs/', - alias: 'clients_jobs_retrieve', - description: `Retrieve all jobs for a specific client.`, + path: '/api/companies/:company_id/jobs/', + alias: 'companies_jobs_retrieve', + description: `Retrieve all jobs for a specific company.`, requestFormat: 'json', parameters: [ { - name: 'client_id', + name: 'company_id', type: 'Path', schema: z.string().uuid(), }, ], - response: ClientJobsResponse, + response: CompanyJobsResponse, errors: [ { status: 404, - schema: ClientErrorResponse, + schema: CompanyErrorResponse, }, { status: 500, - schema: ClientErrorResponse, + schema: CompanyErrorResponse, }, ], }, { method: 'get', - path: '/api/clients/:client_id/supplier-aliases/', - alias: 'clients_supplier_aliases_list', - description: `List and create search aliases for a client/supplier contact.`, + path: '/api/companies/:company_id/supplier-aliases/', + alias: 'companies_supplier_aliases_list', + description: `List and create search aliases for a company/supplier contact.`, requestFormat: 'json', parameters: [ { - name: 'client_id', + name: 'company_id', type: 'Path', schema: z.string().uuid(), }, @@ -4833,9 +4833,9 @@ Endpoint: /api/app-errors/<id>/`, }, { method: 'post', - path: '/api/clients/:client_id/supplier-aliases/', - alias: 'clients_supplier_aliases_create', - description: `List and create search aliases for a client/supplier contact.`, + path: '/api/companies/:company_id/supplier-aliases/', + alias: 'companies_supplier_aliases_create', + description: `List and create search aliases for a company/supplier contact.`, requestFormat: 'json', parameters: [ { @@ -4844,7 +4844,7 @@ Endpoint: /api/app-errors/<id>/`, schema: z.object({ alias: z.string().min(1).max(255) }), }, { - name: 'client_id', + name: 'company_id', type: 'Path', schema: z.string().uuid(), }, @@ -4853,76 +4853,76 @@ Endpoint: /api/app-errors/<id>/`, }, { method: 'put', - path: '/api/clients/:client_id/update/', - alias: 'clients_update_update', - description: `Update an existing client's information.`, + path: '/api/companies/:company_id/update/', + alias: 'companies_update_update', + description: `Update an existing company's information.`, requestFormat: 'json', parameters: [ { name: 'body', type: 'Body', - schema: ClientUpdateRequest, + schema: CompanyUpdateRequest, }, { - name: 'client_id', + name: 'company_id', type: 'Path', schema: z.string().uuid(), }, ], - response: ClientUpdateResponse, + response: CompanyUpdateResponse, errors: [ { status: 400, - schema: ClientErrorResponse, + schema: CompanyErrorResponse, }, { status: 404, - schema: ClientErrorResponse, + schema: CompanyErrorResponse, }, { status: 500, - schema: ClientErrorResponse, + schema: CompanyErrorResponse, }, ], }, { method: 'patch', - path: '/api/clients/:client_id/update/', - alias: 'clients_update_partial_update', - description: `Partially update an existing client's information.`, + path: '/api/companies/:company_id/update/', + alias: 'companies_update_partial_update', + description: `Partially update an existing company's information.`, requestFormat: 'json', parameters: [ { name: 'body', type: 'Body', - schema: PatchedClientUpdateRequest, + schema: PatchedCompanyUpdateRequest, }, { - name: 'client_id', + name: 'company_id', type: 'Path', schema: z.string().uuid(), }, ], - response: ClientUpdateResponse, + response: CompanyUpdateResponse, errors: [ { status: 400, - schema: ClientErrorResponse, + schema: CompanyErrorResponse, }, { status: 404, - schema: ClientErrorResponse, + schema: CompanyErrorResponse, }, { status: 500, - schema: ClientErrorResponse, + schema: CompanyErrorResponse, }, ], }, { method: 'post', - path: '/api/clients/addresses/validate/', - alias: 'clients_addresses_validate_create', + path: '/api/companies/addresses/validate/', + alias: 'companies_addresses_validate_create', description: `Validate an address and return structured candidates.`, requestFormat: 'json', parameters: [ @@ -4965,27 +4965,27 @@ Endpoint: /api/app-errors/<id>/`, }, { method: 'get', - path: '/api/clients/all/', - alias: 'clients_all_list', - description: `Returns a list of all clients with basic information (id and name) for dropdowns and search.`, + path: '/api/companies/all/', + alias: 'companies_all_list', + description: `Returns a list of all companies with basic information (id and name) for dropdowns and search.`, requestFormat: 'json', - response: z.array(ClientNameOnly), + response: z.array(CompanyNameOnly), errors: [ { status: 500, - schema: ClientErrorResponse, + schema: CompanyErrorResponse, }, ], }, { method: 'get', - path: '/api/clients/contact-methods/', - alias: 'clients_contact_methods_list', - description: `CRUD API for canonical client/contact phone and email methods.`, + path: '/api/companies/contact-methods/', + alias: 'companies_contact_methods_list', + description: `CRUD API for canonical company/contact phone and email methods.`, requestFormat: 'json', parameters: [ { - name: 'client_id', + name: 'company_id', type: 'Query', schema: z.string().uuid().optional(), }, @@ -5014,9 +5014,9 @@ Endpoint: /api/app-errors/<id>/`, }, { method: 'post', - path: '/api/clients/contact-methods/', - alias: 'clients_contact_methods_create', - description: `CRUD API for canonical client/contact phone and email methods.`, + path: '/api/companies/contact-methods/', + alias: 'companies_contact_methods_create', + description: `CRUD API for canonical company/contact phone and email methods.`, requestFormat: 'json', parameters: [ { @@ -5029,9 +5029,9 @@ Endpoint: /api/app-errors/<id>/`, }, { method: 'get', - path: '/api/clients/contact-methods/:id/', - alias: 'clients_contact_methods_retrieve', - description: `CRUD API for canonical client/contact phone and email methods.`, + path: '/api/companies/contact-methods/:id/', + alias: 'companies_contact_methods_retrieve', + description: `CRUD API for canonical company/contact phone and email methods.`, requestFormat: 'json', parameters: [ { @@ -5044,9 +5044,9 @@ Endpoint: /api/app-errors/<id>/`, }, { method: 'put', - path: '/api/clients/contact-methods/:id/', - alias: 'clients_contact_methods_update', - description: `CRUD API for canonical client/contact phone and email methods.`, + path: '/api/companies/contact-methods/:id/', + alias: 'companies_contact_methods_update', + description: `CRUD API for canonical company/contact phone and email methods.`, requestFormat: 'json', parameters: [ { @@ -5064,9 +5064,9 @@ Endpoint: /api/app-errors/<id>/`, }, { method: 'patch', - path: '/api/clients/contact-methods/:id/', - alias: 'clients_contact_methods_partial_update', - description: `CRUD API for canonical client/contact phone and email methods.`, + path: '/api/companies/contact-methods/:id/', + alias: 'companies_contact_methods_partial_update', + description: `CRUD API for canonical company/contact phone and email methods.`, requestFormat: 'json', parameters: [ { @@ -5084,9 +5084,9 @@ Endpoint: /api/app-errors/<id>/`, }, { method: 'delete', - path: '/api/clients/contact-methods/:id/', - alias: 'clients_contact_methods_destroy', - description: `CRUD API for canonical client/contact phone and email methods.`, + path: '/api/companies/contact-methods/:id/', + alias: 'companies_contact_methods_destroy', + description: `CRUD API for canonical company/contact phone and email methods.`, requestFormat: 'json', parameters: [ { @@ -5099,13 +5099,13 @@ Endpoint: /api/app-errors/<id>/`, }, { method: 'get', - path: '/api/clients/contacts/', - alias: 'clients_contacts_list', - description: `List all contacts, optionally filtered by client_id.`, + path: '/api/companies/contacts/', + alias: 'companies_contacts_list', + description: `List all contacts, optionally filtered by company_id.`, requestFormat: 'json', parameters: [ { - name: 'client_id', + name: 'company_id', type: 'Query', schema: z.string().uuid().optional(), }, @@ -5114,20 +5114,20 @@ Endpoint: /api/app-errors/<id>/`, }, { method: 'post', - path: '/api/clients/contacts/', - alias: 'clients_contacts_create', + path: '/api/companies/contacts/', + alias: 'companies_contacts_create', description: `ViewSet for ClientContact CRUD operations. Endpoints: -- GET /api/clients/contacts/ - list all contacts -- POST /api/clients/contacts/ - create contact -- GET /api/clients/contacts/<id>/ - retrieve contact -- PUT /api/clients/contacts/<id>/ - full update -- PATCH /api/clients/contacts/<id>/ - partial update -- DELETE /api/clients/contacts/<id>/ - soft delete (sets is_active=False) +- GET /api/companies/contacts/ - list all contacts +- POST /api/companies/contacts/ - create contact +- GET /api/companies/contacts/<id>/ - retrieve contact +- PUT /api/companies/contacts/<id>/ - full update +- PATCH /api/companies/contacts/<id>/ - partial update +- DELETE /api/companies/contacts/<id>/ - soft delete (sets is_active=False) Query Parameters: -- client_id: Filter contacts by client UUID`, +- company_id: Filter contacts by company UUID`, requestFormat: 'json', parameters: [ { @@ -5140,20 +5140,20 @@ Query Parameters: }, { method: 'get', - path: '/api/clients/contacts/:id/', - alias: 'clients_contacts_retrieve', + path: '/api/companies/contacts/:id/', + alias: 'companies_contacts_retrieve', description: `ViewSet for ClientContact CRUD operations. Endpoints: -- GET /api/clients/contacts/ - list all contacts -- POST /api/clients/contacts/ - create contact -- GET /api/clients/contacts/<id>/ - retrieve contact -- PUT /api/clients/contacts/<id>/ - full update -- PATCH /api/clients/contacts/<id>/ - partial update -- DELETE /api/clients/contacts/<id>/ - soft delete (sets is_active=False) +- GET /api/companies/contacts/ - list all contacts +- POST /api/companies/contacts/ - create contact +- GET /api/companies/contacts/<id>/ - retrieve contact +- PUT /api/companies/contacts/<id>/ - full update +- PATCH /api/companies/contacts/<id>/ - partial update +- DELETE /api/companies/contacts/<id>/ - soft delete (sets is_active=False) Query Parameters: -- client_id: Filter contacts by client UUID`, +- company_id: Filter contacts by company UUID`, requestFormat: 'json', parameters: [ { @@ -5166,20 +5166,20 @@ Query Parameters: }, { method: 'put', - path: '/api/clients/contacts/:id/', - alias: 'clients_contacts_update', + path: '/api/companies/contacts/:id/', + alias: 'companies_contacts_update', description: `ViewSet for ClientContact CRUD operations. Endpoints: -- GET /api/clients/contacts/ - list all contacts -- POST /api/clients/contacts/ - create contact -- GET /api/clients/contacts/<id>/ - retrieve contact -- PUT /api/clients/contacts/<id>/ - full update -- PATCH /api/clients/contacts/<id>/ - partial update -- DELETE /api/clients/contacts/<id>/ - soft delete (sets is_active=False) +- GET /api/companies/contacts/ - list all contacts +- POST /api/companies/contacts/ - create contact +- GET /api/companies/contacts/<id>/ - retrieve contact +- PUT /api/companies/contacts/<id>/ - full update +- PATCH /api/companies/contacts/<id>/ - partial update +- DELETE /api/companies/contacts/<id>/ - soft delete (sets is_active=False) Query Parameters: -- client_id: Filter contacts by client UUID`, +- company_id: Filter contacts by company UUID`, requestFormat: 'json', parameters: [ { @@ -5197,20 +5197,20 @@ Query Parameters: }, { method: 'patch', - path: '/api/clients/contacts/:id/', - alias: 'clients_contacts_partial_update', + path: '/api/companies/contacts/:id/', + alias: 'companies_contacts_partial_update', description: `ViewSet for ClientContact CRUD operations. Endpoints: -- GET /api/clients/contacts/ - list all contacts -- POST /api/clients/contacts/ - create contact -- GET /api/clients/contacts/<id>/ - retrieve contact -- PUT /api/clients/contacts/<id>/ - full update -- PATCH /api/clients/contacts/<id>/ - partial update -- DELETE /api/clients/contacts/<id>/ - soft delete (sets is_active=False) +- GET /api/companies/contacts/ - list all contacts +- POST /api/companies/contacts/ - create contact +- GET /api/companies/contacts/<id>/ - retrieve contact +- PUT /api/companies/contacts/<id>/ - full update +- PATCH /api/companies/contacts/<id>/ - partial update +- DELETE /api/companies/contacts/<id>/ - soft delete (sets is_active=False) Query Parameters: -- client_id: Filter contacts by client UUID`, +- company_id: Filter contacts by company UUID`, requestFormat: 'json', parameters: [ { @@ -5228,20 +5228,20 @@ Query Parameters: }, { method: 'delete', - path: '/api/clients/contacts/:id/', - alias: 'clients_contacts_destroy', + path: '/api/companies/contacts/:id/', + alias: 'companies_contacts_destroy', description: `ViewSet for ClientContact CRUD operations. Endpoints: -- GET /api/clients/contacts/ - list all contacts -- POST /api/clients/contacts/ - create contact -- GET /api/clients/contacts/<id>/ - retrieve contact -- PUT /api/clients/contacts/<id>/ - full update -- PATCH /api/clients/contacts/<id>/ - partial update -- DELETE /api/clients/contacts/<id>/ - soft delete (sets is_active=False) +- GET /api/companies/contacts/ - list all contacts +- POST /api/companies/contacts/ - create contact +- GET /api/companies/contacts/<id>/ - retrieve contact +- PUT /api/companies/contacts/<id>/ - full update +- PATCH /api/companies/contacts/<id>/ - partial update +- DELETE /api/companies/contacts/<id>/ - soft delete (sets is_active=False) Query Parameters: -- client_id: Filter contacts by client UUID`, +- company_id: Filter contacts by company UUID`, requestFormat: 'json', parameters: [ { @@ -5254,41 +5254,41 @@ Query Parameters: }, { method: 'post', - path: '/api/clients/create/', - alias: 'clients_create_create', - description: `Creates a new client in Xero first, then syncs locally. Requires valid Xero authentication.`, + path: '/api/companies/create/', + alias: 'companies_create_create', + description: `Creates a new company in Xero first, then syncs locally. Requires valid Xero authentication.`, requestFormat: 'json', parameters: [ { name: 'body', type: 'Body', - schema: ClientCreateRequest, + schema: CompanyCreateRequest, }, ], - response: ClientCreateResponse, + response: CompanyCreateResponse, errors: [ { status: 400, - schema: ClientErrorResponse, + schema: CompanyErrorResponse, }, { status: 401, - schema: ClientErrorResponse, + schema: CompanyErrorResponse, }, { status: 409, - schema: ClientDuplicateErrorResponse, + schema: CompanyDuplicateErrorResponse, }, { status: 500, - schema: ClientErrorResponse, + schema: CompanyErrorResponse, }, ], }, { method: 'get', - path: '/api/clients/jobs/:job_id/contact/', - alias: 'clients_jobs_contact_retrieve', + path: '/api/companies/jobs/:job_id/contact/', + alias: 'companies_jobs_contact_retrieve', description: `Retrieve contact information for a specific job.`, requestFormat: 'json', parameters: [ @@ -5302,18 +5302,18 @@ Query Parameters: errors: [ { status: 404, - schema: ClientErrorResponse, + schema: CompanyErrorResponse, }, { status: 500, - schema: ClientErrorResponse, + schema: CompanyErrorResponse, }, ], }, { method: 'put', - path: '/api/clients/jobs/:job_id/contact/', - alias: 'clients_jobs_contact_update', + path: '/api/companies/jobs/:job_id/contact/', + alias: 'companies_jobs_contact_update', description: `Update the contact person associated with a specific job.`, requestFormat: 'json', parameters: [ @@ -5332,22 +5332,22 @@ Query Parameters: errors: [ { status: 400, - schema: ClientErrorResponse, + schema: CompanyErrorResponse, }, { status: 404, - schema: ClientErrorResponse, + schema: CompanyErrorResponse, }, { status: 500, - schema: ClientErrorResponse, + schema: CompanyErrorResponse, }, ], }, { method: 'get', - path: '/api/clients/pickup-addresses/', - alias: 'clients_pickup_addresses_list', + path: '/api/companies/pickup-addresses/', + alias: 'companies_pickup_addresses_list', description: `List all pickup addresses, optionally filtered by supplier_id.`, requestFormat: 'json', parameters: [ @@ -5361,20 +5361,20 @@ Query Parameters: }, { method: 'post', - path: '/api/clients/pickup-addresses/', - alias: 'clients_pickup_addresses_create', + path: '/api/companies/pickup-addresses/', + alias: 'companies_pickup_addresses_create', description: `ViewSet for SupplierPickupAddress CRUD operations. Endpoints: -- GET /api/clients/pickup-addresses/ - list all addresses -- POST /api/clients/pickup-addresses/ - create address -- GET /api/clients/pickup-addresses/<id>/ - retrieve address -- PUT /api/clients/pickup-addresses/<id>/ - full update -- PATCH /api/clients/pickup-addresses/<id>/ - partial update -- DELETE /api/clients/pickup-addresses/<id>/ - soft delete (sets is_active=False) +- GET /api/companies/pickup-addresses/ - list all addresses +- POST /api/companies/pickup-addresses/ - create address +- GET /api/companies/pickup-addresses/<id>/ - retrieve address +- PUT /api/companies/pickup-addresses/<id>/ - full update +- PATCH /api/companies/pickup-addresses/<id>/ - partial update +- DELETE /api/companies/pickup-addresses/<id>/ - soft delete (sets is_active=False) Query Parameters: -- supplier_id: Filter addresses by supplier (client) UUID`, +- supplier_id: Filter addresses by supplier (company) UUID`, requestFormat: 'json', parameters: [ { @@ -5387,20 +5387,20 @@ Query Parameters: }, { method: 'get', - path: '/api/clients/pickup-addresses/:id/', - alias: 'clients_pickup_addresses_retrieve', + path: '/api/companies/pickup-addresses/:id/', + alias: 'companies_pickup_addresses_retrieve', description: `ViewSet for SupplierPickupAddress CRUD operations. Endpoints: -- GET /api/clients/pickup-addresses/ - list all addresses -- POST /api/clients/pickup-addresses/ - create address -- GET /api/clients/pickup-addresses/<id>/ - retrieve address -- PUT /api/clients/pickup-addresses/<id>/ - full update -- PATCH /api/clients/pickup-addresses/<id>/ - partial update -- DELETE /api/clients/pickup-addresses/<id>/ - soft delete (sets is_active=False) +- GET /api/companies/pickup-addresses/ - list all addresses +- POST /api/companies/pickup-addresses/ - create address +- GET /api/companies/pickup-addresses/<id>/ - retrieve address +- PUT /api/companies/pickup-addresses/<id>/ - full update +- PATCH /api/companies/pickup-addresses/<id>/ - partial update +- DELETE /api/companies/pickup-addresses/<id>/ - soft delete (sets is_active=False) Query Parameters: -- supplier_id: Filter addresses by supplier (client) UUID`, +- supplier_id: Filter addresses by supplier (company) UUID`, requestFormat: 'json', parameters: [ { @@ -5413,20 +5413,20 @@ Query Parameters: }, { method: 'put', - path: '/api/clients/pickup-addresses/:id/', - alias: 'clients_pickup_addresses_update', + path: '/api/companies/pickup-addresses/:id/', + alias: 'companies_pickup_addresses_update', description: `ViewSet for SupplierPickupAddress CRUD operations. Endpoints: -- GET /api/clients/pickup-addresses/ - list all addresses -- POST /api/clients/pickup-addresses/ - create address -- GET /api/clients/pickup-addresses/<id>/ - retrieve address -- PUT /api/clients/pickup-addresses/<id>/ - full update -- PATCH /api/clients/pickup-addresses/<id>/ - partial update -- DELETE /api/clients/pickup-addresses/<id>/ - soft delete (sets is_active=False) +- GET /api/companies/pickup-addresses/ - list all addresses +- POST /api/companies/pickup-addresses/ - create address +- GET /api/companies/pickup-addresses/<id>/ - retrieve address +- PUT /api/companies/pickup-addresses/<id>/ - full update +- PATCH /api/companies/pickup-addresses/<id>/ - partial update +- DELETE /api/companies/pickup-addresses/<id>/ - soft delete (sets is_active=False) Query Parameters: -- supplier_id: Filter addresses by supplier (client) UUID`, +- supplier_id: Filter addresses by supplier (company) UUID`, requestFormat: 'json', parameters: [ { @@ -5444,20 +5444,20 @@ Query Parameters: }, { method: 'patch', - path: '/api/clients/pickup-addresses/:id/', - alias: 'clients_pickup_addresses_partial_update', + path: '/api/companies/pickup-addresses/:id/', + alias: 'companies_pickup_addresses_partial_update', description: `ViewSet for SupplierPickupAddress CRUD operations. Endpoints: -- GET /api/clients/pickup-addresses/ - list all addresses -- POST /api/clients/pickup-addresses/ - create address -- GET /api/clients/pickup-addresses/<id>/ - retrieve address -- PUT /api/clients/pickup-addresses/<id>/ - full update -- PATCH /api/clients/pickup-addresses/<id>/ - partial update -- DELETE /api/clients/pickup-addresses/<id>/ - soft delete (sets is_active=False) +- GET /api/companies/pickup-addresses/ - list all addresses +- POST /api/companies/pickup-addresses/ - create address +- GET /api/companies/pickup-addresses/<id>/ - retrieve address +- PUT /api/companies/pickup-addresses/<id>/ - full update +- PATCH /api/companies/pickup-addresses/<id>/ - partial update +- DELETE /api/companies/pickup-addresses/<id>/ - soft delete (sets is_active=False) Query Parameters: -- supplier_id: Filter addresses by supplier (client) UUID`, +- supplier_id: Filter addresses by supplier (company) UUID`, requestFormat: 'json', parameters: [ { @@ -5475,20 +5475,20 @@ Query Parameters: }, { method: 'delete', - path: '/api/clients/pickup-addresses/:id/', - alias: 'clients_pickup_addresses_destroy', + path: '/api/companies/pickup-addresses/:id/', + alias: 'companies_pickup_addresses_destroy', description: `ViewSet for SupplierPickupAddress CRUD operations. Endpoints: -- GET /api/clients/pickup-addresses/ - list all addresses -- POST /api/clients/pickup-addresses/ - create address -- GET /api/clients/pickup-addresses/<id>/ - retrieve address -- PUT /api/clients/pickup-addresses/<id>/ - full update -- PATCH /api/clients/pickup-addresses/<id>/ - partial update -- DELETE /api/clients/pickup-addresses/<id>/ - soft delete (sets is_active=False) +- GET /api/companies/pickup-addresses/ - list all addresses +- POST /api/companies/pickup-addresses/ - create address +- GET /api/companies/pickup-addresses/<id>/ - retrieve address +- PUT /api/companies/pickup-addresses/<id>/ - full update +- PATCH /api/companies/pickup-addresses/<id>/ - partial update +- DELETE /api/companies/pickup-addresses/<id>/ - soft delete (sets is_active=False) Query Parameters: -- supplier_id: Filter addresses by supplier (client) UUID`, +- supplier_id: Filter addresses by supplier (company) UUID`, requestFormat: 'json', parameters: [ { @@ -5501,9 +5501,9 @@ Query Parameters: }, { method: 'get', - path: '/api/clients/search/', - alias: 'clients_search_retrieve', - description: `Lists/searches clients with pagination and sorting.`, + path: '/api/companies/search/', + alias: 'companies_search_retrieve', + description: `Lists/searches companies with pagination and sorting.`, requestFormat: 'json', parameters: [ { @@ -5532,18 +5532,18 @@ Query Parameters: schema: z.string().optional(), }, ], - response: ClientSearchResponse, + response: CompanySearchResponse, errors: [ { status: 500, - schema: ClientErrorResponse, + schema: CompanyErrorResponse, }, ], }, { method: 'delete', - path: '/api/clients/supplier-aliases/:alias_id/', - alias: 'clients_supplier_aliases_destroy', + path: '/api/companies/supplier-aliases/:alias_id/', + alias: 'companies_supplier_aliases_destroy', description: `Deactivate a supplier search alias.`, requestFormat: 'json', parameters: [ @@ -5752,14 +5752,14 @@ DELETE: Clear a logo field and remove the file from disk.`, requestFormat: 'json', parameters: [ { - name: 'client', + name: 'client_match', type: 'Query', - schema: z.string().uuid().optional(), + schema: z.string().optional(), }, { - name: 'client_match', + name: 'company', type: 'Query', - schema: z.string().optional(), + schema: z.string().uuid().optional(), }, { name: 'contact', @@ -6140,7 +6140,7 @@ POST /job/rest/cost_lines/<cost_line_id>/approve`, method: 'get', path: '/api/job/data-quality/duplicate-phones/', alias: 'check_duplicate_phones', - description: `List phone numbers owned by more than one client, or client numbers that are actually internal company lines.`, + description: `List phone numbers owned by more than one company, or company numbers that are actually internal company lines.`, requestFormat: 'json', response: DuplicatePhonesResponse, errors: [ @@ -7343,7 +7343,7 @@ Expected JSON: requestFormat: 'json', parameters: [ { - name: 'client_name', + name: 'company_name', type: 'Query', schema: z.string().optional(), }, @@ -9227,7 +9227,7 @@ Custom Actions: alias: 'getSupplierPriceStatus', description: `Return latest price upload status per supplier. -Minimal-impact: read-only query over existing Client and SupplierPriceList +Minimal-impact: read-only query over existing Company and SupplierPriceList models. No migrations required.`, requestFormat: 'json', response: SupplierPriceStatusResponse, @@ -9388,7 +9388,7 @@ Supports pagination via ``limit``/``offset`&# method: 'post', path: '/api/search-events/click/', alias: 'search_events_click_create', - description: `Records a selected result from client, Kanban, or stock search.`, + description: `Records a selected result from company, Kanban, or stock search.`, requestFormat: 'json', parameters: [ { @@ -9552,7 +9552,7 @@ Returns: errors: [ { status: 500, - schema: ClientErrorResponse, + schema: CompanyErrorResponse, }, ], }, @@ -9573,15 +9573,15 @@ Returns: errors: [ { status: 400, - schema: ClientErrorResponse, + schema: CompanyErrorResponse, }, { status: 409, - schema: ClientErrorResponse, + schema: CompanyErrorResponse, }, { status: 500, - schema: ClientErrorResponse, + schema: CompanyErrorResponse, }, ], }, @@ -9595,7 +9595,7 @@ Returns: errors: [ { status: 500, - schema: ClientErrorResponse, + schema: CompanyErrorResponse, }, ], }, @@ -9618,7 +9618,7 @@ Use GET /api/payroll/post-staff-week/stream/{task_id}/ to receive SSE progress.` errors: [ { status: 400, - schema: ClientErrorResponse, + schema: CompanyErrorResponse, }, ], }, @@ -9681,11 +9681,11 @@ Returns: errors: [ { status: 400, - schema: ClientErrorResponse, + schema: CompanyErrorResponse, }, { status: 500, - schema: ClientErrorResponse, + schema: CompanyErrorResponse, }, ], }, diff --git a/mypy-baseline.txt b/mypy-baseline.txt index b66a3f507..535303973 100644 --- a/mypy-baseline.txt +++ b/mypy-baseline.txt @@ -1,17 +1,6 @@ apps/job/services/kanban_categorization_service.py:0: error: Missing type arguments for generic type "List" [type-arg] apps/job/services/kanban_categorization_service.py:0: error: Missing type arguments for generic type "List" [type-arg] apps/workflow/tests/fixtures/xero_responses.py:0: error: Missing type arguments for generic type "dict" [type-arg] -apps/workflow/accounting/types.py:0: error: Name "date" is not defined [name-defined] -apps/workflow/accounting/types.py:0: error: Variable "apps.workflow.accounting.types.InvoicePayload.date" is not valid as a type [valid-type] -apps/workflow/accounting/types.py:0: note: See https://mypy.readthedocs.io/en/stable/common_issues.html#variables-vs-type-aliases -apps/workflow/accounting/types.py:0: error: Name "date" is not defined [name-defined] -apps/workflow/accounting/types.py:0: error: Variable "apps.workflow.accounting.types.QuotePayload.date" is not valid as a type [valid-type] -apps/workflow/accounting/types.py:0: note: See https://mypy.readthedocs.io/en/stable/common_issues.html#variables-vs-type-aliases -apps/workflow/accounting/types.py:0: error: Name "date" is not defined [name-defined] -apps/workflow/accounting/types.py:0: error: Variable "apps.workflow.accounting.types.POPayload.date" is not valid as a type [valid-type] -apps/workflow/accounting/types.py:0: note: See https://mypy.readthedocs.io/en/stable/common_issues.html#variables-vs-type-aliases -apps/workflow/accounting/types.py:0: error: Missing type arguments for generic type "dict" [type-arg] -apps/workflow/accounting/types.py:0: error: Missing type arguments for generic type "dict" [type-arg] apps/workflow/views/xero/xero_helpers.py:0: error: Function is missing a type annotation [no-untyped-def] apps/workflow/views/xero/xero_helpers.py:0: error: Call to untyped function "clean_payload" in typed context [no-untyped-call] apps/workflow/views/xero/xero_helpers.py:0: error: Call to untyped function "clean_payload" in typed context [no-untyped-call] @@ -28,8 +17,8 @@ apps/job/services/delta_checksum.py:0: error: Incompatible types in assignment ( apps/job/services/delta_checksum.py:0: error: Argument 1 to "join" of "str" has incompatible type "Decimal"; expected "Iterable[str]" [arg-type] apps/accounts/staff_anonymization.py:0: error: Missing type arguments for generic type "dict" [type-arg] apps/accounts/staff_anonymization.py:0: error: Missing type arguments for generic type "dict" [type-arg] -apps/client/utils.py:0: error: Function is missing a type annotation [no-untyped-def] -apps/client/services/geocoding_service.py:0: error: Missing type arguments for generic type "dict" [type-arg] +apps/company/utils.py:0: error: Function is missing a type annotation [no-untyped-def] +apps/company/services/geocoding_service.py:0: error: Missing type arguments for generic type "dict" [type-arg] apps/job/tests/test_delta_checksum.py:0: error: Function is missing a return type annotation [no-untyped-def] apps/job/tests/test_delta_checksum.py:0: note: Use "-> None" if function does not return a value apps/job/tests/test_delta_checksum.py:0: error: Function is missing a return type annotation [no-untyped-def] @@ -44,6 +33,28 @@ apps/job/tests/test_delta_checksum.py:0: error: Function is missing a return typ apps/job/tests/test_delta_checksum.py:0: note: Use "-> None" if function does not return a value apps/job/tests/test_delta_checksum.py:0: error: Function is missing a return type annotation [no-untyped-def] apps/job/tests/test_delta_checksum.py:0: note: Use "-> None" if function does not return a value +apps/quoting/services/providers/mistral_provider.py:0: error: Function is missing a type annotation [no-untyped-def] +apps/quoting/services/providers/mistral_provider.py:0: error: Argument 1 to "_parse_price_from_string" of "MistralPriceExtractionProvider" has incompatible type "str | None"; expected "str" [arg-type] +apps/quoting/services/providers/mistral_provider.py:0: error: Function is missing a type annotation for one or more parameters [no-untyped-def] +apps/quoting/services/providers/mistral_provider.py:0: error: Call to untyped function "encode_pdf" in typed context [no-untyped-call] +apps/quoting/services/providers/mistral_provider.py:0: error: Function is missing a type annotation [no-untyped-def] +apps/quoting/services/providers/mistral_provider.py:0: error: Call to untyped function "ocr_response_to_dict" in typed context [no-untyped-call] +apps/quoting/services/providers/mistral_provider.py:0: error: Call to untyped function "ocr_response_to_dict" in typed context [no-untyped-call] +apps/quoting/services/providers/mistral_provider.py:0: error: Call to untyped function "ocr_response_to_dict" in typed context [no-untyped-call] +apps/quoting/tests/test_ocr_fixtures.py:0: error: Function is missing a return type annotation [no-untyped-def] +apps/quoting/tests/test_ocr_fixtures.py:0: error: Function is missing a type annotation [no-untyped-def] +apps/quoting/tests/test_ocr_fixtures.py:0: error: Call to untyped function "_ocr_response" in typed context [no-untyped-call] +apps/workflow/accounting/types.py:0: error: Name "date" is not defined [name-defined] +apps/workflow/accounting/types.py:0: error: Variable "apps.workflow.accounting.types.InvoicePayload.date" is not valid as a type [valid-type] +apps/workflow/accounting/types.py:0: note: See https://mypy.readthedocs.io/en/stable/common_issues.html#variables-vs-type-aliases +apps/workflow/accounting/types.py:0: error: Name "date" is not defined [name-defined] +apps/workflow/accounting/types.py:0: error: Variable "apps.workflow.accounting.types.QuotePayload.date" is not valid as a type [valid-type] +apps/workflow/accounting/types.py:0: note: See https://mypy.readthedocs.io/en/stable/common_issues.html#variables-vs-type-aliases +apps/workflow/accounting/types.py:0: error: Name "date" is not defined [name-defined] +apps/workflow/accounting/types.py:0: error: Variable "apps.workflow.accounting.types.POPayload.date" is not valid as a type [valid-type] +apps/workflow/accounting/types.py:0: note: See https://mypy.readthedocs.io/en/stable/common_issues.html#variables-vs-type-aliases +apps/workflow/accounting/types.py:0: error: Missing type arguments for generic type "dict" [type-arg] +apps/workflow/accounting/types.py:0: error: Missing type arguments for generic type "dict" [type-arg] docketworks/settings.py:0: error: Item "None" of "str | None" has no attribute "lower" [union-attr] docketworks/settings.py:0: error: Item "None" of "str | None" has no attribute "lower" [union-attr] docketworks/settings.py:0: error: Item "None" of "str | None" has no attribute "lower" [union-attr] @@ -79,11 +90,11 @@ apps/accounting/models/invoice.py:0: error: Incompatible return value type (got apps/accounting/models/invoice.py:0: error: Generator has incompatible item type "Any | Decimal"; expected "bool" [misc] apps/accounting/models/invoice.py:0: error: "BaseXeroInvoiceDocument" has no attribute "line_items" [attr-defined] apps/job/helpers.py:0: error: Argument 1 to "join" has incompatible type "str | None"; expected "str" [arg-type] -apps/client/models.py:0: error: Function is missing a type annotation for one or more parameters [no-untyped-def] -apps/client/models.py:0: error: Missing type arguments for generic type "QuerySet" [type-arg] -apps/client/models.py:0: error: Need type annotation for "CLIENTCONTACT_INTERNAL_FIELDS" (hint: "CLIENTCONTACT_INTERNAL_FIELDS: list[] = ...") [var-annotated] -apps/client/models.py:0: error: Incompatible type for lookup 'client_id': (got "UUID | None", expected "UUID | str") [misc] -apps/client/models.py:0: error: Need type annotation for "SUPPLIERPICKUPADDRESS_INTERNAL_FIELDS" (hint: "SUPPLIERPICKUPADDRESS_INTERNAL_FIELDS: list[] = ...") [var-annotated] +apps/company/models.py:0: error: Function is missing a type annotation for one or more parameters [no-untyped-def] +apps/company/models.py:0: error: Missing type arguments for generic type "QuerySet" [type-arg] +apps/company/models.py:0: error: Need type annotation for "CLIENTCONTACT_INTERNAL_FIELDS" (hint: "CLIENTCONTACT_INTERNAL_FIELDS: list[] = ...") [var-annotated] +apps/company/models.py:0: error: Incompatible type for lookup 'company_id': (got "UUID | None", expected "UUID | str") [misc] +apps/company/models.py:0: error: Need type annotation for "SUPPLIERPICKUPADDRESS_INTERNAL_FIELDS" (hint: "SUPPLIERPICKUPADDRESS_INTERNAL_FIELDS: list[] = ...") [var-annotated] apps/workflow/models/company_defaults.py:0: error: Call to untyped function "_recompute_all_staff_wage_rates" in typed context [no-untyped-call] apps/job/services/file_service.py:0: error: Function is missing a type annotation [no-untyped-def] apps/job/services/file_service.py:0: error: Function is missing a type annotation [no-untyped-def] @@ -253,12 +264,57 @@ docketworks/celery.py:0: error: Module "celery.signals" has no attribute "task_p docketworks/celery.py:0: error: Module "celery.signals" has no attribute "task_prerun" [attr-defined] docketworks/celery.py:0: error: Untyped decorator makes function "_nplusone_setup" untyped [untyped-decorator] docketworks/celery.py:0: error: Untyped decorator makes function "_nplusone_teardown" untyped [untyped-decorator] +apps/workflow/services/request.py:0: error: Returning Any from function declared to return "str" [no-any-return] +apps/workflow/services/request.py:0: error: Returning Any from function declared to return "str" [no-any-return] +apps/job/services/time_entry_rates.py:0: error: Item "None" of "Any | None" has no attribute "xero_id" [union-attr] +apps/job/services/time_entry_rates.py:0: error: Item "None" of "Any | None" has no attribute "name" [union-attr] +apps/job/services/time_entry_rates.py:0: error: Incompatible return value type (got "Any | None", expected "XeroPayItem") [return-value] +apps/workflow/tests/test_xero_instance_templates.py:0: error: Function is missing a return type annotation [no-untyped-def] +apps/workflow/tests/test_xero_instance_templates.py:0: note: Use "-> None" if function does not return a value +apps/workflow/tests/test_xero_instance_templates.py:0: error: Function is missing a return type annotation [no-untyped-def] +apps/workflow/tests/test_xero_instance_templates.py:0: note: Use "-> None" if function does not return a value +apps/workflow/tests/test_xero_instance_templates.py:0: error: Function is missing a return type annotation [no-untyped-def] +apps/workflow/tests/test_xero_instance_templates.py:0: note: Use "-> None" if function does not return a value +apps/company/tests/test_get_company_for_xero.py:0: error: Function is missing a type annotation [no-untyped-def] +apps/company/tests/test_get_company_for_xero.py:0: error: Function is missing a return type annotation [no-untyped-def] +apps/company/tests/test_get_company_for_xero.py:0: note: Use "-> None" if function does not return a value +apps/company/tests/test_get_company_for_xero.py:0: error: Call to untyped function "_make_company" in typed context [no-untyped-call] +apps/company/tests/test_get_company_for_xero.py:0: error: Function is missing a return type annotation [no-untyped-def] +apps/company/tests/test_get_company_for_xero.py:0: note: Use "-> None" if function does not return a value +apps/company/tests/test_get_company_for_xero.py:0: error: Call to untyped function "_make_company" in typed context [no-untyped-call] +apps/company/tests/test_get_company_for_xero.py:0: error: Function is missing a return type annotation [no-untyped-def] +apps/company/tests/test_get_company_for_xero.py:0: note: Use "-> None" if function does not return a value +apps/company/tests/test_get_company_for_xero.py:0: error: Call to untyped function "_make_company" in typed context [no-untyped-call] +apps/company/tests/test_get_company_for_xero.py:0: error: Function is missing a return type annotation [no-untyped-def] +apps/company/tests/test_get_company_for_xero.py:0: note: Use "-> None" if function does not return a value +apps/company/tests/test_get_company_for_xero.py:0: error: Call to untyped function "_make_company" in typed context [no-untyped-call] +apps/company/tests/test_get_company_for_xero.py:0: error: Function is missing a return type annotation [no-untyped-def] +apps/company/tests/test_get_company_for_xero.py:0: note: Use "-> None" if function does not return a value +apps/company/tests/test_get_company_for_xero.py:0: error: Call to untyped function "_make_company" in typed context [no-untyped-call] +apps/company/tests/test_get_company_for_xero.py:0: error: Function is missing a return type annotation [no-untyped-def] +apps/company/tests/test_get_company_for_xero.py:0: note: Use "-> None" if function does not return a value +apps/company/tests/test_get_company_for_xero.py:0: error: Call to untyped function "_make_company" in typed context [no-untyped-call] +apps/company/tests/test_get_company_for_xero.py:0: error: Function is missing a return type annotation [no-untyped-def] +apps/company/tests/test_get_company_for_xero.py:0: note: Use "-> None" if function does not return a value +apps/company/tests/test_get_company_for_xero.py:0: error: Call to untyped function "_make_company" in typed context [no-untyped-call] +apps/company/tests/test_get_company_for_xero.py:0: error: Function is missing a return type annotation [no-untyped-def] +apps/company/tests/test_get_company_for_xero.py:0: note: Use "-> None" if function does not return a value +apps/company/tests/test_get_company_for_xero.py:0: error: Call to untyped function "_make_company" in typed context [no-untyped-call] +apps/company/tests/test_get_company_for_xero.py:0: error: Function is missing a return type annotation [no-untyped-def] +apps/company/tests/test_get_company_for_xero.py:0: note: Use "-> None" if function does not return a value +apps/company/tests/test_get_company_for_xero.py:0: error: Call to untyped function "_make_company" in typed context [no-untyped-call] +apps/company/tests/test_get_company_for_xero.py:0: error: Function is missing a return type annotation [no-untyped-def] +apps/company/tests/test_get_company_for_xero.py:0: note: Use "-> None" if function does not return a value +apps/company/tests/test_get_company_for_xero.py:0: error: Call to untyped function "_make_company" in typed context [no-untyped-call] +apps/workflow/management/commands/sync_sequences.py:0: error: Function is missing a type annotation [no-untyped-def] apps/workflow/services/session_replay_service.py:0: error: Argument 1 to "Path" has incompatible type "str | None"; expected "str | PathLike[str]" [arg-type] apps/workflow/services/session_replay_service.py:0: error: Function is missing a type annotation for one or more parameters [no-untyped-def] apps/workflow/services/search_telemetry.py:0: error: Function is missing a type annotation for one or more parameters [no-untyped-def] apps/workflow/services/search_telemetry.py:0: error: Function is missing a return type annotation [no-untyped-def] -apps/workflow/services/request.py:0: error: Returning Any from function declared to return "str" [no-any-return] -apps/workflow/services/request.py:0: error: Returning Any from function declared to return "str" [no-any-return] +apps/workflow/management/commands/backfill_kanban_search_telemetry.py:0: error: Function is missing a type annotation [no-untyped-def] +apps/workflow/management/commands/backfill_kanban_search_telemetry.py:0: error: Function is missing a type annotation [no-untyped-def] +apps/workflow/management/commands/backfill_kanban_search_telemetry.py:0: error: Missing type arguments for generic type "dict" [type-arg] +apps/workflow/management/commands/backfill_kanban_search_telemetry.py:0: error: Returning Any from function declared to return "dict[Any, Any]" [no-any-return] apps/workflow/api/xero/client.py:0: error: Class cannot subclass "RESTClientObject" (has type "Any") [misc] apps/workflow/api/xero/client.py:0: error: Function is missing a type annotation [no-untyped-def] apps/workflow/api/xero/client.py:0: error: Function is missing a type annotation [no-untyped-def] @@ -297,6 +353,28 @@ apps/workflow/accounting/provider.py:0: error: Missing type arguments for generi apps/timesheet/services/xero_hours.py:0: error: Missing type arguments for generic type "dict" [type-arg] apps/timesheet/services/xero_hours.py:0: error: Missing type arguments for generic type "dict" [type-arg] apps/timesheet/services/xero_hours.py:0: error: Incompatible return value type (got "dict[str | None, Staff]", expected "dict[str, Staff]") [return-value] +apps/quoting/tests_utils.py:0: error: Function is missing a return type annotation [no-untyped-def] +apps/quoting/tests_utils.py:0: note: Use "-> None" if function does not return a value +apps/quoting/tests_utils.py:0: error: Function is missing a return type annotation [no-untyped-def] +apps/quoting/tests_utils.py:0: note: Use "-> None" if function does not return a value +apps/quoting/tests_utils.py:0: error: Function is missing a return type annotation [no-untyped-def] +apps/quoting/tests_utils.py:0: note: Use "-> None" if function does not return a value +apps/quoting/tests_utils.py:0: error: Function is missing a return type annotation [no-untyped-def] +apps/quoting/tests_utils.py:0: note: Use "-> None" if function does not return a value +apps/quoting/tests_utils.py:0: error: Function is missing a return type annotation [no-untyped-def] +apps/quoting/tests_utils.py:0: note: Use "-> None" if function does not return a value +apps/quoting/tests_utils.py:0: error: Function is missing a return type annotation [no-untyped-def] +apps/quoting/tests_utils.py:0: note: Use "-> None" if function does not return a value +apps/quoting/tests_utils.py:0: error: Function is missing a return type annotation [no-untyped-def] +apps/quoting/tests_utils.py:0: note: Use "-> None" if function does not return a value +apps/quoting/tests_utils.py:0: error: Function is missing a return type annotation [no-untyped-def] +apps/quoting/tests_utils.py:0: note: Use "-> None" if function does not return a value +apps/quoting/tests_utils.py:0: error: Function is missing a return type annotation [no-untyped-def] +apps/quoting/tests_utils.py:0: note: Use "-> None" if function does not return a value +apps/quoting/tests_utils.py:0: error: Function is missing a return type annotation [no-untyped-def] +apps/quoting/tests_utils.py:0: note: Use "-> None" if function does not return a value +apps/quoting/tests_utils.py:0: error: Function is missing a return type annotation [no-untyped-def] +apps/quoting/tests_utils.py:0: note: Use "-> None" if function does not return a value apps/quoting/services/pdf_data_validation.py:0: error: Function is missing a return type annotation [no-untyped-def] apps/quoting/services/pdf_data_validation.py:0: note: Use "-> None" if function does not return a value apps/quoting/services/pdf_data_validation.py:0: error: Returning Any from function declared to return "str" [no-any-return] @@ -340,10 +418,9 @@ apps/job/tests/test_job_delta_rejection_resolution.py:0: error: Function is miss apps/job/tests/test_job_delta_rejection_resolution.py:0: error: Function is missing a type annotation [no-untyped-def] apps/job/tests/test_job_delta_rejection_resolution.py:0: error: Function is missing a type annotation [no-untyped-def] apps/job/tests/_pdf_golden_fixtures.py:0: error: Call to untyped function "save" in typed context [no-untyped-call] +apps/job/tests/_pdf_golden_fixtures.py:0: error: Incompatible types in assignment (expression has type "Company", variable has type "CompanyDefaults") [assignment] +apps/job/tests/_pdf_golden_fixtures.py:0: error: Incompatible type for "company" of "ClientContact" (got "CompanyDefaults", expected "Company | Combinable") [misc] apps/job/tests/_pdf_golden_fixtures.py:0: error: Returning Any from function declared to return "Job" [no-any-return] -apps/job/services/time_entry_rates.py:0: error: Item "None" of "Any | None" has no attribute "xero_id" [union-attr] -apps/job/services/time_entry_rates.py:0: error: Item "None" of "Any | None" has no attribute "name" [union-attr] -apps/job/services/time_entry_rates.py:0: error: Incompatible return value type (got "Any | None", expected "XeroPayItem") [return-value] apps/job/services/job_service.py:0: error: Function is missing a return type annotation [no-untyped-def] apps/job/services/job_service.py:0: error: Function is missing a type annotation [no-untyped-def] apps/job/services/job_service.py:0: error: Function is missing a type annotation for one or more parameters [no-untyped-def] @@ -403,34 +480,68 @@ apps/job/importers/quote_spreadsheet.py:0: error: Function is missing a type ann apps/job/importers/google_sheets.py:0: error: Call to untyped function "from_service_account_file" of "Credentials" in typed context [no-untyped-call] apps/crm/services/phone_call_service.py:0: error: Module "django.utils.timezone" does not explicitly export attribute "timedelta" [attr-defined] apps/crm/services/phone_call_service.py:0: error: Function is missing a return type annotation [no-untyped-def] -apps/crm/services/phone_call_service.py:0: error: Dict entry 0 has incompatible type "str": "Client"; expected "str": "ClientContact | None" [dict-item] +apps/crm/services/phone_call_service.py:0: error: Dict entry 0 has incompatible type "str": "Company"; expected "str": "ClientContact | None" [dict-item] apps/crm/services/phone_call_service.py:0: error: Item "None" of "ClientContact | None" has no attribute "is_active" [union-attr] -apps/crm/services/phone_call_service.py:0: error: Item "None" of "ClientContact | None" has no attribute "client_id" [union-attr] -apps/crm/services/phone_call_service.py:0: error: Item "None" of "Client | None" has no attribute "allow_jobs" [union-attr] +apps/crm/services/phone_call_service.py:0: error: Item "None" of "ClientContact | None" has no attribute "company_id" [union-attr] +apps/crm/services/phone_call_service.py:0: error: Item "None" of "Company | None" has no attribute "allow_jobs" [union-attr] apps/accounting/services/sales_pipeline_service.py:0: error: Missing type arguments for generic type "dict" [type-arg] apps/accounting/services/sales_pipeline_service.py:0: error: Missing type arguments for generic type "dict" [type-arg] apps/accounting/services/sales_pipeline_service.py:0: error: Incompatible types in assignment (expression has type "float | None", target has type "float") [assignment] apps/accounting/services/sales_pipeline_service.py:0: error: Incompatible types in assignment (expression has type "float | None", target has type "float") [assignment] apps/accounting/services/sales_pipeline_service.py:0: error: Incompatible types in assignment (expression has type "float | None", target has type "float") [assignment] -apps/accounting/services/sales_pipeline_service.py:0: error: Item "None" of "Client | None" has no attribute "name" [union-attr] +apps/accounting/services/sales_pipeline_service.py:0: error: Item "None" of "Company | None" has no attribute "name" [union-attr] apps/accounting/services/invoice_calculation.py:0: error: Returning Any from function declared to return "Job" [no-any-return] +apps/company/services/company_merge_service.py:0: error: Function is missing a type annotation for one or more parameters [no-untyped-def] +apps/company/management/commands/merge_companies.py:0: error: Function is missing a type annotation [no-untyped-def] +apps/company/management/commands/merge_companies.py:0: error: Function is missing a type annotation [no-untyped-def] +apps/quoting/services/providers/gemini_provider.py:0: error: Function is missing a type annotation for one or more parameters [no-untyped-def] +apps/quoting/services/providers/gemini_provider.py:0: error: Incompatible types in assignment (expression has type "bool", target has type "Sequence[str]") [assignment] +apps/quoting/services/providers/gemini_provider.py:0: error: Incompatible types in assignment (expression has type "bool", target has type "Sequence[str]") [assignment] +apps/quoting/services/providers/gemini_provider.py:0: error: Incompatible types in assignment (expression has type "int", target has type "Sequence[str]") [assignment] +apps/quoting/services/providers/gemini_provider.py:0: error: Incompatible types in assignment (expression has type "Any | None", target has type "Sequence[str]") [assignment] +apps/quoting/services/providers/gemini_provider.py:0: error: Incompatible types in assignment (expression has type "int", target has type "Sequence[str]") [assignment] +apps/quoting/services/providers/gemini_provider.py:0: error: Incompatible types in assignment (expression has type "list[dict[str, object]]", target has type "Sequence[str]") [assignment] +apps/quoting/services/providers/gemini_provider.py:0: error: Need type annotation for "supplier_info" (hint: "supplier_info: dict[, ] = ...") [var-annotated] +apps/quoting/services/providers/gemini_provider.py:0: error: Missing type arguments for generic type "List" [type-arg] +apps/quoting/services/providers/gemini_provider.py:0: error: Argument "config" to "generate_content" of "Models" has incompatible type "dict[str, object]"; expected "GenerateContentConfig | GenerateContentConfigDict | None" [arg-type] +apps/quoting/services/providers/gemini_provider.py:0: error: Call to untyped function "log_token_usage" in typed context [no-untyped-call] +apps/purchasing/services/quote_to_po_service.py:0: error: Function is missing a type annotation [no-untyped-def] +apps/purchasing/services/quote_to_po_service.py:0: error: Argument 1 to "join" has incompatible type "str | None"; expected "str" [arg-type] +apps/purchasing/services/quote_to_po_service.py:0: error: Function is missing a type annotation [no-untyped-def] +apps/purchasing/services/quote_to_po_service.py:0: error: Dict entry 0 has incompatible type "str": "dict[str, str]"; expected "str": "str" [dict-item] +apps/purchasing/services/quote_to_po_service.py:0: error: Dict entry 0 has incompatible type "str": "dict[str, str]"; expected "str": "str" [dict-item] +apps/purchasing/services/quote_to_po_service.py:0: error: Argument "contents" to "generate_content" of "Models" has incompatible type "list[dict[str, str]]"; expected "Content | ContentDict | str | Image | File | FileDict | Part | PartDict | list[str | Image | File | FileDict | Part | PartDict] | list[Content | ContentDict | str | Image | File | FileDict | Part | PartDict | list[str | Image | File | FileDict | Part | PartDict]]" [arg-type] +apps/purchasing/services/quote_to_po_service.py:0: error: Call to untyped function "log_token_usage" in typed context [no-untyped-call] +apps/purchasing/services/quote_to_po_service.py:0: error: Argument 1 to "clean_json_response" has incompatible type "str | None"; expected "str" [arg-type] +apps/purchasing/services/quote_to_po_service.py:0: error: Argument 1 to "join" has incompatible type "str | None"; expected "str" [arg-type] +apps/workflow/services/llm_service.py:0: error: Module "litellm" does not explicitly export attribute "set_verbose" [attr-defined] +apps/workflow/services/llm_service.py:0: error: Function is missing a return type annotation [no-untyped-def] +apps/workflow/services/llm_service.py:0: error: Call to untyped function "get_default" of "AIProvider" in typed context [no-untyped-call] +apps/workflow/services/llm_service.py:0: error: No overload variant of "get" of "dict" matches argument types "str", "str" [call-overload] +apps/workflow/services/llm_service.py:0: note: Possible overload variants: +apps/workflow/services/llm_service.py:0: note: def get(self, AIProviderTypes, None = ..., /) -> str | None +apps/workflow/services/llm_service.py:0: note: def get(self, AIProviderTypes, str, /) -> str +apps/workflow/services/llm_service.py:0: note: def [_T] get(self, AIProviderTypes, _T, /) -> str | _T +apps/workflow/services/llm_service.py:0: error: Function is missing a type annotation for one or more parameters [no-untyped-def] +apps/workflow/services/llm_service.py:0: error: Missing type arguments for generic type "dict" [type-arg] +apps/workflow/services/llm_service.py:0: error: Missing type arguments for generic type "dict" [type-arg] +apps/workflow/services/llm_service.py:0: error: Missing type arguments for generic type "dict" [type-arg] +apps/workflow/services/llm_service.py:0: error: Function is missing a type annotation for one or more parameters [no-untyped-def] +apps/workflow/services/llm_service.py:0: error: Missing type arguments for generic type "dict" [type-arg] +apps/workflow/services/llm_service.py:0: error: Missing type arguments for generic type "dict" [type-arg] +apps/workflow/services/llm_service.py:0: error: Returning Any from function declared to return "dict[Any, Any]" [no-any-return] +apps/workflow/services/llm_service.py:0: error: Function is missing a type annotation for one or more parameters [no-untyped-def] +apps/workflow/services/llm_service.py:0: error: Returning Any from function declared to return "str" [no-any-return] +apps/workflow/services/llm_service.py:0: error: Missing type arguments for generic type "dict" [type-arg] +apps/workflow/services/llm_service.py:0: error: Missing type arguments for generic type "dict" [type-arg] +apps/workflow/services/llm_service.py:0: error: Function is missing a type annotation for one or more parameters [no-untyped-def] +apps/workflow/services/llm_service.py:0: error: Function is missing a type annotation for one or more parameters [no-untyped-def] +apps/workflow/services/llm_service.py:0: error: Missing type arguments for generic type "dict" [type-arg] apps/workflow/services/dev_demo_export_scrubber.py:0: error: Call to untyped function "save" in typed context [no-untyped-call] -apps/job/services/month_end_service.py:0: error: Call to untyped function "get_stock_holding_job" of "Stock" in typed context [no-untyped-call] -apps/job/services/month_end_service.py:0: error: Missing type arguments for generic type "dict" [type-arg] -apps/job/services/month_end_service.py:0: error: Missing type arguments for generic type "dict" [type-arg] -apps/job/services/month_end_service.py:0: error: Missing type arguments for generic type "dict" [type-arg] -apps/job/services/month_end_service.py:0: error: Call to untyped function "get_stock_holding_job" of "Stock" in typed context [no-untyped-call] -apps/job/services/month_end_service.py:0: error: Function is missing a type annotation for one or more parameters [no-untyped-def] apps/workflow/tests/test_xero_pay_item.py:0: error: Function is missing a return type annotation [no-untyped-def] apps/workflow/tests/test_xero_pay_item.py:0: note: Use "-> None" if function does not return a value apps/workflow/tests/test_xero_pay_item.py:0: error: Function is missing a return type annotation [no-untyped-def] apps/workflow/tests/test_xero_pay_item.py:0: note: Use "-> None" if function does not return a value -apps/workflow/tests/test_xero_instance_templates.py:0: error: Function is missing a return type annotation [no-untyped-def] -apps/workflow/tests/test_xero_instance_templates.py:0: note: Use "-> None" if function does not return a value -apps/workflow/tests/test_xero_instance_templates.py:0: error: Function is missing a return type annotation [no-untyped-def] -apps/workflow/tests/test_xero_instance_templates.py:0: note: Use "-> None" if function does not return a value -apps/workflow/tests/test_xero_instance_templates.py:0: error: Function is missing a return type annotation [no-untyped-def] -apps/workflow/tests/test_xero_instance_templates.py:0: note: Use "-> None" if function does not return a value apps/workflow/tests/test_xero_app_model.py:0: error: Function is missing a type annotation [no-untyped-def] apps/workflow/tests/test_xero_app_model.py:0: error: Function is missing a return type annotation [no-untyped-def] apps/workflow/tests/test_xero_app_model.py:0: note: Use "-> None" if function does not return a value @@ -451,37 +562,6 @@ apps/workflow/tests/test_xero_app_model.py:0: error: Function is missing a retur apps/workflow/tests/test_xero_app_model.py:0: note: Use "-> None" if function does not return a value apps/workflow/tests/test_xero_app_model.py:0: error: Call to untyped function "_row" in typed context [no-untyped-call] apps/workflow/tests/test_xero_app_model.py:0: error: Call to untyped function "_row" in typed context [no-untyped-call] -apps/client/tests/test_get_client_for_xero.py:0: error: Function is missing a type annotation [no-untyped-def] -apps/client/tests/test_get_client_for_xero.py:0: error: Function is missing a return type annotation [no-untyped-def] -apps/client/tests/test_get_client_for_xero.py:0: note: Use "-> None" if function does not return a value -apps/client/tests/test_get_client_for_xero.py:0: error: Call to untyped function "_make_client" in typed context [no-untyped-call] -apps/client/tests/test_get_client_for_xero.py:0: error: Function is missing a return type annotation [no-untyped-def] -apps/client/tests/test_get_client_for_xero.py:0: note: Use "-> None" if function does not return a value -apps/client/tests/test_get_client_for_xero.py:0: error: Call to untyped function "_make_client" in typed context [no-untyped-call] -apps/client/tests/test_get_client_for_xero.py:0: error: Function is missing a return type annotation [no-untyped-def] -apps/client/tests/test_get_client_for_xero.py:0: note: Use "-> None" if function does not return a value -apps/client/tests/test_get_client_for_xero.py:0: error: Call to untyped function "_make_client" in typed context [no-untyped-call] -apps/client/tests/test_get_client_for_xero.py:0: error: Function is missing a return type annotation [no-untyped-def] -apps/client/tests/test_get_client_for_xero.py:0: note: Use "-> None" if function does not return a value -apps/client/tests/test_get_client_for_xero.py:0: error: Call to untyped function "_make_client" in typed context [no-untyped-call] -apps/client/tests/test_get_client_for_xero.py:0: error: Function is missing a return type annotation [no-untyped-def] -apps/client/tests/test_get_client_for_xero.py:0: note: Use "-> None" if function does not return a value -apps/client/tests/test_get_client_for_xero.py:0: error: Call to untyped function "_make_client" in typed context [no-untyped-call] -apps/client/tests/test_get_client_for_xero.py:0: error: Function is missing a return type annotation [no-untyped-def] -apps/client/tests/test_get_client_for_xero.py:0: note: Use "-> None" if function does not return a value -apps/client/tests/test_get_client_for_xero.py:0: error: Call to untyped function "_make_client" in typed context [no-untyped-call] -apps/client/tests/test_get_client_for_xero.py:0: error: Function is missing a return type annotation [no-untyped-def] -apps/client/tests/test_get_client_for_xero.py:0: note: Use "-> None" if function does not return a value -apps/client/tests/test_get_client_for_xero.py:0: error: Call to untyped function "_make_client" in typed context [no-untyped-call] -apps/client/tests/test_get_client_for_xero.py:0: error: Function is missing a return type annotation [no-untyped-def] -apps/client/tests/test_get_client_for_xero.py:0: note: Use "-> None" if function does not return a value -apps/client/tests/test_get_client_for_xero.py:0: error: Call to untyped function "_make_client" in typed context [no-untyped-call] -apps/client/tests/test_get_client_for_xero.py:0: error: Function is missing a return type annotation [no-untyped-def] -apps/client/tests/test_get_client_for_xero.py:0: note: Use "-> None" if function does not return a value -apps/client/tests/test_get_client_for_xero.py:0: error: Call to untyped function "_make_client" in typed context [no-untyped-call] -apps/client/tests/test_get_client_for_xero.py:0: error: Function is missing a return type annotation [no-untyped-def] -apps/client/tests/test_get_client_for_xero.py:0: note: Use "-> None" if function does not return a value -apps/client/tests/test_get_client_for_xero.py:0: error: Call to untyped function "_make_client" in typed context [no-untyped-call] apps/workflow/management/commands/create_service_api_key.py:0: error: Function is missing a type annotation [no-untyped-def] apps/workflow/management/commands/create_service_api_key.py:0: error: Function is missing a type annotation [no-untyped-def] apps/timesheet/management/commands/reassign_time_entries.py:0: error: Function is missing a type annotation [no-untyped-def] @@ -502,10 +582,131 @@ apps/job/management/commands/set_paid_flag_jobs.py:0: error: Function is missing apps/job/management/commands/set_paid_flag_jobs.py:0: error: Function is missing a type annotation [no-untyped-def] apps/job/management/commands/create_shop_jobs.py:0: error: Function is missing a type annotation [no-untyped-def] apps/job/management/commands/create_shop_jobs.py:0: error: Unexpected attribute "shop_job" for model "Job" [misc] -apps/workflow/management/commands/backfill_kanban_search_telemetry.py:0: error: Function is missing a type annotation [no-untyped-def] -apps/workflow/management/commands/backfill_kanban_search_telemetry.py:0: error: Function is missing a type annotation [no-untyped-def] -apps/workflow/management/commands/backfill_kanban_search_telemetry.py:0: error: Missing type arguments for generic type "dict" [type-arg] -apps/workflow/management/commands/backfill_kanban_search_telemetry.py:0: error: Returning Any from function declared to return "dict[Any, Any]" [no-any-return] +apps/process/tests/test_procedure_model.py:0: error: Function is missing a return type annotation [no-untyped-def] +apps/process/tests/test_procedure_model.py:0: note: Use "-> None" if function does not return a value +apps/process/tests/test_procedure_model.py:0: error: Function is missing a return type annotation [no-untyped-def] +apps/process/tests/test_procedure_model.py:0: note: Use "-> None" if function does not return a value +apps/process/tests/test_procedure_model.py:0: error: Item "None" of "Procedure | None" has no attribute "title" [union-attr] +apps/process/tests/test_procedure_model.py:0: error: Function is missing a return type annotation [no-untyped-def] +apps/process/tests/test_procedure_model.py:0: note: Use "-> None" if function does not return a value +apps/process/tests/test_procedure_model.py:0: error: Function is missing a return type annotation [no-untyped-def] +apps/process/tests/test_procedure_model.py:0: note: Use "-> None" if function does not return a value +apps/process/tests/test_form_model.py:0: error: Function is missing a return type annotation [no-untyped-def] +apps/process/tests/test_form_model.py:0: note: Use "-> None" if function does not return a value +apps/process/tests/test_form_model.py:0: error: Function is missing a return type annotation [no-untyped-def] +apps/process/tests/test_form_model.py:0: note: Use "-> None" if function does not return a value +apps/process/tests/test_form_model.py:0: error: Function is missing a return type annotation [no-untyped-def] +apps/process/tests/test_form_model.py:0: note: Use "-> None" if function does not return a value +apps/process/tests/test_form_model.py:0: error: Function is missing a return type annotation [no-untyped-def] +apps/process/tests/test_form_model.py:0: note: Use "-> None" if function does not return a value +apps/process/tests/test_form_model.py:0: error: Function is missing a type annotation [no-untyped-def] +apps/process/tests/test_form_model.py:0: error: Function is missing a return type annotation [no-untyped-def] +apps/process/tests/test_form_model.py:0: note: Use "-> None" if function does not return a value +apps/process/services/form_service.py:0: error: Missing type arguments for generic type "dict" [type-arg] +apps/process/services/form_service.py:0: error: Function is missing a type annotation for one or more parameters [no-untyped-def] +apps/job/services/workshop_service.py:0: error: Function is missing a return type annotation [no-untyped-def] +apps/job/services/workshop_service.py:0: error: Function is missing a type annotation for one or more parameters [no-untyped-def] +apps/job/services/workshop_service.py:0: error: Missing type arguments for generic type "dict" [type-arg] +apps/job/services/workshop_service.py:0: error: Function is missing a type annotation for one or more parameters [no-untyped-def] +apps/job/services/workshop_service.py:0: error: Call to untyped function "_format_time" of "WorkshopTimesheetService" in typed context [no-untyped-call] +apps/job/services/workshop_service.py:0: error: Call to untyped function "_format_time" of "WorkshopTimesheetService" in typed context [no-untyped-call] +apps/job/services/workshop_service.py:0: error: Function is missing a type annotation for one or more parameters [no-untyped-def] +apps/job/services/workshop_service.py:0: error: Call to untyped function "_format_time" of "WorkshopTimesheetService" in typed context [no-untyped-call] +apps/job/services/workshop_service.py:0: error: Call to untyped function "_format_time" of "WorkshopTimesheetService" in typed context [no-untyped-call] +apps/job/services/workshop_service.py:0: error: Call to untyped function "save" in typed context [no-untyped-call] +apps/job/services/workshop_service.py:0: error: Call to untyped function "delete" in typed context [no-untyped-call] +apps/job/services/workshop_service.py:0: error: Missing type arguments for generic type "dict" [type-arg] +apps/job/services/workshop_service.py:0: error: Function is missing a return type annotation [no-untyped-def] +apps/job/services/workshop_service.py:0: error: Call to untyped function "get_default_cost_set_summary" in typed context [no-untyped-call] +apps/job/services/workshop_service.py:0: error: Function is missing a return type annotation [no-untyped-def] +apps/job/services/workshop_service.py:0: error: Function is missing a type annotation for one or more parameters [no-untyped-def] +apps/job/services/workshop_service.py:0: error: Function is missing a return type annotation [no-untyped-def] +apps/job/services/workshop_service.py:0: error: Function is missing a type annotation for one or more parameters [no-untyped-def] +apps/job/services/workshop_service.py:0: error: Function is missing a type annotation [no-untyped-def] +apps/accounts/utils.py:0: error: Missing type arguments for generic type "QuerySet" [type-arg] +apps/accounts/utils.py:0: error: Function is missing a return type annotation [no-untyped-def] +apps/accounts/utils.py:0: error: Missing type arguments for generic type "dict" [type-arg] +apps/workflow/tests/test_relabel_client_app.py:0: error: Function is missing a type annotation for one or more parameters [no-untyped-def] +apps/workflow/utils.py:0: error: Function is missing a type annotation for one or more parameters [no-untyped-def] +apps/workflow/search_telemetry_serializers.py:0: error: Missing type arguments for generic type "Serializer" [type-arg] +apps/workflow/search_telemetry_serializers.py:0: error: Incompatible types in assignment (expression has type "CharField", base class "Field" defined the type as "Callable[..., Any] | str | None") [assignment] +apps/workflow/search_telemetry_serializers.py:0: error: Missing type arguments for generic type "Serializer" [type-arg] +apps/workflow/exception_handlers.py:0: error: Missing type arguments for generic type "dict" [type-arg] +apps/workflow/exception_handlers.py:0: error: Argument "user_id" to "persist_app_error" has incompatible type "str | None"; expected "str" [arg-type] +apps/workflow/exception_handlers.py:0: error: Argument "session_replay_id" to "persist_app_error" has incompatible type "Any | None"; expected "str" [arg-type] +apps/workflow/exception_handlers.py:0: error: Argument "additional_context" to "persist_app_error" has incompatible type "dict[str, Any] | None"; expected "dict[Any, Any]" [arg-type] +apps/timesheet/serializers/payroll_serializers.py:0: error: Missing type arguments for generic type "Serializer" [type-arg] +apps/timesheet/serializers/payroll_serializers.py:0: error: Missing type arguments for generic type "Serializer" [type-arg] +apps/timesheet/serializers/payroll_serializers.py:0: error: Missing type arguments for generic type "Serializer" [type-arg] +apps/timesheet/serializers/payroll_serializers.py:0: error: Missing type arguments for generic type "Serializer" [type-arg] +apps/timesheet/serializers/payroll_serializers.py:0: error: Missing type arguments for generic type "Serializer" [type-arg] +apps/timesheet/serializers/payroll_serializers.py:0: error: Incompatible types in assignment (expression has type "ListField", base class "Serializer" defined the type as "ReturnDict[Any, Any]") [assignment] +apps/timesheet/serializers/payroll_serializers.py:0: error: Missing type arguments for generic type "Serializer" [type-arg] +apps/timesheet/serializers/payroll_serializers.py:0: error: Missing type arguments for generic type "Serializer" [type-arg] +apps/timesheet/serializers/payroll_serializers.py:0: error: Missing type arguments for generic type "Serializer" [type-arg] +apps/quoting/serializers_scheduled_tasks.py:0: error: Missing type arguments for generic type "ModelSerializer" [type-arg] +apps/quoting/serializers_scheduled_tasks.py:0: error: Missing type arguments for generic type "ModelSerializer" [type-arg] +apps/quoting/serializers.py:0: error: Missing type arguments for generic type "Serializer" [type-arg] +apps/quoting/serializers.py:0: error: Missing type arguments for generic type "Serializer" [type-arg] +apps/quoting/serializers.py:0: error: Missing type arguments for generic type "Serializer" [type-arg] +apps/quoting/serializers.py:0: error: Missing type arguments for generic type "Serializer" [type-arg] +apps/quoting/serializers.py:0: error: Missing type arguments for generic type "Serializer" [type-arg] +apps/quoting/serializers.py:0: error: Missing type arguments for generic type "Serializer" [type-arg] +apps/quoting/serializers.py:0: error: Missing type arguments for generic type "Serializer" [type-arg] +apps/process/serializers/procedure_serializer.py:0: error: Missing type arguments for generic type "Serializer" [type-arg] +apps/process/serializers/procedure_serializer.py:0: error: Missing type arguments for generic type "ModelSerializer" [type-arg] +apps/process/serializers/procedure_serializer.py:0: error: Missing type arguments for generic type "ModelSerializer" [type-arg] +apps/process/serializers/procedure_serializer.py:0: error: Missing type arguments for generic type "Serializer" [type-arg] +apps/process/serializers/procedure_serializer.py:0: error: Missing type arguments for generic type "ModelSerializer" [type-arg] +apps/process/serializers/procedure_serializer.py:0: error: Missing type arguments for generic type "Serializer" [type-arg] +apps/process/serializers/form_serializer.py:0: error: Missing type arguments for generic type "ModelSerializer" [type-arg] +apps/process/serializers/form_serializer.py:0: error: Missing type arguments for generic type "ModelSerializer" [type-arg] +apps/process/serializers/form_serializer.py:0: error: Missing type arguments for generic type "ModelSerializer" [type-arg] +apps/process/serializers/form_serializer.py:0: error: Missing type arguments for generic type "Serializer" [type-arg] +apps/process/serializers/form_serializer.py:0: error: Missing type arguments for generic type "ModelSerializer" [type-arg] +apps/job/permissions.py:0: error: Function is missing a type annotation [no-untyped-def] +apps/job/permissions.py:0: error: Function is missing a type annotation [no-untyped-def] +apps/job/serializers/quote_spreadsheet_serializer.py:0: error: Missing type arguments for generic type "ModelSerializer" [type-arg] +apps/job/serializers/data_integrity_serializers.py:0: error: Missing type arguments for generic type "Serializer" [type-arg] +apps/job/serializers/data_integrity_serializers.py:0: error: Missing type arguments for generic type "Serializer" [type-arg] +apps/job/serializers/data_integrity_serializers.py:0: error: Missing type arguments for generic type "Serializer" [type-arg] +apps/job/serializers/data_integrity_serializers.py:0: error: Missing type arguments for generic type "Serializer" [type-arg] +apps/job/serializers/data_integrity_serializers.py:0: error: Missing type arguments for generic type "Serializer" [type-arg] +apps/company/serializers.py:0: error: Missing type arguments for generic type "ModelSerializer" [type-arg] +apps/company/serializers.py:0: error: Function is missing a type annotation [no-untyped-def] +apps/company/serializers.py:0: error: Missing type arguments for generic type "ModelSerializer" [type-arg] +apps/company/serializers.py:0: error: Function is missing a type annotation [no-untyped-def] +apps/company/serializers.py:0: error: Missing type arguments for generic type "ModelSerializer" [type-arg] +apps/company/serializers.py:0: error: Function is missing a type annotation [no-untyped-def] +apps/company/serializers.py:0: error: Missing type arguments for generic type "ModelSerializer" [type-arg] +apps/company/serializers.py:0: error: Missing type arguments for generic type "ModelSerializer" [type-arg] +apps/company/serializers.py:0: error: Missing type arguments for generic type "Serializer" [type-arg] +apps/company/serializers.py:0: error: Missing type arguments for generic type "Serializer" [type-arg] +apps/company/serializers.py:0: error: Missing type arguments for generic type "Serializer" [type-arg] +apps/company/serializers.py:0: error: Missing type arguments for generic type "Serializer" [type-arg] +apps/company/serializers.py:0: error: Missing type arguments for generic type "ModelSerializer" [type-arg] +apps/company/serializers.py:0: error: Missing type arguments for generic type "Serializer" [type-arg] +apps/company/serializers.py:0: error: Missing type arguments for generic type "Serializer" [type-arg] +apps/company/serializers.py:0: error: Missing type arguments for generic type "Serializer" [type-arg] +apps/company/serializers.py:0: error: Missing type arguments for generic type "Serializer" [type-arg] +apps/company/serializers.py:0: error: Missing type arguments for generic type "Serializer" [type-arg] +apps/company/serializers.py:0: error: Missing type arguments for generic type "Serializer" [type-arg] +apps/company/serializers.py:0: error: Missing type arguments for generic type "Serializer" [type-arg] +apps/company/serializers.py:0: error: Missing type arguments for generic type "Serializer" [type-arg] +apps/company/serializers.py:0: error: Missing type arguments for generic type "Serializer" [type-arg] +apps/company/serializers.py:0: error: Missing type arguments for generic type "Serializer" [type-arg] +apps/company/serializers.py:0: error: Missing type arguments for generic type "Serializer" [type-arg] +apps/workflow/views/build_id_view.py:0: error: Missing type arguments for generic type "list" [type-arg] +apps/workflow/api/reports/pnl.py:0: error: Function is missing a type annotation [no-untyped-def] +apps/company/views/supplier_search_alias_views.py:0: error: Function is missing a type annotation for one or more parameters [no-untyped-def] +apps/company/views/supplier_search_alias_views.py:0: error: Function is missing a type annotation for one or more parameters [no-untyped-def] +apps/company/views/supplier_search_alias_views.py:0: error: Function is missing a type annotation for one or more parameters [no-untyped-def] +apps/job/services/month_end_service.py:0: error: Call to untyped function "get_stock_holding_job" of "Stock" in typed context [no-untyped-call] +apps/job/services/month_end_service.py:0: error: Missing type arguments for generic type "dict" [type-arg] +apps/job/services/month_end_service.py:0: error: Missing type arguments for generic type "dict" [type-arg] +apps/job/services/month_end_service.py:0: error: Missing type arguments for generic type "dict" [type-arg] +apps/job/services/month_end_service.py:0: error: Call to untyped function "get_stock_holding_job" of "Stock" in typed context [no-untyped-call] +apps/job/services/month_end_service.py:0: error: Function is missing a type annotation for one or more parameters [no-untyped-def] apps/workflow/tests/test_error_grouping.py:0: error: Function is missing a type annotation [no-untyped-def] apps/workflow/tests/test_error_grouping.py:0: error: Function is missing a type annotation [no-untyped-def] apps/workflow/tests/test_error_grouping.py:0: error: Function is missing a type annotation [no-untyped-def] @@ -551,28 +752,6 @@ apps/timesheet/management/commands/create_overtime_entries.py:0: error: Item "Co apps/timesheet/management/commands/create_overtime_entries.py:0: error: Item "Staff" of "str | Any | date | Decimal | CostSet | Staff" has no attribute "startswith" [union-attr] apps/timesheet/management/commands/create_overtime_entries.py:0: error: Invalid index type "str | Any | date | Decimal | CostSet | Staff" for "dict[str, int]"; expected type "str" [index] apps/timesheet/management/commands/create_overtime_entries.py:0: error: Missing type arguments for generic type "dict" [type-arg] -apps/quoting/tests_utils.py:0: error: Function is missing a return type annotation [no-untyped-def] -apps/quoting/tests_utils.py:0: note: Use "-> None" if function does not return a value -apps/quoting/tests_utils.py:0: error: Function is missing a return type annotation [no-untyped-def] -apps/quoting/tests_utils.py:0: note: Use "-> None" if function does not return a value -apps/quoting/tests_utils.py:0: error: Function is missing a return type annotation [no-untyped-def] -apps/quoting/tests_utils.py:0: note: Use "-> None" if function does not return a value -apps/quoting/tests_utils.py:0: error: Function is missing a return type annotation [no-untyped-def] -apps/quoting/tests_utils.py:0: note: Use "-> None" if function does not return a value -apps/quoting/tests_utils.py:0: error: Function is missing a return type annotation [no-untyped-def] -apps/quoting/tests_utils.py:0: note: Use "-> None" if function does not return a value -apps/quoting/tests_utils.py:0: error: Function is missing a return type annotation [no-untyped-def] -apps/quoting/tests_utils.py:0: note: Use "-> None" if function does not return a value -apps/quoting/tests_utils.py:0: error: Function is missing a return type annotation [no-untyped-def] -apps/quoting/tests_utils.py:0: note: Use "-> None" if function does not return a value -apps/quoting/tests_utils.py:0: error: Function is missing a return type annotation [no-untyped-def] -apps/quoting/tests_utils.py:0: note: Use "-> None" if function does not return a value -apps/quoting/tests_utils.py:0: error: Function is missing a return type annotation [no-untyped-def] -apps/quoting/tests_utils.py:0: note: Use "-> None" if function does not return a value -apps/quoting/tests_utils.py:0: error: Function is missing a return type annotation [no-untyped-def] -apps/quoting/tests_utils.py:0: note: Use "-> None" if function does not return a value -apps/quoting/tests_utils.py:0: error: Function is missing a return type annotation [no-untyped-def] -apps/quoting/tests_utils.py:0: note: Use "-> None" if function does not return a value apps/quoting/mcp.py:0: error: Class cannot subclass "ModelQueryToolset" (has type "Any") [misc] apps/quoting/mcp.py:0: error: Function is missing a return type annotation [no-untyped-def] apps/quoting/mcp.py:0: error: Class cannot subclass "MCPToolset" (has type "Any") [misc] @@ -585,34 +764,12 @@ apps/quoting/mcp.py:0: note: Use https://github.com/hauntsaninja/no_implicit_opt apps/quoting/mcp.py:0: error: Incompatible default for parameter "labor_hours" (default has type "None", parameter has type "float") [assignment] apps/quoting/mcp.py:0: note: PEP 484 prohibits implicit Optional. Accordingly, mypy has changed its default to no_implicit_optional=True apps/quoting/mcp.py:0: note: Use https://github.com/hauntsaninja/no_implicit_optional to automatically upgrade your codebase -apps/quoting/mcp.py:0: error: Item "None" of "Client | None" has no attribute "name" [union-attr] +apps/quoting/mcp.py:0: error: Item "None" of "Company | None" has no attribute "name" [union-attr] apps/quoting/mcp.py:0: error: Incompatible types in assignment (expression has type "float", variable has type "int") [assignment] apps/quoting/mcp.py:0: error: Incompatible default for parameter "supplier_name" (default has type "None", parameter has type "str") [assignment] apps/quoting/mcp.py:0: note: PEP 484 prohibits implicit Optional. Accordingly, mypy has changed its default to no_implicit_optional=True apps/quoting/mcp.py:0: note: Use https://github.com/hauntsaninja/no_implicit_optional to automatically upgrade your codebase apps/quoting/mcp.py:0: error: Need type annotation for "supplier_prices" (hint: "supplier_prices: dict[, ] = ...") [var-annotated] -apps/process/tests/test_procedure_model.py:0: error: Function is missing a return type annotation [no-untyped-def] -apps/process/tests/test_procedure_model.py:0: note: Use "-> None" if function does not return a value -apps/process/tests/test_procedure_model.py:0: error: Function is missing a return type annotation [no-untyped-def] -apps/process/tests/test_procedure_model.py:0: note: Use "-> None" if function does not return a value -apps/process/tests/test_procedure_model.py:0: error: Item "None" of "Procedure | None" has no attribute "title" [union-attr] -apps/process/tests/test_procedure_model.py:0: error: Function is missing a return type annotation [no-untyped-def] -apps/process/tests/test_procedure_model.py:0: note: Use "-> None" if function does not return a value -apps/process/tests/test_procedure_model.py:0: error: Function is missing a return type annotation [no-untyped-def] -apps/process/tests/test_procedure_model.py:0: note: Use "-> None" if function does not return a value -apps/process/tests/test_form_model.py:0: error: Function is missing a return type annotation [no-untyped-def] -apps/process/tests/test_form_model.py:0: note: Use "-> None" if function does not return a value -apps/process/tests/test_form_model.py:0: error: Function is missing a return type annotation [no-untyped-def] -apps/process/tests/test_form_model.py:0: note: Use "-> None" if function does not return a value -apps/process/tests/test_form_model.py:0: error: Function is missing a return type annotation [no-untyped-def] -apps/process/tests/test_form_model.py:0: note: Use "-> None" if function does not return a value -apps/process/tests/test_form_model.py:0: error: Function is missing a return type annotation [no-untyped-def] -apps/process/tests/test_form_model.py:0: note: Use "-> None" if function does not return a value -apps/process/tests/test_form_model.py:0: error: Function is missing a type annotation [no-untyped-def] -apps/process/tests/test_form_model.py:0: error: Function is missing a return type annotation [no-untyped-def] -apps/process/tests/test_form_model.py:0: note: Use "-> None" if function does not return a value -apps/process/services/form_service.py:0: error: Missing type arguments for generic type "dict" [type-arg] -apps/process/services/form_service.py:0: error: Function is missing a type annotation for one or more parameters [no-untyped-def] apps/operations/services/scheduler_service.py:0: error: Missing type arguments for generic type "frozenset" [type-arg] apps/operations/services/scheduler_service.py:0: error: Function is missing a type annotation for one or more parameters [no-untyped-def] apps/operations/services/scheduler_service.py:0: error: Missing type arguments for generic type "frozenset" [type-arg] @@ -622,25 +779,6 @@ apps/operations/services/scheduler_service.py:0: error: Invalid index type "UUID apps/operations/services/scheduler_service.py:0: error: Invalid index type "UUID" for "dict[int, float]"; expected type "int" [index] apps/operations/services/scheduler_service.py:0: error: Invalid index type "UUID" for "dict[int, float]"; expected type "int" [index] apps/operations/services/scheduler_service.py:0: error: Missing type arguments for generic type "frozenset" [type-arg] -apps/job/services/workshop_service.py:0: error: Function is missing a return type annotation [no-untyped-def] -apps/job/services/workshop_service.py:0: error: Function is missing a type annotation for one or more parameters [no-untyped-def] -apps/job/services/workshop_service.py:0: error: Missing type arguments for generic type "dict" [type-arg] -apps/job/services/workshop_service.py:0: error: Function is missing a type annotation for one or more parameters [no-untyped-def] -apps/job/services/workshop_service.py:0: error: Call to untyped function "_format_time" of "WorkshopTimesheetService" in typed context [no-untyped-call] -apps/job/services/workshop_service.py:0: error: Call to untyped function "_format_time" of "WorkshopTimesheetService" in typed context [no-untyped-call] -apps/job/services/workshop_service.py:0: error: Function is missing a type annotation for one or more parameters [no-untyped-def] -apps/job/services/workshop_service.py:0: error: Call to untyped function "_format_time" of "WorkshopTimesheetService" in typed context [no-untyped-call] -apps/job/services/workshop_service.py:0: error: Call to untyped function "_format_time" of "WorkshopTimesheetService" in typed context [no-untyped-call] -apps/job/services/workshop_service.py:0: error: Call to untyped function "save" in typed context [no-untyped-call] -apps/job/services/workshop_service.py:0: error: Call to untyped function "delete" in typed context [no-untyped-call] -apps/job/services/workshop_service.py:0: error: Missing type arguments for generic type "dict" [type-arg] -apps/job/services/workshop_service.py:0: error: Function is missing a return type annotation [no-untyped-def] -apps/job/services/workshop_service.py:0: error: Call to untyped function "get_default_cost_set_summary" in typed context [no-untyped-call] -apps/job/services/workshop_service.py:0: error: Function is missing a return type annotation [no-untyped-def] -apps/job/services/workshop_service.py:0: error: Function is missing a type annotation for one or more parameters [no-untyped-def] -apps/job/services/workshop_service.py:0: error: Function is missing a return type annotation [no-untyped-def] -apps/job/services/workshop_service.py:0: error: Function is missing a type annotation for one or more parameters [no-untyped-def] -apps/job/services/workshop_service.py:0: error: Function is missing a type annotation [no-untyped-def] apps/job/services/job_profitability_report.py:0: error: Call to untyped function "_get_queryset" in typed context [no-untyped-call] apps/job/services/job_profitability_report.py:0: error: Function is missing a return type annotation [no-untyped-def] apps/job/services/job_profitability_report.py:0: error: Function is missing a type annotation for one or more parameters [no-untyped-def] @@ -702,20 +840,8 @@ apps/workflow/services/db_scrubber.py:0: error: Argument 1 to "add" of "set" has apps/job/services/data_integrity_service.py:0: error: Missing return statement [return] apps/job/services/data_integrity_service.py:0: error: Dict entry 4 has incompatible type "str": "list[str]"; expected "str": "str" [dict-item] apps/job/services/data_integrity_service.py:0: error: Argument 1 to "join" has incompatible type "str | None"; expected "str" [arg-type] -apps/client/services/client_merge_service.py:0: error: Function is missing a type annotation for one or more parameters [no-untyped-def] apps/accounting/services/wip_service.py:0: error: Argument "key" to "sorted" has incompatible type "Callable[[dict[str, object]], object]"; expected "Callable[[dict[str, object]], SupportsDunderLT[Any] | SupportsDunderGT[Any]]" [arg-type] apps/accounting/services/wip_service.py:0: error: Incompatible return value type (got "object", expected "SupportsDunderLT[Any] | SupportsDunderGT[Any]") [return-value] -apps/accounts/utils.py:0: error: Missing type arguments for generic type "QuerySet" [type-arg] -apps/accounts/utils.py:0: error: Function is missing a return type annotation [no-untyped-def] -apps/accounts/utils.py:0: error: Missing type arguments for generic type "dict" [type-arg] -apps/workflow/tests/test_dev_demo_export.py:0: error: Function is missing a return type annotation [no-untyped-def] -apps/workflow/tests/test_dev_demo_export.py:0: note: Use "-> None" if function does not return a value -apps/workflow/tests/test_dev_demo_export.py:0: error: Function is missing a return type annotation [no-untyped-def] -apps/workflow/tests/test_dev_demo_export.py:0: note: Use "-> None" if function does not return a value -apps/workflow/tests/test_dev_demo_export.py:0: error: Function is missing a type annotation [no-untyped-def] -apps/workflow/tests/test_dev_demo_export.py:0: error: Function is missing a type annotation [no-untyped-def] -apps/workflow/tests/test_dev_demo_export.py:0: error: Function is missing a type annotation [no-untyped-def] -apps/workflow/management/commands/sync_sequences.py:0: error: Function is missing a type annotation [no-untyped-def] apps/workflow/management/commands/e2e_cleanup.py:0: error: Function is missing a type annotation [no-untyped-def] apps/workflow/management/commands/e2e_cleanup.py:0: error: Function is missing a type annotation [no-untyped-def] apps/workflow/management/commands/e2e_cleanup.py:0: error: Call to untyped function "_report_queryset" in typed context [no-untyped-call] @@ -734,10 +860,62 @@ apps/workflow/management/commands/e2e_cleanup.py:0: error: Call to untyped funct apps/workflow/management/commands/e2e_cleanup.py:0: error: Call to untyped function "_report_queryset" in typed context [no-untyped-call] apps/workflow/management/commands/e2e_cleanup.py:0: error: Call to untyped function "_report_queryset" in typed context [no-untyped-call] apps/workflow/management/commands/e2e_cleanup.py:0: error: Function is missing a type annotation [no-untyped-def] -apps/workflow/views/xero/xero_base_manager.py:0: error: Function is missing a type annotation for one or more parameters [no-untyped-def] -apps/workflow/views/xero/xero_base_manager.py:0: error: Function is missing a return type annotation [no-untyped-def] -apps/workflow/views/xero/xero_base_manager.py:0: note: Use "-> None" if function does not return a value -apps/workflow/views/xero/xero_base_manager.py:0: error: Call to untyped function "validate_for_xero" in typed context [no-untyped-call] +apps/quoting/services/ai_price_extraction.py:0: error: Incompatible default for parameter "model_name" (default has type "None", parameter has type "str") [assignment] +apps/quoting/services/ai_price_extraction.py:0: note: PEP 484 prohibits implicit Optional. Accordingly, mypy has changed its default to no_implicit_optional=True +apps/quoting/services/ai_price_extraction.py:0: note: Use https://github.com/hauntsaninja/no_implicit_optional to automatically upgrade your codebase +apps/quoting/services/ai_price_extraction.py:0: error: Incompatible return value type (got "MistralPriceExtractionProvider", expected "PriceExtractionProvider") [return-value] +apps/quoting/services/ai_price_extraction.py:0: error: Incompatible return value type (got "GeminiPriceExtractionProvider", expected "PriceExtractionProvider") [return-value] +apps/quoting/services/ai_price_extraction.py:0: error: Function is missing a return type annotation [no-untyped-def] +apps/quoting/services/ai_price_extraction.py:0: error: No overload variant of "get" of "dict" matches argument types "str", "int" [call-overload] +apps/quoting/services/ai_price_extraction.py:0: note: Possible overload variants: +apps/quoting/services/ai_price_extraction.py:0: note: def get(self, AIProviderTypes, None = ..., /) -> int | None +apps/quoting/services/ai_price_extraction.py:0: note: def get(self, AIProviderTypes, int, /) -> int +apps/quoting/services/ai_price_extraction.py:0: note: def [_T] get(self, AIProviderTypes, _T, /) -> int | _T +apps/quoting/services/ai_price_extraction.py:0: error: Call to untyped function "get_prioritized_active_providers" in typed context [no-untyped-call] +apps/process/services/safety_ai_service.py:0: error: Function is missing a return type annotation [no-untyped-def] +apps/process/services/safety_ai_service.py:0: note: Use "-> None" if function does not return a value +apps/process/services/safety_ai_service.py:0: error: Missing type arguments for generic type "dict" [type-arg] +apps/process/services/safety_ai_service.py:0: error: Item "str" of "dict[Any, Any] | str" has no attribute "setdefault" [union-attr] +apps/process/services/safety_ai_service.py:0: error: Item "str" of "dict[Any, Any] | str" has no attribute "setdefault" [union-attr] +apps/process/services/safety_ai_service.py:0: error: Item "str" of "dict[Any, Any] | str" has no attribute "setdefault" [union-attr] +apps/process/services/safety_ai_service.py:0: error: Item "str" of "dict[Any, Any] | str" has no attribute "setdefault" [union-attr] +apps/process/services/safety_ai_service.py:0: error: Item "str" of "dict[Any, Any] | str" has no attribute "setdefault" [union-attr] +apps/process/services/safety_ai_service.py:0: error: Item "str" of "dict[Any, Any] | str" has no attribute "setdefault" [union-attr] +apps/process/services/safety_ai_service.py:0: error: Incompatible return value type (got "dict[Any, Any] | str", expected "dict[str, Any]") [return-value] +apps/process/services/safety_ai_service.py:0: error: Item "str" of "dict[Any, Any] | str" has no attribute "setdefault" [union-attr] +apps/process/services/safety_ai_service.py:0: error: Item "str" of "dict[Any, Any] | str" has no attribute "setdefault" [union-attr] +apps/process/services/safety_ai_service.py:0: error: Item "str" of "dict[Any, Any] | str" has no attribute "setdefault" [union-attr] +apps/process/services/safety_ai_service.py:0: error: Item "str" of "dict[Any, Any] | str" has no attribute "setdefault" [union-attr] +apps/process/services/safety_ai_service.py:0: error: Item "str" of "dict[Any, Any] | str" has no attribute "setdefault" [union-attr] +apps/process/services/safety_ai_service.py:0: error: Item "str" of "dict[Any, Any] | str" has no attribute "setdefault" [union-attr] +apps/process/services/safety_ai_service.py:0: error: Incompatible return value type (got "dict[Any, Any] | str", expected "dict[str, Any]") [return-value] +apps/process/services/safety_ai_service.py:0: error: Item "str" of "dict[Any, Any] | str" has no attribute "setdefault" [union-attr] +apps/process/services/safety_ai_service.py:0: error: Item "str" of "dict[Any, Any] | str" has no attribute "setdefault" [union-attr] +apps/process/services/safety_ai_service.py:0: error: Item "str" of "dict[Any, Any] | str" has no attribute "setdefault" [union-attr] +apps/process/services/safety_ai_service.py:0: error: Item "str" of "dict[Any, Any] | str" has no attribute "setdefault" [union-attr] +apps/process/services/safety_ai_service.py:0: error: Item "str" of "dict[Any, Any] | str" has no attribute "setdefault" [union-attr] +apps/process/services/safety_ai_service.py:0: error: Item "str" of "dict[Any, Any] | str" has no attribute "setdefault" [union-attr] +apps/process/services/safety_ai_service.py:0: error: Incompatible return value type (got "dict[Any, Any] | str", expected "dict[str, Any]") [return-value] +apps/process/services/safety_ai_service.py:0: error: Returning Any from function declared to return "list[str]" [no-any-return] +apps/process/services/safety_ai_service.py:0: error: Item "str" of "dict[Any, Any] | str" has no attribute "get" [union-attr] +apps/process/services/safety_ai_service.py:0: error: Returning Any from function declared to return "list[dict[str, str]]" [no-any-return] +apps/process/services/safety_ai_service.py:0: error: Item "str" of "dict[Any, Any] | str" has no attribute "get" [union-attr] +apps/process/services/safety_ai_service.py:0: error: Returning Any from function declared to return "str" [no-any-return] +apps/process/services/safety_ai_service.py:0: error: Item "str" of "dict[Any, Any] | str" has no attribute "get" [union-attr] +apps/process/services/safety_ai_service.py:0: error: Item "str" of "dict[Any, Any] | str" has no attribute "setdefault" [union-attr] +apps/process/services/safety_ai_service.py:0: error: Item "str" of "dict[Any, Any] | str" has no attribute "setdefault" [union-attr] +apps/process/services/safety_ai_service.py:0: error: Item "str" of "dict[Any, Any] | str" has no attribute "setdefault" [union-attr] +apps/process/services/safety_ai_service.py:0: error: Item "str" of "dict[Any, Any] | str" has no attribute "setdefault" [union-attr] +apps/process/services/safety_ai_service.py:0: error: Item "str" of "dict[Any, Any] | str" has no attribute "setdefault" [union-attr] +apps/process/services/safety_ai_service.py:0: error: Item "str" of "dict[Any, Any] | str" has no attribute "setdefault" [union-attr] +apps/process/services/safety_ai_service.py:0: error: Incompatible return value type (got "dict[Any, Any] | str", expected "dict[str, Any]") [return-value] +apps/workflow/tests/test_dev_demo_export.py:0: error: Function is missing a return type annotation [no-untyped-def] +apps/workflow/tests/test_dev_demo_export.py:0: note: Use "-> None" if function does not return a value +apps/workflow/tests/test_dev_demo_export.py:0: error: Function is missing a return type annotation [no-untyped-def] +apps/workflow/tests/test_dev_demo_export.py:0: note: Use "-> None" if function does not return a value +apps/workflow/tests/test_dev_demo_export.py:0: error: Function is missing a type annotation [no-untyped-def] +apps/workflow/tests/test_dev_demo_export.py:0: error: Function is missing a type annotation [no-untyped-def] +apps/workflow/tests/test_dev_demo_export.py:0: error: Function is missing a type annotation [no-untyped-def] apps/process/tests/test_form_service.py:0: error: Function is missing a return type annotation [no-untyped-def] apps/process/tests/test_form_service.py:0: error: Function is missing a return type annotation [no-untyped-def] apps/process/tests/test_form_service.py:0: note: Use "-> None" if function does not return a value @@ -756,41 +934,6 @@ apps/process/tests/test_form_service.py:0: error: Call to untyped function "_mak apps/process/tests/test_form_service.py:0: error: Function is missing a return type annotation [no-untyped-def] apps/process/tests/test_form_service.py:0: note: Use "-> None" if function does not return a value apps/process/tests/test_form_service.py:0: error: Call to untyped function "_make_service" in typed context [no-untyped-call] -apps/job/services/quote_sync_service.py:0: error: Call to untyped function "_copy_estimate_to_quote_costset" in typed context [no-untyped-call] -apps/job/services/quote_sync_service.py:0: error: Function is missing a return type annotation [no-untyped-def] -apps/job/services/quote_sync_service.py:0: error: "" has no attribute "LABOR" [attr-defined] -apps/job/services/quote_sync_service.py:0: error: "" has no attribute "MATERIAL" [attr-defined] -apps/job/services/quote_sync_service.py:0: error: Argument "quantity" to "DraftLine" has incompatible type "float | int"; expected "Decimal" [arg-type] -apps/job/services/quote_sync_service.py:0: error: Argument "unit_cost" to "DraftLine" has incompatible type "float | int"; expected "Decimal" [arg-type] -apps/job/services/quote_sync_service.py:0: error: Function is missing a return type annotation [no-untyped-def] -apps/job/services/quote_sync_service.py:0: error: Function is missing a return type annotation [no-untyped-def] -apps/job/services/quote_sync_service.py:0: error: Function is missing a type annotation for one or more parameters [no-untyped-def] -apps/job/services/quote_sync_service.py:0: error: Function is missing a type annotation [no-untyped-def] -apps/workflow/api/xero/reprocess_xero.py:0: error: Function is missing a type annotation [no-untyped-def] -apps/workflow/api/xero/reprocess_xero.py:0: error: Item "None" of "type[InvoiceLineItem] | type[BillLineItem] | type[CreditNoteLineItem] | None" has no attribute "objects" [union-attr] -apps/workflow/api/xero/reprocess_xero.py:0: error: Argument after ** must have string keys [arg-type] -apps/workflow/api/xero/reprocess_xero.py:0: error: Unsupported operand types for + ("str" and "None") [operator] -apps/workflow/api/xero/reprocess_xero.py:0: note: Right operand is of type "Any | None" -apps/workflow/api/xero/reprocess_xero.py:0: error: Function is missing a return type annotation [no-untyped-def] -apps/workflow/api/xero/reprocess_xero.py:0: note: Use "-> None" if function does not return a value -apps/workflow/api/xero/reprocess_xero.py:0: error: Call to untyped function "set_invoice_or_bill_fields" in typed context [no-untyped-call] -apps/workflow/api/xero/reprocess_xero.py:0: error: Function is missing a return type annotation [no-untyped-def] -apps/workflow/api/xero/reprocess_xero.py:0: note: Use "-> None" if function does not return a value -apps/workflow/api/xero/reprocess_xero.py:0: error: Call to untyped function "set_invoice_or_bill_fields" in typed context [no-untyped-call] -apps/workflow/api/xero/reprocess_xero.py:0: error: Function is missing a return type annotation [no-untyped-def] -apps/workflow/api/xero/reprocess_xero.py:0: note: Use "-> None" if function does not return a value -apps/workflow/api/xero/reprocess_xero.py:0: error: Call to untyped function "set_invoice_or_bill_fields" in typed context [no-untyped-call] -apps/workflow/api/xero/reprocess_xero.py:0: error: Function is missing a return type annotation [no-untyped-def] -apps/workflow/api/xero/reprocess_xero.py:0: note: Use "-> None" if function does not return a value -apps/workflow/api/xero/reprocess_xero.py:0: error: Function is missing a return type annotation [no-untyped-def] -apps/workflow/api/xero/reprocess_xero.py:0: note: Use "-> None" if function does not return a value -apps/workflow/api/xero/reprocess_xero.py:0: error: Call to untyped function "reprocess_clients" in typed context [no-untyped-call] -apps/workflow/api/xero/reprocess_xero.py:0: error: Call to untyped function "reprocess_invoices" in typed context [no-untyped-call] -apps/workflow/api/xero/reprocess_xero.py:0: error: Call to untyped function "reprocess_bills" in typed context [no-untyped-call] -apps/workflow/api/xero/reprocess_xero.py:0: error: Call to untyped function "reprocess_credit_notes" in typed context [no-untyped-call] -apps/client/management/commands/merge_clients.py:0: error: Function is missing a type annotation [no-untyped-def] -apps/client/management/commands/merge_clients.py:0: error: Function is missing a type annotation [no-untyped-def] -apps/workflow/utils.py:0: error: Function is missing a type annotation for one or more parameters [no-untyped-def] apps/timesheet/services/weekly_timesheet_service.py:0: error: Need type annotation for "lines_by_staff_day" (hint: "lines_by_staff_day: dict[, ] = ...") [var-annotated] apps/timesheet/services/weekly_timesheet_service.py:0: error: Missing type arguments for generic type "list" [type-arg] apps/timesheet/services/daily_timesheet_service.py:0: error: Function is missing a type annotation [no-untyped-def] @@ -813,17 +956,17 @@ apps/timesheet/services/daily_timesheet_service.py:0: error: Missing type argume apps/timesheet/services/daily_timesheet_service.py:0: error: Argument 1 to "_calculate_percentage" of "DailyTimesheetService" has incompatible type "int"; expected "Decimal" [arg-type] apps/timesheet/services/daily_timesheet_service.py:0: error: Argument 2 to "_calculate_percentage" of "DailyTimesheetService" has incompatible type "int"; expected "Decimal" [arg-type] apps/accounting/services/core.py:0: error: Function is missing a type annotation for one or more parameters [no-untyped-def] -apps/accounting/services/core.py:0: error: Item "None" of "Client | None" has no attribute "name" [union-attr] +apps/accounting/services/core.py:0: error: Item "None" of "Company | None" has no attribute "name" [union-attr] apps/accounting/services/core.py:0: error: Unsupported operand types for + ("str" and "float") [operator] apps/accounting/services/core.py:0: note: Left operand is of type "int | str | Any" apps/accounting/services/core.py:0: error: Unsupported operand types for + ("str" and "float") [operator] apps/accounting/services/core.py:0: note: Left operand is of type "int | str | Any" -apps/accounting/services/core.py:0: error: Item "None" of "Client | None" has no attribute "name" [union-attr] +apps/accounting/services/core.py:0: error: Item "None" of "Company | None" has no attribute "name" [union-attr] apps/accounting/services/core.py:0: error: Unsupported operand types for + ("str" and "float") [operator] apps/accounting/services/core.py:0: note: Left operand is of type "int | str | Any" apps/accounting/services/core.py:0: error: Unsupported operand types for + ("str" and "float") [operator] apps/accounting/services/core.py:0: note: Left operand is of type "int | str | Any" -apps/accounting/services/core.py:0: error: Item "None" of "Client | None" has no attribute "name" [union-attr] +apps/accounting/services/core.py:0: error: Item "None" of "Company | None" has no attribute "name" [union-attr] apps/accounting/services/core.py:0: error: Unsupported operand types for + ("str" and "float") [operator] apps/accounting/services/core.py:0: note: Left operand is of type "int | str | Any" apps/accounting/services/core.py:0: error: Unsupported operand types for + ("str" and "float") [operator] @@ -918,6 +1061,25 @@ apps/accounting/services/core.py:0: error: Unsupported left operand type for / ( apps/accounting/services/core.py:0: note: Both left and right operands are unions apps/accounting/services/core.py:0: error: Unsupported operand types for > ("str" and "int") [operator] apps/accounting/services/core.py:0: note: Left operand is of type "float | Any | str" +apps/job/services/kanban_service.py:0: error: Incompatible types in assignment (expression has type "list[Job]", variable has type "JobQuerySet") [assignment] +apps/job/services/kanban_service.py:0: error: Argument 1 to "_rank_kanban_search_candidates" of "KanbanService" has incompatible type "list[Job@AnnotatedWith[TypedDict({'trigram_score': Any})]]"; expected "list[Job]" [arg-type] +apps/job/services/kanban_service.py:0: note: "list" is invariant -- see https://mypy.readthedocs.io/en/stable/common_issues.html#variance +apps/job/services/kanban_service.py:0: note: Consider using "Sequence" instead, which is covariant +apps/job/services/kanban_service.py:0: error: Item "None" of "Company | None" has no attribute "name" [union-attr] +apps/job/services/kanban_service.py:0: error: Item "None" of "Company | None" has no attribute "name" [union-attr] +apps/job/services/kanban_service.py:0: error: Value of type "list[dict[str, int | str | Any | None]] | int | dict[str, Any] | str | Any | None" is not indexable [index] +apps/job/services/kanban_service.py:0: error: Invalid index type "slice[None, int, None]" for "dict[str, Any]"; expected type "str" [index] +apps/job/services/kanban_service.py:0: error: Call to untyped function "filter_kanban_jobs" of "KanbanService" in typed context [no-untyped-call] +apps/job/services/kanban_service.py:0: error: Returning Any from function declared to return "QuerySet[Job, Job]" [no-any-return] +apps/job/services/kanban_service.py:0: error: Function is missing a type annotation for one or more parameters [no-untyped-def] +apps/job/services/kanban_service.py:0: error: Function is missing a type annotation for one or more parameters [no-untyped-def] +apps/job/services/kanban_service.py:0: error: Function is missing a type annotation for one or more parameters [no-untyped-def] +apps/job/services/kanban_service.py:0: error: Returning Any from function declared to return "float" [no-any-return] +apps/job/services/kanban_service.py:0: error: Returning Any from function declared to return "float" [no-any-return] +apps/job/services/kanban_service.py:0: error: Function is missing a type annotation for one or more parameters [no-untyped-def] +apps/job/services/kanban_service.py:0: error: Function is missing a type annotation for one or more parameters [no-untyped-def] +apps/job/services/kanban_service.py:0: error: Call to untyped function "filter_kanban_jobs" of "KanbanService" in typed context [no-untyped-call] +apps/job/services/kanban_service.py:0: error: Function is missing a type annotation [no-untyped-def] apps/testing.py:0: error: Function is missing a return type annotation [no-untyped-def] apps/testing.py:0: note: Use "-> None" if function does not return a value apps/testing.py:0: error: Function is missing a return type annotation [no-untyped-def] @@ -983,13 +1145,6 @@ apps/workflow/serializers.py:0: error: Missing type arguments for generic type " apps/workflow/serializers.py:0: error: Missing type arguments for generic type "Serializer" [type-arg] apps/workflow/serializers.py:0: error: Missing type arguments for generic type "Serializer" [type-arg] apps/workflow/serializers.py:0: error: Missing type arguments for generic type "Serializer" [type-arg] -apps/workflow/search_telemetry_serializers.py:0: error: Missing type arguments for generic type "Serializer" [type-arg] -apps/workflow/search_telemetry_serializers.py:0: error: Incompatible types in assignment (expression has type "CharField", base class "Field" defined the type as "Callable[..., Any] | str | None") [assignment] -apps/workflow/search_telemetry_serializers.py:0: error: Missing type arguments for generic type "Serializer" [type-arg] -apps/workflow/exception_handlers.py:0: error: Missing type arguments for generic type "dict" [type-arg] -apps/workflow/exception_handlers.py:0: error: Argument "user_id" to "persist_app_error" has incompatible type "str | None"; expected "str" [arg-type] -apps/workflow/exception_handlers.py:0: error: Argument "session_replay_id" to "persist_app_error" has incompatible type "Any | None"; expected "str" [arg-type] -apps/workflow/exception_handlers.py:0: error: Argument "additional_context" to "persist_app_error" has incompatible type "dict[str, Any] | None"; expected "dict[Any, Any]" [arg-type] apps/workflow/tests/test_xero_app_api.py:0: error: Function is missing a type annotation [no-untyped-def] apps/workflow/tests/test_xero_app_api.py:0: error: Function is missing a type annotation [no-untyped-def] apps/workflow/tests/test_xero_app_api.py:0: error: Function is missing a return type annotation [no-untyped-def] @@ -1138,30 +1293,12 @@ apps/workflow/tests/test_app_error_grouped_view.py:0: error: Function is missing apps/workflow/tests/test_app_error_grouped_view.py:0: error: Function is missing a type annotation [no-untyped-def] apps/workflow/tests/test_app_error_grouped_view.py:0: error: Function is missing a type annotation [no-untyped-def] apps/workflow/tests/test_app_error_grouped_view.py:0: error: Function is missing a type annotation [no-untyped-def] -apps/timesheet/serializers/payroll_serializers.py:0: error: Missing type arguments for generic type "Serializer" [type-arg] -apps/timesheet/serializers/payroll_serializers.py:0: error: Missing type arguments for generic type "Serializer" [type-arg] -apps/timesheet/serializers/payroll_serializers.py:0: error: Missing type arguments for generic type "Serializer" [type-arg] -apps/timesheet/serializers/payroll_serializers.py:0: error: Missing type arguments for generic type "Serializer" [type-arg] -apps/timesheet/serializers/payroll_serializers.py:0: error: Missing type arguments for generic type "Serializer" [type-arg] -apps/timesheet/serializers/payroll_serializers.py:0: error: Incompatible types in assignment (expression has type "ListField", base class "Serializer" defined the type as "ReturnDict[Any, Any]") [assignment] -apps/timesheet/serializers/payroll_serializers.py:0: error: Missing type arguments for generic type "Serializer" [type-arg] -apps/timesheet/serializers/payroll_serializers.py:0: error: Missing type arguments for generic type "Serializer" [type-arg] -apps/timesheet/serializers/payroll_serializers.py:0: error: Missing type arguments for generic type "Serializer" [type-arg] apps/timesheet/serializers/daily_timesheet_serializers.py:0: error: Missing type arguments for generic type "Serializer" [type-arg] apps/timesheet/serializers/daily_timesheet_serializers.py:0: error: Missing type arguments for generic type "Serializer" [type-arg] apps/timesheet/serializers/daily_timesheet_serializers.py:0: error: Missing type arguments for generic type "Serializer" [type-arg] apps/timesheet/serializers/daily_timesheet_serializers.py:0: error: Missing type arguments for generic type "Serializer" [type-arg] apps/timesheet/serializers/daily_timesheet_serializers.py:0: error: Missing type arguments for generic type "Serializer" [type-arg] apps/timesheet/serializers/daily_timesheet_serializers.py:0: error: Missing type arguments for generic type "Serializer" [type-arg] -apps/quoting/serializers_scheduled_tasks.py:0: error: Missing type arguments for generic type "ModelSerializer" [type-arg] -apps/quoting/serializers_scheduled_tasks.py:0: error: Missing type arguments for generic type "ModelSerializer" [type-arg] -apps/quoting/serializers.py:0: error: Missing type arguments for generic type "Serializer" [type-arg] -apps/quoting/serializers.py:0: error: Missing type arguments for generic type "Serializer" [type-arg] -apps/quoting/serializers.py:0: error: Missing type arguments for generic type "Serializer" [type-arg] -apps/quoting/serializers.py:0: error: Missing type arguments for generic type "Serializer" [type-arg] -apps/quoting/serializers.py:0: error: Missing type arguments for generic type "Serializer" [type-arg] -apps/quoting/serializers.py:0: error: Missing type arguments for generic type "Serializer" [type-arg] -apps/quoting/serializers.py:0: error: Missing type arguments for generic type "Serializer" [type-arg] apps/purchasing/tests/test_supplier_search.py:0: error: Function is missing a return type annotation [no-untyped-def] apps/purchasing/tests/test_supplier_search.py:0: error: Function is missing a type annotation for one or more parameters [no-untyped-def] apps/purchasing/tests/test_supplier_search.py:0: error: Function is missing a return type annotation [no-untyped-def] @@ -1222,31 +1359,17 @@ apps/process/tests/test_form_api.py:0: error: Function is missing a type annotat apps/process/tests/test_form_api.py:0: error: Function is missing a type annotation [no-untyped-def] apps/process/tests/test_form_api.py:0: error: Function is missing a type annotation [no-untyped-def] apps/process/tests/test_form_api.py:0: error: Function is missing a type annotation [no-untyped-def] -apps/process/serializers/procedure_serializer.py:0: error: Missing type arguments for generic type "Serializer" [type-arg] -apps/process/serializers/procedure_serializer.py:0: error: Missing type arguments for generic type "ModelSerializer" [type-arg] -apps/process/serializers/procedure_serializer.py:0: error: Missing type arguments for generic type "ModelSerializer" [type-arg] -apps/process/serializers/procedure_serializer.py:0: error: Missing type arguments for generic type "Serializer" [type-arg] -apps/process/serializers/procedure_serializer.py:0: error: Missing type arguments for generic type "ModelSerializer" [type-arg] -apps/process/serializers/procedure_serializer.py:0: error: Missing type arguments for generic type "Serializer" [type-arg] -apps/process/serializers/form_serializer.py:0: error: Missing type arguments for generic type "ModelSerializer" [type-arg] -apps/process/serializers/form_serializer.py:0: error: Missing type arguments for generic type "ModelSerializer" [type-arg] -apps/process/serializers/form_serializer.py:0: error: Missing type arguments for generic type "ModelSerializer" [type-arg] -apps/process/serializers/form_serializer.py:0: error: Missing type arguments for generic type "Serializer" [type-arg] -apps/process/serializers/form_serializer.py:0: error: Missing type arguments for generic type "ModelSerializer" [type-arg] apps/operations/serializers/workshop_schedule_serializer.py:0: error: Missing type arguments for generic type "Serializer" [type-arg] apps/operations/serializers/workshop_schedule_serializer.py:0: error: Missing type arguments for generic type "Serializer" [type-arg] apps/operations/serializers/workshop_schedule_serializer.py:0: error: Missing type arguments for generic type "Serializer" [type-arg] apps/operations/serializers/workshop_schedule_serializer.py:0: error: Missing type arguments for generic type "Serializer" [type-arg] apps/operations/serializers/workshop_schedule_serializer.py:0: error: Missing type arguments for generic type "Serializer" [type-arg] apps/operations/serializers/workshop_schedule_serializer.py:0: error: Missing type arguments for generic type "Serializer" [type-arg] -apps/job/permissions.py:0: error: Function is missing a type annotation [no-untyped-def] -apps/job/permissions.py:0: error: Function is missing a type annotation [no-untyped-def] apps/job/tests/test_job_delta_rejection_grouped_view.py:0: error: Function is missing a type annotation [no-untyped-def] apps/job/tests/test_job_delta_rejection_grouped_view.py:0: error: Function is missing a type annotation [no-untyped-def] apps/job/tests/test_job_delta_rejection_grouped_view.py:0: error: Function is missing a type annotation [no-untyped-def] apps/job/tests/test_job_delta_rejection_grouped_view.py:0: error: Function is missing a type annotation [no-untyped-def] apps/job/tests/test_job_delta_rejection_grouped_view.py:0: error: Function is missing a type annotation [no-untyped-def] -apps/job/serializers/quote_spreadsheet_serializer.py:0: error: Missing type arguments for generic type "ModelSerializer" [type-arg] apps/job/serializers/kanban_serializer.py:0: error: Missing type arguments for generic type "Serializer" [type-arg] apps/job/serializers/kanban_serializer.py:0: error: Function is missing a type annotation [no-untyped-def] apps/job/serializers/kanban_serializer.py:0: error: Missing type arguments for generic type "Serializer" [type-arg] @@ -1301,144 +1424,20 @@ apps/job/serializers/job_file_serializer.py:0: error: Missing type arguments for apps/job/serializers/data_quality_report_serializers.py:0: error: Missing type arguments for generic type "Serializer" [type-arg] apps/job/serializers/data_quality_report_serializers.py:0: error: Missing type arguments for generic type "Serializer" [type-arg] apps/job/serializers/data_quality_report_serializers.py:0: error: Missing type arguments for generic type "Serializer" [type-arg] -apps/job/serializers/data_integrity_serializers.py:0: error: Missing type arguments for generic type "Serializer" [type-arg] -apps/job/serializers/data_integrity_serializers.py:0: error: Missing type arguments for generic type "Serializer" [type-arg] -apps/job/serializers/data_integrity_serializers.py:0: error: Missing type arguments for generic type "Serializer" [type-arg] -apps/job/serializers/data_integrity_serializers.py:0: error: Missing type arguments for generic type "Serializer" [type-arg] -apps/job/serializers/data_integrity_serializers.py:0: error: Missing type arguments for generic type "Serializer" [type-arg] -apps/client/serializers.py:0: error: Missing type arguments for generic type "ModelSerializer" [type-arg] -apps/client/serializers.py:0: error: Function is missing a type annotation [no-untyped-def] -apps/client/serializers.py:0: error: Missing type arguments for generic type "ModelSerializer" [type-arg] -apps/client/serializers.py:0: error: Function is missing a type annotation [no-untyped-def] -apps/client/serializers.py:0: error: Missing type arguments for generic type "ModelSerializer" [type-arg] -apps/client/serializers.py:0: error: Function is missing a type annotation [no-untyped-def] -apps/client/serializers.py:0: error: Missing type arguments for generic type "ModelSerializer" [type-arg] -apps/client/serializers.py:0: error: Missing type arguments for generic type "ModelSerializer" [type-arg] -apps/client/serializers.py:0: error: Missing type arguments for generic type "Serializer" [type-arg] -apps/client/serializers.py:0: error: Missing type arguments for generic type "Serializer" [type-arg] -apps/client/serializers.py:0: error: Missing type arguments for generic type "Serializer" [type-arg] -apps/client/serializers.py:0: error: Missing type arguments for generic type "Serializer" [type-arg] -apps/client/serializers.py:0: error: Missing type arguments for generic type "ModelSerializer" [type-arg] -apps/client/serializers.py:0: error: Missing type arguments for generic type "Serializer" [type-arg] -apps/client/serializers.py:0: error: Missing type arguments for generic type "Serializer" [type-arg] -apps/client/serializers.py:0: error: Missing type arguments for generic type "Serializer" [type-arg] -apps/client/serializers.py:0: error: Missing type arguments for generic type "Serializer" [type-arg] -apps/client/serializers.py:0: error: Missing type arguments for generic type "Serializer" [type-arg] -apps/client/serializers.py:0: error: Missing type arguments for generic type "Serializer" [type-arg] -apps/client/serializers.py:0: error: Missing type arguments for generic type "Serializer" [type-arg] -apps/client/serializers.py:0: error: Missing type arguments for generic type "Serializer" [type-arg] -apps/client/serializers.py:0: error: Missing type arguments for generic type "Serializer" [type-arg] -apps/client/serializers.py:0: error: Missing type arguments for generic type "Serializer" [type-arg] -apps/client/serializers.py:0: error: Missing type arguments for generic type "Serializer" [type-arg] apps/accounting/serializers/rdti_spend_serializers.py:0: error: Incompatible types in assignment (expression has type "CharField", base class "Field" defined the type as "str | _StrPromise | None") [assignment] -apps/workflow/views/xero/xero_quote_manager.py:0: error: Function is missing a type annotation [no-untyped-def] -apps/workflow/views/xero/xero_quote_manager.py:0: error: Function is missing a return type annotation [no-untyped-def] -apps/workflow/views/xero/xero_quote_manager.py:0: error: Item "None" of "Job | None" has no attribute "quote" [union-attr] -apps/workflow/views/xero/xero_quote_manager.py:0: error: Item "None" of "Job | None" has no attribute "quote" [union-attr] -apps/workflow/views/xero/xero_quote_manager.py:0: error: Function is missing a return type annotation [no-untyped-def] -apps/workflow/views/xero/xero_quote_manager.py:0: error: Item "None" of "Job | None" has no attribute "quoted" [union-attr] -apps/workflow/views/xero/xero_quote_manager.py:0: error: Function is missing a return type annotation [no-untyped-def] -apps/workflow/views/xero/xero_quote_manager.py:0: note: Use "-> None" if function does not return a value -apps/workflow/views/xero/xero_quote_manager.py:0: error: Item "None" of "Job | None" has no attribute "quoted" [union-attr] -apps/workflow/views/xero/xero_quote_manager.py:0: error: Item "None" of "Job | None" has no attribute "id" [union-attr] -apps/workflow/views/xero/xero_quote_manager.py:0: error: Argument "client_external_id" to "QuotePayload" has incompatible type "str | None"; expected "str" [arg-type] -apps/workflow/views/xero/xero_quote_manager.py:0: error: Function is missing a return type annotation [no-untyped-def] -apps/workflow/views/xero/xero_quote_manager.py:0: error: Call to untyped function "validate_client" in typed context [no-untyped-call] -apps/workflow/views/xero/xero_quote_manager.py:0: error: Call to untyped function "state_valid_for_xero" in typed context [no-untyped-call] -apps/workflow/views/xero/xero_quote_manager.py:0: error: Incompatible type for "xero_id" of "Quote" (got "str | None", expected "str | UUID") [misc] -apps/workflow/views/xero/xero_quote_manager.py:0: error: Item "None" of "Job | None" has no attribute "save" [union-attr] -apps/workflow/views/xero/xero_quote_manager.py:0: error: Item "None" of "Job | None" has no attribute "id" [union-attr] -apps/workflow/views/xero/xero_quote_manager.py:0: error: Argument 2 to "_add_xero_history_note" of "XeroDocumentManager" has incompatible type "str | None"; expected "str" [arg-type] -apps/workflow/views/xero/xero_quote_manager.py:0: error: Function is missing a return type annotation [no-untyped-def] -apps/workflow/views/xero/xero_quote_manager.py:0: error: Call to untyped function "validate_client" in typed context [no-untyped-call] -apps/workflow/views/xero/xero_quote_manager.py:0: error: Call to untyped function "get_xero_id" in typed context [no-untyped-call] -apps/workflow/views/xero/xero_quote_manager.py:0: error: Item "None" of "Job | None" has no attribute "quote" [union-attr] -apps/workflow/views/xero/xero_quote_manager.py:0: error: Item "None" of "Job | None" has no attribute "id" [union-attr] -apps/workflow/views/xero/xero_quote_manager.py:0: error: Item "None" of "Job | None" has no attribute "quote" [union-attr] -apps/workflow/views/xero/xero_quote_manager.py:0: error: Item "None" of "Job | None" has no attribute "quote" [union-attr] -apps/workflow/views/xero/xero_quote_manager.py:0: error: Item "None" of "Job | None" has no attribute "quote" [union-attr] -apps/workflow/views/xero/xero_quote_manager.py:0: error: Item "None" of "Job | None" has no attribute "id" [union-attr] -apps/workflow/views/xero/xero_quote_manager.py:0: error: Item "None" of "Job | None" has no attribute "save" [union-attr] -apps/workflow/views/xero/xero_po_manager.py:0: error: Argument 1 to "DocumentLineItem" has incompatible type "**dict[str, object]"; expected "str" [arg-type] -apps/workflow/views/xero/xero_po_manager.py:0: error: Argument 1 to "DocumentLineItem" has incompatible type "**dict[str, object]"; expected "Decimal" [arg-type] -apps/workflow/views/xero/xero_po_manager.py:0: error: Argument 1 to "DocumentLineItem" has incompatible type "**dict[str, object]"; expected "str | None" [arg-type] -apps/workflow/views/xero/xero_po_manager.py:0: error: Argument "supplier_external_id" to "POPayload" has incompatible type "str | None"; expected "str" [arg-type] -apps/workflow/views/xero/xero_po_manager.py:0: error: Function is missing a type annotation for one or more parameters [no-untyped-def] -apps/workflow/views/xero/xero_po_manager.py:0: error: Call to untyped function "save" in typed context [no-untyped-call] -apps/workflow/views/xero/xero_po_manager.py:0: error: Missing type arguments for generic type "dict" [type-arg] -apps/workflow/views/xero/xero_po_manager.py:0: error: Missing type arguments for generic type "dict" [type-arg] -apps/workflow/views/xero/xero_po_manager.py:0: error: Call to untyped function "validate_for_xero_sync" in typed context [no-untyped-call] -apps/workflow/views/xero/xero_po_manager.py:0: error: Missing type arguments for generic type "dict" [type-arg] -apps/workflow/views/xero/xero_po_manager.py:0: error: Call to untyped function "save" in typed context [no-untyped-call] -apps/workflow/views/xero/xero_po_manager.py:0: error: Function is missing a return type annotation [no-untyped-def] -apps/workflow/views/xero/xero_po_manager.py:0: note: Use "-> None" if function does not return a value -apps/workflow/views/xero/xero_invoice_manager.py:0: error: Function is missing a return type annotation [no-untyped-def] -apps/workflow/views/xero/xero_invoice_manager.py:0: error: Cannot resolve keyword 'created_at' into field. Choices are: amount_due, billing_metadata, client, client_id, date, django_created_at, django_updated_at, due_date, id, job, job_id, line_items, number, online_url, raw_json, status, tax, total_excl_tax, total_incl_tax, xero_id, xero_last_modified, xero_last_synced, xero_tenant_id [misc] -apps/workflow/views/xero/xero_invoice_manager.py:0: error: Function is missing a return type annotation [no-untyped-def] -apps/workflow/views/xero/xero_invoice_manager.py:0: error: Item "None" of "Job | None" has no attribute "paid" [union-attr] -apps/workflow/views/xero/xero_invoice_manager.py:0: error: Argument "client_external_id" to "InvoicePayload" has incompatible type "str | None"; expected "str" [arg-type] -apps/workflow/views/xero/xero_invoice_manager.py:0: error: Function is missing a return type annotation [no-untyped-def] -apps/workflow/views/xero/xero_invoice_manager.py:0: error: Missing type arguments for generic type "dict" [type-arg] -apps/workflow/views/xero/xero_invoice_manager.py:0: error: Call to untyped function "validate_client" in typed context [no-untyped-call] -apps/workflow/views/xero/xero_invoice_manager.py:0: error: Call to untyped function "state_valid_for_xero" in typed context [no-untyped-call] -apps/workflow/views/xero/xero_invoice_manager.py:0: error: Item "None" of "Job | None" has no attribute "save" [union-attr] -apps/workflow/views/xero/xero_invoice_manager.py:0: error: Item "None" of "Job | None" has no attribute "id" [union-attr] -apps/workflow/views/xero/xero_invoice_manager.py:0: error: Argument 2 to "_add_xero_history_note" of "XeroDocumentManager" has incompatible type "str | None"; expected "str" [arg-type] -apps/workflow/views/xero/xero_invoice_manager.py:0: error: Argument 1 to "_attach_workshop_pdf" of "XeroInvoiceManager" has incompatible type "str | None"; expected "str" [arg-type] -apps/workflow/views/xero/xero_invoice_manager.py:0: error: Missing type arguments for generic type "dict" [type-arg] -apps/workflow/views/xero/xero_invoice_manager.py:0: error: Function is missing a return type annotation [no-untyped-def] -apps/workflow/views/xero/xero_invoice_manager.py:0: error: Call to untyped function "validate_client" in typed context [no-untyped-call] -apps/workflow/views/xero/xero_invoice_manager.py:0: error: Call to untyped function "get_xero_id" in typed context [no-untyped-call] -apps/workflow/views/xero/xero_invoice_manager.py:0: error: Item "None" of "Job | None" has no attribute "save" [union-attr] -apps/quoting/services/providers/gemini_provider.py:0: error: Function is missing a type annotation for one or more parameters [no-untyped-def] -apps/quoting/services/providers/gemini_provider.py:0: error: Incompatible types in assignment (expression has type "bool", target has type "Sequence[str]") [assignment] -apps/quoting/services/providers/gemini_provider.py:0: error: Incompatible types in assignment (expression has type "bool", target has type "Sequence[str]") [assignment] -apps/quoting/services/providers/gemini_provider.py:0: error: Incompatible types in assignment (expression has type "int", target has type "Sequence[str]") [assignment] -apps/quoting/services/providers/gemini_provider.py:0: error: Incompatible types in assignment (expression has type "Any | None", target has type "Sequence[str]") [assignment] -apps/quoting/services/providers/gemini_provider.py:0: error: Incompatible types in assignment (expression has type "int", target has type "Sequence[str]") [assignment] -apps/quoting/services/providers/gemini_provider.py:0: error: Incompatible types in assignment (expression has type "list[dict[str, object]]", target has type "Sequence[str]") [assignment] -apps/quoting/services/providers/gemini_provider.py:0: error: Need type annotation for "supplier_info" (hint: "supplier_info: dict[, ] = ...") [var-annotated] -apps/quoting/services/providers/gemini_provider.py:0: error: Missing type arguments for generic type "List" [type-arg] -apps/quoting/services/providers/gemini_provider.py:0: error: Argument "config" to "generate_content" of "Models" has incompatible type "dict[str, object]"; expected "GenerateContentConfig | GenerateContentConfigDict | None" [arg-type] -apps/quoting/services/providers/gemini_provider.py:0: error: Call to untyped function "log_token_usage" in typed context [no-untyped-call] -apps/purchasing/services/quote_to_po_service.py:0: error: Function is missing a type annotation [no-untyped-def] -apps/purchasing/services/quote_to_po_service.py:0: error: Argument 1 to "join" has incompatible type "str | None"; expected "str" [arg-type] -apps/purchasing/services/quote_to_po_service.py:0: error: Function is missing a type annotation [no-untyped-def] -apps/purchasing/services/quote_to_po_service.py:0: error: Dict entry 0 has incompatible type "str": "dict[str, str]"; expected "str": "str" [dict-item] -apps/purchasing/services/quote_to_po_service.py:0: error: Dict entry 0 has incompatible type "str": "dict[str, str]"; expected "str": "str" [dict-item] -apps/purchasing/services/quote_to_po_service.py:0: error: Argument "contents" to "generate_content" of "Models" has incompatible type "list[dict[str, str]]"; expected "Content | ContentDict | str | Image | File | FileDict | Part | PartDict | list[str | Image | File | FileDict | Part | PartDict] | list[Content | ContentDict | str | Image | File | FileDict | Part | PartDict | list[str | Image | File | FileDict | Part | PartDict]]" [arg-type] -apps/purchasing/services/quote_to_po_service.py:0: error: Call to untyped function "log_token_usage" in typed context [no-untyped-call] -apps/purchasing/services/quote_to_po_service.py:0: error: Argument 1 to "clean_json_response" has incompatible type "str | None"; expected "str" [arg-type] -apps/purchasing/services/quote_to_po_service.py:0: error: Argument 1 to "join" has incompatible type "str | None"; expected "str" [arg-type] -apps/job/services/kanban_service.py:0: error: Incompatible types in assignment (expression has type "list[Job]", variable has type "JobQuerySet") [assignment] -apps/job/services/kanban_service.py:0: error: Argument 1 to "_rank_kanban_search_candidates" of "KanbanService" has incompatible type "list[Job@AnnotatedWith[TypedDict({'trigram_score': Any})]]"; expected "list[Job]" [arg-type] -apps/job/services/kanban_service.py:0: note: "list" is invariant -- see https://mypy.readthedocs.io/en/stable/common_issues.html#variance -apps/job/services/kanban_service.py:0: note: Consider using "Sequence" instead, which is covariant -apps/job/services/kanban_service.py:0: error: Item "None" of "Client | None" has no attribute "name" [union-attr] -apps/job/services/kanban_service.py:0: error: Item "None" of "Client | None" has no attribute "name" [union-attr] -apps/job/services/kanban_service.py:0: error: Value of type "list[dict[str, int | str | Any | None]] | int | dict[str, Any] | str | Any | None" is not indexable [index] -apps/job/services/kanban_service.py:0: error: Invalid index type "slice[None, int, None]" for "dict[str, Any]"; expected type "str" [index] -apps/job/services/kanban_service.py:0: error: Call to untyped function "filter_kanban_jobs" of "KanbanService" in typed context [no-untyped-call] -apps/job/services/kanban_service.py:0: error: Returning Any from function declared to return "QuerySet[Job, Job]" [no-any-return] -apps/job/services/kanban_service.py:0: error: Function is missing a type annotation for one or more parameters [no-untyped-def] -apps/job/services/kanban_service.py:0: error: Function is missing a type annotation for one or more parameters [no-untyped-def] -apps/job/services/kanban_service.py:0: error: Function is missing a type annotation for one or more parameters [no-untyped-def] -apps/job/services/kanban_service.py:0: error: Returning Any from function declared to return "float" [no-any-return] -apps/job/services/kanban_service.py:0: error: Returning Any from function declared to return "float" [no-any-return] -apps/job/services/kanban_service.py:0: error: Function is missing a type annotation for one or more parameters [no-untyped-def] -apps/job/services/kanban_service.py:0: error: Function is missing a type annotation for one or more parameters [no-untyped-def] -apps/job/services/kanban_service.py:0: error: Call to untyped function "filter_kanban_jobs" of "KanbanService" in typed context [no-untyped-call] -apps/job/services/kanban_service.py:0: error: Function is missing a type annotation [no-untyped-def] -apps/accounting/services/payroll_reconciliation_service.py:0: error: Missing return statement [return] -apps/accounting/services/payroll_reconciliation_service.py:0: error: Key expression in dictionary comprehension has incompatible type "str | None"; expected type "str" [misc] -apps/accounting/services/payroll_reconciliation_service.py:0: error: Invalid index type "str | None" for "dict[str, dict[str, Any]]"; expected type "str" [index] -apps/accounting/services/payroll_reconciliation_service.py:0: error: Invalid index type "str | None" for "dict[str, dict[str, Any]]"; expected type "str" [index] -apps/accounting/services/payroll_reconciliation_service.py:0: error: Invalid index type "str | None" for "dict[str, dict[str, Any]]"; expected type "str" [index] -apps/accounting/services/payroll_reconciliation_service.py:0: error: Invalid index type "str | None" for "dict[str, dict[str, Any]]"; expected type "str" [index] -apps/accounting/services/payroll_reconciliation_service.py:0: error: Invalid index type "str | None" for "dict[str, dict[str, Any]]"; expected type "str" [index] +apps/company/views/supplier_pickup_address_viewset.py:0: error: Missing type arguments for generic type "ModelViewSet" [type-arg] +apps/company/views/supplier_pickup_address_viewset.py:0: error: Function is missing a type annotation [no-untyped-def] +apps/company/views/supplier_pickup_address_viewset.py:0: error: Function is missing a return type annotation [no-untyped-def] +apps/company/views/supplier_pickup_address_viewset.py:0: error: Function is missing a type annotation [no-untyped-def] +apps/company/views/contact_viewset.py:0: error: Missing type arguments for generic type "ModelViewSet" [type-arg] +apps/company/views/contact_viewset.py:0: error: Function is missing a type annotation [no-untyped-def] +apps/company/views/contact_viewset.py:0: error: Function is missing a return type annotation [no-untyped-def] +apps/company/views/contact_viewset.py:0: error: Function is missing a type annotation [no-untyped-def] +apps/company/views/contact_method_viewset.py:0: error: Missing type arguments for generic type "ModelViewSet" [type-arg] +apps/company/views/contact_method_viewset.py:0: error: Function is missing a type annotation [no-untyped-def] +apps/company/views/contact_method_viewset.py:0: error: Function is missing a return type annotation [no-untyped-def] +apps/company/views/contact_method_viewset.py:0: error: Incompatible type for lookup 'contact_id': (got "str", expected "ClientContact | UUID | None") [misc] apps/workflow/views/data_versions_view.py:0: error: Function is missing a type annotation for one or more parameters [no-untyped-def] -apps/workflow/views/build_id_view.py:0: error: Missing type arguments for generic type "list" [type-arg] -apps/workflow/api/reports/pnl.py:0: error: Function is missing a type annotation [no-untyped-def] apps/workflow/api/reports/job_movement.py:0: error: Function is missing a type annotation [no-untyped-def] apps/workflow/api/reports/job_movement.py:0: error: Function is missing a type annotation [no-untyped-def] apps/workflow/api/reports/job_movement.py:0: error: Function is missing a type annotation [no-untyped-def] @@ -1519,12 +1518,156 @@ apps/job/serializers/costing_serializer.py:0: error: Missing type arguments for apps/accounting/views/sales_forecast_view.py:0: error: Item "None" of "Job | None" has no attribute "start_date" [union-attr] apps/accounting/views/sales_forecast_view.py:0: error: Argument 1 to "_get_total_invoiced_for_job" of "SalesForecastMonthDetailAPIView" has incompatible type "Job | None"; expected "Job" [arg-type] apps/accounting/views/sales_forecast_view.py:0: error: Item "None" of "Job | None" has no attribute "latest_actual" [union-attr] -apps/accounting/views/sales_forecast_view.py:0: error: Item "None" of "Job | None" has no attribute "client" [union-attr] -apps/accounting/views/sales_forecast_view.py:0: error: Item "None" of "Client | Any | None" has no attribute "name" [union-attr] +apps/accounting/views/sales_forecast_view.py:0: error: Item "None" of "Job | None" has no attribute "company" [union-attr] +apps/accounting/views/sales_forecast_view.py:0: error: Item "None" of "Company | Any | None" has no attribute "name" [union-attr] apps/accounting/views/sales_forecast_view.py:0: error: Item "None" of "Job | None" has no attribute "job_number" [union-attr] apps/accounting/views/sales_forecast_view.py:0: error: Item "None" of "Job | None" has no attribute "name" [union-attr] apps/accounting/views/sales_forecast_view.py:0: error: Item "None" of "Job | None" has no attribute "id" [union-attr] apps/accounting/views/sales_forecast_view.py:0: error: Argument "row_date" to "build_row" has incompatible type "Any | None"; expected "str" [arg-type] +apps/workflow/views/xero/xero_base_manager.py:0: error: Function is missing a type annotation for one or more parameters [no-untyped-def] +apps/workflow/views/xero/xero_base_manager.py:0: error: Function is missing a return type annotation [no-untyped-def] +apps/workflow/views/xero/xero_base_manager.py:0: note: Use "-> None" if function does not return a value +apps/workflow/views/xero/xero_base_manager.py:0: error: Call to untyped function "validate_for_xero" in typed context [no-untyped-call] +apps/company/services/company_rest_service.py:0: error: Missing return statement [return] +apps/company/services/company_rest_service.py:0: error: Missing return statement [return] +apps/company/services/company_rest_service.py:0: error: Missing return statement [return] +apps/company/services/company_rest_service.py:0: error: Call to untyped function "_hydrate_company_search_results" of "CompanyRestService" in typed context [no-untyped-call] +apps/company/services/company_rest_service.py:0: error: Missing return statement [return] +apps/company/services/company_rest_service.py:0: error: Missing return statement [return] +apps/company/services/company_rest_service.py:0: error: Missing return statement [return] +apps/company/services/company_rest_service.py:0: error: Call to untyped function "save" in typed context [no-untyped-call] +apps/company/services/company_rest_service.py:0: error: Missing return statement [return] +apps/company/services/company_rest_service.py:0: error: Missing return statement [return] +apps/company/services/company_rest_service.py:0: error: Missing return statement [return] +apps/company/services/company_rest_service.py:0: error: Function is missing a return type annotation [no-untyped-def] +apps/company/services/company_rest_service.py:0: error: Call to untyped function "_hydrate_company_search_results" of "CompanyRestService" in typed context [no-untyped-call] +apps/company/services/company_rest_service.py:0: error: Function is missing a type annotation [no-untyped-def] +apps/company/services/company_rest_service.py:0: error: Function is missing a return type annotation [no-untyped-def] +apps/company/services/company_rest_service.py:0: error: Function is missing a type annotation for one or more parameters [no-untyped-def] +apps/company/services/company_rest_service.py:0: error: Function is missing a return type annotation [no-untyped-def] +apps/company/services/company_rest_service.py:0: error: Function is missing a return type annotation [no-untyped-def] +apps/company/services/company_rest_service.py:0: error: Call to untyped function "_hydrate_company_search_results" of "CompanyRestService" in typed context [no-untyped-call] +apps/company/services/company_rest_service.py:0: error: Function is missing a type annotation for one or more parameters [no-untyped-def] +apps/company/services/company_rest_service.py:0: error: Value of type "list[dict[str, Any]] | int | Any | str | None" is not indexable [index] +apps/company/services/company_rest_service.py:0: error: Function is missing a type annotation for one or more parameters [no-untyped-def] +apps/company/services/company_rest_service.py:0: error: Call to untyped function "date_to_datetime" in typed context [no-untyped-call] +apps/company/services/company_rest_service.py:0: error: Function is missing a type annotation for one or more parameters [no-untyped-def] +apps/company/services/company_rest_service.py:0: error: Call to untyped function "date_to_datetime" in typed context [no-untyped-call] +apps/company/services/company_rest_service.py:0: error: Call to untyped function "save" in typed context [no-untyped-call] +apps/company/views/company_rest_views.py:0: error: Module "drf_spectacular.utils" does not explicitly export attribute "OpenApiTypes" [attr-defined] +apps/company/views/company_rest_views.py:0: error: Argument 1 to "get_company_by_id" of "CompanyRestService" has incompatible type "str"; expected "UUID" [arg-type] +apps/company/views/company_rest_views.py:0: error: Function is missing a return type annotation [no-untyped-def] +apps/company/views/company_rest_views.py:0: error: Argument 1 to "update_company" of "CompanyRestService" has incompatible type "str"; expected "UUID" [arg-type] +apps/company/views/company_rest_views.py:0: error: Function is missing a return type annotation [no-untyped-def] +apps/company/views/company_rest_views.py:0: error: Incompatible types in assignment (expression has type "CompanyDuplicateErrorResponseSerializer", variable has type "CompanyErrorResponseSerializer") [assignment] +apps/company/views/company_rest_views.py:0: error: Function is missing a return type annotation [no-untyped-def] +apps/company/views/company_rest_views.py:0: error: Argument 1 to "get_job_contact" of "CompanyRestService" has incompatible type "str"; expected "UUID" [arg-type] +apps/company/views/company_rest_views.py:0: error: Argument 1 to "update_job_contact" of "CompanyRestService" has incompatible type "str"; expected "UUID" [arg-type] +apps/company/views/company_rest_views.py:0: error: Argument 3 to "update_job_contact" of "CompanyRestService" has incompatible type "Staff | AnonymousUser"; expected "Staff" [arg-type] +apps/company/views/company_rest_views.py:0: error: Argument 1 to "get_company_jobs" of "CompanyRestService" has incompatible type "str"; expected "UUID" [arg-type] +apps/company/tests/test_company_invoice_summary.py:0: error: Function is missing a type annotation [no-untyped-def] +apps/company/tests/test_company_invoice_summary.py:0: error: Function is missing a type annotation [no-untyped-def] +apps/company/tests/test_company_invoice_summary.py:0: error: Function is missing a type annotation [no-untyped-def] +apps/company/tests/test_company_invoice_summary.py:0: error: Function is missing a type annotation [no-untyped-def] +apps/company/tests/test_company_invoice_summary.py:0: error: Call to untyped function "date_to_datetime" in typed context [no-untyped-call] +apps/company/tests/test_company_invoice_summary.py:0: error: Function is missing a return type annotation [no-untyped-def] +apps/company/tests/test_company_invoice_summary.py:0: note: Use "-> None" if function does not return a value +apps/company/tests/test_company_invoice_summary.py:0: error: Function is missing a return type annotation [no-untyped-def] +apps/company/tests/test_company_invoice_summary.py:0: note: Use "-> None" if function does not return a value +apps/job/services/quote_mode_controller.py:0: error: Function is missing a return type annotation [no-untyped-def] +apps/job/services/quote_mode_controller.py:0: note: Use "-> None" if function does not return a value +apps/job/services/quote_mode_controller.py:0: error: Function is missing a type annotation for one or more parameters [no-untyped-def] +apps/job/services/quote_mode_controller.py:0: error: Missing type arguments for generic type "dict" [type-arg] +apps/job/services/quote_mode_controller.py:0: error: Returning Any from function declared to return "dict[str, Any]" [no-any-return] +apps/job/services/quote_mode_controller.py:0: error: Missing type arguments for generic type "dict" [type-arg] +apps/job/services/quote_mode_controller.py:0: error: Missing type arguments for generic type "dict" [type-arg] +apps/job/services/quote_mode_controller.py:0: error: Cannot call function of unknown type [operator] +apps/job/services/quote_mode_controller.py:0: error: Returning Any from function declared to return "str" [no-any-return] +apps/job/services/quote_mode_controller.py:0: error: Incompatible return value type (got "None", expected "str") [return-value] +apps/job/services/mcp_chat_service.py:0: error: Item "None" of "Company | None" has no attribute "name" [union-attr] +apps/job/services/mcp_chat_service.py:0: error: Dict entry 1 has incompatible type "str": "str | None"; expected "str": "str" [dict-item] +apps/job/services/mcp_chat_service.py:0: error: Dict entry 2 has incompatible type "str": "list[dict[str, dict[str, Any | str] | Any | str]]"; expected "str": "str" [dict-item] +apps/job/services/quote_sync_service.py:0: error: Call to untyped function "_copy_estimate_to_quote_costset" in typed context [no-untyped-call] +apps/job/services/quote_sync_service.py:0: error: Function is missing a return type annotation [no-untyped-def] +apps/job/services/quote_sync_service.py:0: error: "" has no attribute "LABOR" [attr-defined] +apps/job/services/quote_sync_service.py:0: error: "" has no attribute "MATERIAL" [attr-defined] +apps/job/services/quote_sync_service.py:0: error: Argument "quantity" to "DraftLine" has incompatible type "float | int"; expected "Decimal" [arg-type] +apps/job/services/quote_sync_service.py:0: error: Argument "unit_cost" to "DraftLine" has incompatible type "float | int"; expected "Decimal" [arg-type] +apps/job/services/quote_sync_service.py:0: error: Function is missing a return type annotation [no-untyped-def] +apps/job/services/quote_sync_service.py:0: error: Function is missing a return type annotation [no-untyped-def] +apps/job/services/quote_sync_service.py:0: error: Function is missing a type annotation for one or more parameters [no-untyped-def] +apps/job/services/quote_sync_service.py:0: error: Function is missing a type annotation [no-untyped-def] +apps/workflow/api/xero/reprocess_xero.py:0: error: Function is missing a type annotation [no-untyped-def] +apps/workflow/api/xero/reprocess_xero.py:0: error: Item "None" of "type[InvoiceLineItem] | type[BillLineItem] | type[CreditNoteLineItem] | None" has no attribute "objects" [union-attr] +apps/workflow/api/xero/reprocess_xero.py:0: error: Argument after ** must have string keys [arg-type] +apps/workflow/api/xero/reprocess_xero.py:0: error: Unsupported operand types for + ("str" and "None") [operator] +apps/workflow/api/xero/reprocess_xero.py:0: note: Right operand is of type "Any | None" +apps/workflow/api/xero/reprocess_xero.py:0: error: Function is missing a return type annotation [no-untyped-def] +apps/workflow/api/xero/reprocess_xero.py:0: note: Use "-> None" if function does not return a value +apps/workflow/api/xero/reprocess_xero.py:0: error: Call to untyped function "set_invoice_or_bill_fields" in typed context [no-untyped-call] +apps/workflow/api/xero/reprocess_xero.py:0: error: Function is missing a return type annotation [no-untyped-def] +apps/workflow/api/xero/reprocess_xero.py:0: note: Use "-> None" if function does not return a value +apps/workflow/api/xero/reprocess_xero.py:0: error: Call to untyped function "set_invoice_or_bill_fields" in typed context [no-untyped-call] +apps/workflow/api/xero/reprocess_xero.py:0: error: Function is missing a return type annotation [no-untyped-def] +apps/workflow/api/xero/reprocess_xero.py:0: note: Use "-> None" if function does not return a value +apps/workflow/api/xero/reprocess_xero.py:0: error: Call to untyped function "set_invoice_or_bill_fields" in typed context [no-untyped-call] +apps/workflow/api/xero/reprocess_xero.py:0: error: Function is missing a return type annotation [no-untyped-def] +apps/workflow/api/xero/reprocess_xero.py:0: note: Use "-> None" if function does not return a value +apps/workflow/api/xero/reprocess_xero.py:0: error: Function is missing a return type annotation [no-untyped-def] +apps/workflow/api/xero/reprocess_xero.py:0: note: Use "-> None" if function does not return a value +apps/workflow/api/xero/reprocess_xero.py:0: error: Call to untyped function "reprocess_companies" in typed context [no-untyped-call] +apps/workflow/api/xero/reprocess_xero.py:0: error: Call to untyped function "reprocess_invoices" in typed context [no-untyped-call] +apps/workflow/api/xero/reprocess_xero.py:0: error: Call to untyped function "reprocess_bills" in typed context [no-untyped-call] +apps/workflow/api/xero/reprocess_xero.py:0: error: Call to untyped function "reprocess_credit_notes" in typed context [no-untyped-call] +apps/job/views/data_integrity_views.py:0: error: Function is missing a type annotation for one or more parameters [no-untyped-def] +apps/job/views/data_integrity_views.py:0: error: Missing return statement [return] +apps/quoting/services/pdf_import_service.py:0: error: Function is missing a return type annotation [no-untyped-def] +apps/quoting/services/pdf_import_service.py:0: note: Use "-> None" if function does not return a value +apps/quoting/services/pdf_import_service.py:0: error: Missing type arguments for generic type "Dict" [type-arg] +apps/quoting/services/pdf_import_service.py:0: error: "object" has no attribute "append" [attr-defined] +apps/quoting/services/pdf_import_service.py:0: error: Missing type arguments for generic type "Dict" [type-arg] +apps/quoting/services/pdf_import_service.py:0: error: Missing type arguments for generic type "Dict" [type-arg] +apps/quoting/services/pdf_import_service.py:0: error: Incompatible return value type (got "None", expected "SupplierProduct") [return-value] +apps/quoting/services/pdf_import_service.py:0: error: Missing type arguments for generic type "Dict" [type-arg] +apps/quoting/services/pdf_import_service.py:0: error: Function is missing a return type annotation [no-untyped-def] +apps/quoting/services/pdf_import_service.py:0: error: Missing type arguments for generic type "Dict" [type-arg] +apps/quoting/services/pdf_import_service.py:0: error: Missing type arguments for generic type "Dict" [type-arg] +apps/quoting/services/pdf_import_service.py:0: error: Missing type arguments for generic type "Dict" [type-arg] +apps/quoting/services/pdf_import_service.py:0: error: Missing type arguments for generic type "Dict" [type-arg] +apps/quoting/services/pdf_import_service.py:0: error: Missing type arguments for generic type "Dict" [type-arg] +apps/quoting/services/pdf_import_service.py:0: error: Function is missing a return type annotation [no-untyped-def] +apps/quoting/services/pdf_import_service.py:0: note: Use "-> None" if function does not return a value +apps/quoting/scrapers/base.py:0: error: Function is missing a type annotation for one or more parameters [no-untyped-def] +apps/quoting/scrapers/base.py:0: error: Function is missing a return type annotation [no-untyped-def] +apps/quoting/scrapers/base.py:0: error: Incompatible types in assignment (expression has type "WebDriver", variable has type "None") [assignment] +apps/quoting/scrapers/base.py:0: error: Function is missing a return type annotation [no-untyped-def] +apps/quoting/scrapers/base.py:0: note: Use "-> None" if function does not return a value +apps/quoting/scrapers/base.py:0: error: Function is missing a return type annotation [no-untyped-def] +apps/quoting/scrapers/base.py:0: note: Use "-> None" if function does not return a value +apps/quoting/scrapers/base.py:0: error: Function is missing a type annotation [no-untyped-def] +apps/quoting/scrapers/base.py:0: error: Function is missing a return type annotation [no-untyped-def] +apps/quoting/scrapers/base.py:0: note: Use "-> None" if function does not return a value +apps/quoting/scrapers/base.py:0: error: Function is missing a return type annotation [no-untyped-def] +apps/quoting/scrapers/base.py:0: note: Use "-> None" if function does not return a value +apps/quoting/scrapers/base.py:0: error: Call to untyped function "setup_driver" in typed context [no-untyped-call] +apps/quoting/scrapers/base.py:0: error: Call to untyped function "login" of "BaseScraper" in typed context [no-untyped-call] +apps/quoting/scrapers/base.py:0: error: Call to untyped function "get_product_urls" of "BaseScraper" in typed context [no-untyped-call] +apps/quoting/scrapers/base.py:0: error: Call to untyped function "scrape_product" of "BaseScraper" in typed context [no-untyped-call] +apps/quoting/scrapers/base.py:0: error: Call to untyped function "save_products" in typed context [no-untyped-call] +apps/quoting/scrapers/base.py:0: error: Call to untyped function "save_products" in typed context [no-untyped-call] +apps/quoting/scrapers/base.py:0: error: Call to untyped function "cleanup" in typed context [no-untyped-call] +apps/quoting/scrapers/base.py:0: error: Function is missing a type annotation [no-untyped-def] +apps/process/services/procedure_service.py:0: error: Function is missing a return type annotation [no-untyped-def] +apps/process/services/procedure_service.py:0: note: Use "-> None" if function does not return a value +apps/process/services/procedure_service.py:0: error: Call to untyped function "SafetyAIService" in typed context [no-untyped-call] +apps/process/services/procedure_service.py:0: error: Call to untyped function "GoogleDocsService" in typed context [no-untyped-call] +apps/accounting/services/payroll_reconciliation_service.py:0: error: Missing return statement [return] +apps/accounting/services/payroll_reconciliation_service.py:0: error: Key expression in dictionary comprehension has incompatible type "str | None"; expected type "str" [misc] +apps/accounting/services/payroll_reconciliation_service.py:0: error: Invalid index type "str | None" for "dict[str, dict[str, Any]]"; expected type "str" [index] +apps/accounting/services/payroll_reconciliation_service.py:0: error: Invalid index type "str | None" for "dict[str, dict[str, Any]]"; expected type "str" [index] +apps/accounting/services/payroll_reconciliation_service.py:0: error: Invalid index type "str | None" for "dict[str, dict[str, Any]]"; expected type "str" [index] +apps/accounting/services/payroll_reconciliation_service.py:0: error: Invalid index type "str | None" for "dict[str, dict[str, Any]]"; expected type "str" [index] +apps/accounting/services/payroll_reconciliation_service.py:0: error: Invalid index type "str | None" for "dict[str, dict[str, Any]]"; expected type "str" [index] apps/workflow/tests/test_company_defaults_schema.py:0: error: Function is missing a return type annotation [no-untyped-def] apps/workflow/tests/test_company_defaults_schema.py:0: note: Use "-> None" if function does not return a value apps/workflow/tests/test_company_defaults_schema.py:0: error: Function is missing a return type annotation [no-untyped-def] @@ -1598,7 +1741,6 @@ apps/workflow/tests/test_company_defaults_api.py:0: error: "_MonkeyPatchedWSGIRe apps/timesheet/tests/test_weekly_timesheet_service.py:0: error: Function is missing a return type annotation [no-untyped-def] apps/timesheet/tests/test_weekly_timesheet_service.py:0: note: Use "-> None" if function does not return a value apps/timesheet/tests/test_weekly_timesheet_service.py:0: error: Call to untyped function "save" in typed context [no-untyped-call] -apps/timesheet/tests/test_weekly_timesheet_service.py:0: error: Incompatible types in assignment (expression has type "apps.client.models.Client", variable has type "django.test.client.Client") [assignment] apps/timesheet/tests/test_weekly_timesheet_service.py:0: error: Function is missing a return type annotation [no-untyped-def] apps/timesheet/tests/test_weekly_timesheet_service.py:0: note: Use "-> None" if function does not return a value apps/timesheet/tests/test_permissions.py:0: error: Function is missing a return type annotation [no-untyped-def] @@ -1654,7 +1796,6 @@ apps/timesheet/tests/test_jobs_api_filter.py:0: error: Call to untyped function apps/timesheet/tests/test_jobs_api_filter.py:0: error: Returning Any from function declared to return "Job" [no-any-return] apps/timesheet/tests/test_daily_timesheet_service.py:0: error: Function is missing a return type annotation [no-untyped-def] apps/timesheet/tests/test_daily_timesheet_service.py:0: note: Use "-> None" if function does not return a value -apps/timesheet/tests/test_daily_timesheet_service.py:0: error: Incompatible types in assignment (expression has type "apps.client.models.Client", variable has type "django.test.client.Client") [assignment] apps/timesheet/tests/test_daily_timesheet_service.py:0: error: Returning Any from function declared to return "Job" [no-any-return] apps/timesheet/tests/test_daily_timesheet_service.py:0: error: Function is missing a return type annotation [no-untyped-def] apps/timesheet/tests/test_daily_timesheet_service.py:0: note: Use "-> None" if function does not return a value @@ -2028,7 +2169,6 @@ apps/job/tests/test_mcp_tool_integration.py:0: error: Function is missing a retu apps/job/tests/test_mcp_tool_integration.py:0: note: Use "-> None" if function does not return a value apps/job/tests/test_job_quote_chat_model.py:0: error: Function is missing a return type annotation [no-untyped-def] apps/job/tests/test_job_quote_chat_model.py:0: note: Use "-> None" if function does not return a value -apps/job/tests/test_job_quote_chat_model.py:0: error: Incompatible types in assignment (expression has type "apps.client.models.Client", variable has type "django.test.client.Client") [assignment] apps/job/tests/test_job_quote_chat_model.py:0: error: Function is missing a return type annotation [no-untyped-def] apps/job/tests/test_job_quote_chat_model.py:0: note: Use "-> None" if function does not return a value apps/job/tests/test_job_latest_costsets.py:0: error: Function is missing a return type annotation [no-untyped-def] @@ -2104,7 +2244,6 @@ apps/job/tests/test_delivery_docket_service.py:0: error: Function is missing a r apps/job/tests/test_delivery_docket_service.py:0: note: Use "-> None" if function does not return a value apps/job/tests/test_delivery_docket_service.py:0: error: Function is missing a return type annotation [no-untyped-def] apps/job/tests/test_delivery_docket_service.py:0: note: Use "-> None" if function does not return a value -apps/job/tests/test_costline_schema_validation.py:0: error: Incompatible types in assignment (expression has type "apps.client.models.Client", variable has type "django.test.client.Client") [assignment] apps/job/tests/test_costline_schema_validation.py:0: error: Function is missing a type annotation for one or more parameters [no-untyped-def] apps/job/tests/test_costline_schema_validation.py:0: error: Call to untyped function "save" in typed context [no-untyped-call] apps/job/tests/test_costline_schema_validation.py:0: error: Call to untyped function "save" in typed context [no-untyped-call] @@ -2167,19 +2306,19 @@ apps/crm/tests/test_phone_call_service.py:0: error: Function is missing a return apps/crm/tests/test_phone_call_service.py:0: error: Function is missing a type annotation for one or more parameters [no-untyped-def] apps/crm/tests/test_phone_call_service.py:0: error: Function is missing a return type annotation [no-untyped-def] apps/crm/tests/test_phone_call_service.py:0: error: Function is missing a type annotation for one or more parameters [no-untyped-def] -apps/client/tests/test_client_merge_service.py:0: error: Function is missing a type annotation for one or more parameters [no-untyped-def] -apps/client/tests/test_client_merge_service.py:0: error: Missing type arguments for generic type "dict" [type-arg] -apps/client/tests/test_client_merge_service.py:0: error: Value of type "Any | None" is not indexable [index] -apps/client/tests/test_client_merge_service.py:0: error: Call to untyped function "save" in typed context [no-untyped-call] -apps/client/tests/test_client_merge_service.py:0: error: Call to untyped function "save" in typed context [no-untyped-call] -apps/client/tests/test_client_merge_service.py:0: error: Call to untyped function "get_final_client" in typed context [no-untyped-call] -apps/client/tests/test_client_merge_service.py:0: error: Call to untyped function "get_final_client" in typed context [no-untyped-call] -apps/client/tests/test_client_merge_service.py:0: error: Call to untyped function "save" in typed context [no-untyped-call] -apps/client/tests/test_client_merge_service.py:0: error: Call to untyped function "save" in typed context [no-untyped-call] -apps/client/tests/test_client_merge_service.py:0: error: Call to untyped function "get_final_client" in typed context [no-untyped-call] -apps/client/tests/test_client_merge_service.py:0: error: Function is missing a type annotation [no-untyped-def] -apps/client/tests/test_client_merge_service.py:0: error: Function is missing a type annotation [no-untyped-def] -apps/client/tests/test_client_merge_service.py:0: error: Function is missing a type annotation [no-untyped-def] +apps/company/tests/test_company_merge_service.py:0: error: Function is missing a type annotation for one or more parameters [no-untyped-def] +apps/company/tests/test_company_merge_service.py:0: error: Missing type arguments for generic type "dict" [type-arg] +apps/company/tests/test_company_merge_service.py:0: error: Value of type "Any | None" is not indexable [index] +apps/company/tests/test_company_merge_service.py:0: error: Call to untyped function "save" in typed context [no-untyped-call] +apps/company/tests/test_company_merge_service.py:0: error: Call to untyped function "save" in typed context [no-untyped-call] +apps/company/tests/test_company_merge_service.py:0: error: Call to untyped function "get_final_company" in typed context [no-untyped-call] +apps/company/tests/test_company_merge_service.py:0: error: Call to untyped function "get_final_company" in typed context [no-untyped-call] +apps/company/tests/test_company_merge_service.py:0: error: Call to untyped function "save" in typed context [no-untyped-call] +apps/company/tests/test_company_merge_service.py:0: error: Call to untyped function "save" in typed context [no-untyped-call] +apps/company/tests/test_company_merge_service.py:0: error: Call to untyped function "get_final_company" in typed context [no-untyped-call] +apps/company/tests/test_company_merge_service.py:0: error: Function is missing a type annotation [no-untyped-def] +apps/company/tests/test_company_merge_service.py:0: error: Function is missing a type annotation [no-untyped-def] +apps/company/tests/test_company_merge_service.py:0: error: Function is missing a type annotation [no-untyped-def] apps/accounts/tests/test_staff_api.py:0: error: Function is missing a return type annotation [no-untyped-def] apps/accounts/tests/test_staff_api.py:0: note: Use "-> None" if function does not return a value apps/accounts/tests/test_displayable_staff.py:0: error: Function is missing a return type annotation [no-untyped-def] @@ -2329,7 +2468,6 @@ apps/accounting/tests/test_invoice_calculation.py:0: error: Call to untyped func apps/accounting/tests/test_invoice_calculation.py:0: error: Call to untyped function "_add_revenue_line" in typed context [no-untyped-call] apps/accounting/tests/test_core_nplusone.py:0: error: Function is missing a return type annotation [no-untyped-def] apps/accounting/tests/test_core_nplusone.py:0: note: Use "-> None" if function does not return a value -apps/accounting/tests/test_core_nplusone.py:0: error: Incompatible types in assignment (expression has type "apps.client.models.Client", variable has type "django.test.client.Client") [assignment] apps/accounting/tests/test_core_nplusone.py:0: error: Module "django.utils.timezone" does not explicitly export attribute "datetime" [attr-defined] apps/accounting/tests/test_core_nplusone.py:0: error: Returning Any from function declared to return "Job" [no-any-return] apps/accounting/tests/test_core_nplusone.py:0: error: Function is missing a return type annotation [no-untyped-def] @@ -2337,116 +2475,6 @@ apps/accounting/tests/test_core_nplusone.py:0: error: Function is missing a retu apps/accounting/tests/test_core_nplusone.py:0: note: Use "-> None" if function does not return a value apps/accounting/tests/test_core_nplusone.py:0: error: Function is missing a return type annotation [no-untyped-def] apps/accounting/tests/test_core_nplusone.py:0: note: Use "-> None" if function does not return a value -apps/workflow/views/company_defaults_logo_api.py:0: error: Argument 1 to "join" has incompatible type "str | None"; expected "str" [arg-type] -apps/workflow/views/company_defaults_logo_api.py:0: error: Function is missing a type annotation [no-untyped-def] -apps/workflow/views/company_defaults_logo_api.py:0: error: Function is missing a type annotation [no-untyped-def] -apps/workflow/views/company_defaults_logo_api.py:0: error: Call to untyped function "_remove_if_user_uploaded" in typed context [no-untyped-call] -apps/workflow/views/company_defaults_logo_api.py:0: error: Call to untyped function "save" in typed context [no-untyped-call] -apps/workflow/views/company_defaults_logo_api.py:0: error: Function is missing a type annotation [no-untyped-def] -apps/workflow/views/company_defaults_logo_api.py:0: error: Call to untyped function "_remove_if_user_uploaded" in typed context [no-untyped-call] -apps/workflow/views/company_defaults_logo_api.py:0: error: Call to untyped function "save" in typed context [no-untyped-call] -apps/workflow/views/company_defaults_api.py:0: error: Function is missing a type annotation [no-untyped-def] -apps/workflow/views/company_defaults_api.py:0: error: Function is missing a type annotation [no-untyped-def] -apps/workflow/views/company_defaults_api.py:0: error: Function is missing a type annotation [no-untyped-def] -apps/operations/views/workshop_schedule_view.py:0: error: Incompatible types in assignment (expression has type "list[JobProjection]", variable has type "QuerySet[JobProjection, JobProjection]") [assignment] -apps/workflow/views/session_replay_view.py:0: error: Function is missing a type annotation [no-untyped-def] -apps/workflow/views/session_replay_view.py:0: error: Call to untyped function "has_permission" in typed context [no-untyped-call] -apps/workflow/views/session_replay_view.py:0: error: Function is missing a type annotation [no-untyped-def] -apps/workflow/views/session_replay_view.py:0: error: Function is missing a type annotation [no-untyped-def] -apps/workflow/views/session_replay_view.py:0: error: Function is missing a type annotation [no-untyped-def] -apps/workflow/views/session_replay_view.py:0: error: Function is missing a type annotation [no-untyped-def] -apps/workflow/views/session_replay_view.py:0: error: Function is missing a type annotation [no-untyped-def] -apps/timesheet/serializers/modern_timesheet_serializers.py:0: error: Missing type arguments for generic type "ModelSerializer" [type-arg] -apps/timesheet/serializers/modern_timesheet_serializers.py:0: error: Function is missing a type annotation for one or more parameters [no-untyped-def] -apps/timesheet/serializers/modern_timesheet_serializers.py:0: error: Function is missing a type annotation for one or more parameters [no-untyped-def] -apps/timesheet/serializers/modern_timesheet_serializers.py:0: error: Function is missing a type annotation for one or more parameters [no-untyped-def] -apps/timesheet/serializers/modern_timesheet_serializers.py:0: error: Missing type arguments for generic type "Serializer" [type-arg] -apps/timesheet/serializers/modern_timesheet_serializers.py:0: error: Missing type arguments for generic type "Serializer" [type-arg] -apps/timesheet/serializers/modern_timesheet_serializers.py:0: error: Missing type arguments for generic type "Serializer" [type-arg] -apps/timesheet/serializers/modern_timesheet_serializers.py:0: error: Missing type arguments for generic type "Serializer" [type-arg] -apps/timesheet/serializers/modern_timesheet_serializers.py:0: error: Missing type arguments for generic type "Serializer" [type-arg] -apps/timesheet/serializers/modern_timesheet_serializers.py:0: error: Missing type arguments for generic type "Serializer" [type-arg] -apps/timesheet/serializers/modern_timesheet_serializers.py:0: error: Missing type arguments for generic type "Serializer" [type-arg] -apps/timesheet/serializers/modern_timesheet_serializers.py:0: error: Function is missing a type annotation [no-untyped-def] -apps/timesheet/serializers/modern_timesheet_serializers.py:0: error: Missing type arguments for generic type "Serializer" [type-arg] -apps/timesheet/serializers/modern_timesheet_serializers.py:0: error: Missing type arguments for generic type "Serializer" [type-arg] -apps/timesheet/serializers/modern_timesheet_serializers.py:0: error: Missing type arguments for generic type "Serializer" [type-arg] -apps/timesheet/serializers/modern_timesheet_serializers.py:0: error: Function is missing a type annotation [no-untyped-def] -apps/timesheet/serializers/modern_timesheet_serializers.py:0: error: Missing type arguments for generic type "Serializer" [type-arg] -apps/timesheet/serializers/modern_timesheet_serializers.py:0: error: Function is missing a type annotation [no-untyped-def] -apps/timesheet/serializers/modern_timesheet_serializers.py:0: error: Missing type arguments for generic type "Serializer" [type-arg] -apps/timesheet/serializers/modern_timesheet_serializers.py:0: error: Function is missing a type annotation [no-untyped-def] -apps/timesheet/serializers/modern_timesheet_serializers.py:0: error: Missing type arguments for generic type "Serializer" [type-arg] -apps/timesheet/serializers/modern_timesheet_serializers.py:0: error: Missing type arguments for generic type "Serializer" [type-arg] -apps/timesheet/serializers/modern_timesheet_serializers.py:0: error: Missing type arguments for generic type "Serializer" [type-arg] -apps/job/views/job_profitability_report_views.py:0: error: Function is missing a type annotation for one or more parameters [no-untyped-def] -apps/job/views/job_profitability_report_views.py:0: error: Missing return statement [return] -apps/job/views/job_files_collection_view.py:0: error: Function is missing a type annotation [no-untyped-def] -apps/job/views/job_files_collection_view.py:0: error: Argument 1 to "join" has incompatible type "str | None"; expected "str" [arg-type] -apps/job/views/job_files_collection_view.py:0: error: "Callable[[str], None]" has no attribute "delay" [attr-defined] -apps/job/views/job_files_collection_view.py:0: error: Argument "user_id" to "persist_app_error" has incompatible type "str | None"; expected "str" [arg-type] -apps/job/views/job_files_collection_view.py:0: error: Function is missing a type annotation [no-untyped-def] -apps/job/views/job_files_collection_view.py:0: error: Call to untyped function "save_file" in typed context [no-untyped-call] -apps/job/views/job_files_collection_view.py:0: error: Function is missing a type annotation [no-untyped-def] -apps/job/views/job_file_thumbnail_view.py:0: error: Function is missing a type annotation [no-untyped-def] -apps/job/views/job_file_detail_view.py:0: error: Function is missing a type annotation [no-untyped-def] -apps/job/views/job_file_detail_view.py:0: error: Function is missing a type annotation [no-untyped-def] -apps/job/views/job_file_detail_view.py:0: error: Argument 1 to "join" has incompatible type "str | None"; expected "str" [arg-type] -apps/job/views/job_file_detail_view.py:0: error: Function is missing a type annotation [no-untyped-def] -apps/job/views/job_file_detail_view.py:0: error: Argument 1 to "join" has incompatible type "str | None"; expected "str" [arg-type] -apps/job/views/job_file_detail_view.py:0: error: Function is missing a type annotation [no-untyped-def] -apps/job/views/job_file_detail_view.py:0: error: Argument 1 to "join" has incompatible type "str | None"; expected "str" [arg-type] -apps/job/views/data_quality_report_views.py:0: error: Function is missing a type annotation for one or more parameters [no-untyped-def] -apps/job/views/data_integrity_views.py:0: error: Function is missing a type annotation for one or more parameters [no-untyped-def] -apps/job/views/data_integrity_views.py:0: error: Missing return statement [return] -apps/client/views/supplier_search_alias_views.py:0: error: Function is missing a type annotation for one or more parameters [no-untyped-def] -apps/client/views/supplier_search_alias_views.py:0: error: Function is missing a type annotation for one or more parameters [no-untyped-def] -apps/client/views/supplier_search_alias_views.py:0: error: Function is missing a type annotation for one or more parameters [no-untyped-def] -apps/client/services/client_rest_service.py:0: error: Missing return statement [return] -apps/client/services/client_rest_service.py:0: error: Missing return statement [return] -apps/client/services/client_rest_service.py:0: error: Missing return statement [return] -apps/client/services/client_rest_service.py:0: error: Call to untyped function "_hydrate_client_search_results" of "ClientRestService" in typed context [no-untyped-call] -apps/client/services/client_rest_service.py:0: error: Missing return statement [return] -apps/client/services/client_rest_service.py:0: error: Missing return statement [return] -apps/client/services/client_rest_service.py:0: error: Missing return statement [return] -apps/client/services/client_rest_service.py:0: error: Call to untyped function "save" in typed context [no-untyped-call] -apps/client/services/client_rest_service.py:0: error: Missing return statement [return] -apps/client/services/client_rest_service.py:0: error: Missing return statement [return] -apps/client/services/client_rest_service.py:0: error: Missing return statement [return] -apps/client/services/client_rest_service.py:0: error: Function is missing a return type annotation [no-untyped-def] -apps/client/services/client_rest_service.py:0: error: Call to untyped function "_hydrate_client_search_results" of "ClientRestService" in typed context [no-untyped-call] -apps/client/services/client_rest_service.py:0: error: Function is missing a type annotation [no-untyped-def] -apps/client/services/client_rest_service.py:0: error: Function is missing a return type annotation [no-untyped-def] -apps/client/services/client_rest_service.py:0: error: Function is missing a type annotation for one or more parameters [no-untyped-def] -apps/client/services/client_rest_service.py:0: error: Function is missing a return type annotation [no-untyped-def] -apps/client/services/client_rest_service.py:0: error: Function is missing a return type annotation [no-untyped-def] -apps/client/services/client_rest_service.py:0: error: Call to untyped function "_hydrate_client_search_results" of "ClientRestService" in typed context [no-untyped-call] -apps/client/services/client_rest_service.py:0: error: Function is missing a type annotation for one or more parameters [no-untyped-def] -apps/client/services/client_rest_service.py:0: error: Value of type "list[dict[str, Any]] | int | Any | str | None" is not indexable [index] -apps/client/services/client_rest_service.py:0: error: Function is missing a type annotation for one or more parameters [no-untyped-def] -apps/client/services/client_rest_service.py:0: error: Call to untyped function "date_to_datetime" in typed context [no-untyped-call] -apps/client/services/client_rest_service.py:0: error: Function is missing a type annotation for one or more parameters [no-untyped-def] -apps/client/services/client_rest_service.py:0: error: Call to untyped function "date_to_datetime" in typed context [no-untyped-call] -apps/client/services/client_rest_service.py:0: error: Call to untyped function "save" in typed context [no-untyped-call] -apps/workflow/tests/test_xero_document_raw_json.py:0: error: Function is missing a type annotation [no-untyped-def] -apps/workflow/tests/test_xero_document_raw_json.py:0: error: Function is missing a type annotation [no-untyped-def] -apps/workflow/tests/test_xero_document_raw_json.py:0: error: Function is missing a type annotation [no-untyped-def] -apps/workflow/tests/test_xero_document_raw_json.py:0: error: Function is missing a type annotation [no-untyped-def] -apps/workflow/tests/test_xero_document_raw_json.py:0: error: Function is missing a type annotation [no-untyped-def] -apps/workflow/tests/test_xero_document_raw_json.py:0: error: Function is missing a type annotation [no-untyped-def] -apps/workflow/tests/test_xero_document_raw_json.py:0: error: Function is missing a return type annotation [no-untyped-def] -apps/workflow/tests/test_xero_document_raw_json.py:0: note: Use "-> None" if function does not return a value -apps/workflow/tests/test_xero_document_raw_json.py:0: error: Function is missing a return type annotation [no-untyped-def] -apps/workflow/tests/test_xero_document_raw_json.py:0: note: Use "-> None" if function does not return a value -apps/workflow/tests/test_xero_document_raw_json.py:0: error: Call to untyped function "_FakeProvider" in typed context [no-untyped-call] -apps/workflow/tests/test_xero_document_raw_json.py:0: error: Incompatible types in assignment (expression has type "_FakeProvider", variable has type "AccountingProvider") [assignment] -apps/workflow/tests/test_xero_document_raw_json.py:0: error: Cannot assign to a method [method-assign] -apps/workflow/tests/test_xero_document_raw_json.py:0: error: Incompatible types in assignment (expression has type "def _attach_workshop_pdf(self, external_id: str) -> None", variable has type "def _attach_workshop_pdf(self, invoice_external_id: str) -> str | None") [assignment] -apps/workflow/tests/test_xero_document_raw_json.py:0: error: Function is missing a return type annotation [no-untyped-def] -apps/workflow/tests/test_xero_document_raw_json.py:0: note: Use "-> None" if function does not return a value -apps/workflow/tests/test_xero_document_raw_json.py:0: error: Call to untyped function "_FakeProvider" in typed context [no-untyped-call] -apps/workflow/tests/test_xero_document_raw_json.py:0: error: Call to untyped function "XeroQuoteManager" in typed context [no-untyped-call] -apps/workflow/tests/test_xero_document_raw_json.py:0: error: Incompatible types in assignment (expression has type "_FakeProvider", variable has type "AccountingProvider") [assignment] apps/job/tests/test_kanban_service.py:0: error: Function is missing a return type annotation [no-untyped-def] apps/job/tests/test_kanban_service.py:0: note: Use "-> None" if function does not return a value apps/job/tests/test_kanban_service.py:0: error: Function is missing a type annotation [no-untyped-def] @@ -2492,8 +2520,8 @@ apps/job/tests/test_kanban_search.py:0: error: Function is missing a return type apps/job/tests/test_kanban_search.py:0: note: Use "-> None" if function does not return a value apps/job/tests/test_kanban_search.py:0: error: Function is missing a return type annotation [no-untyped-def] apps/job/tests/test_kanban_search.py:0: error: Call to untyped function "untracked_update" in typed context [no-untyped-call] -apps/job/tests/test_kanban_search.py:0: error: Incompatible type for "client" of "Invoice" (got "Client | None", expected "Client | Combinable") [misc] -apps/job/tests/test_kanban_search.py:0: error: Incompatible type for "client" of "Quote" (got "Client | None", expected "Client | Combinable") [misc] +apps/job/tests/test_kanban_search.py:0: error: Incompatible type for "company" of "Invoice" (got "Company | None", expected "Company | Combinable") [misc] +apps/job/tests/test_kanban_search.py:0: error: Incompatible type for "company" of "Quote" (got "Company | None", expected "Company | Combinable") [misc] apps/job/tests/test_kanban_search.py:0: error: Function is missing a return type annotation [no-untyped-def] apps/job/tests/test_kanban_search.py:0: note: Use "-> None" if function does not return a value apps/job/tests/test_kanban_search.py:0: error: Function is missing a return type annotation [no-untyped-def] @@ -2554,9 +2582,7 @@ apps/job/tests/test_kanban_search.py:0: error: Function is missing a return type apps/job/tests/test_kanban_search.py:0: note: Use "-> None" if function does not return a value apps/job/tests/test_kanban_reorder_priority.py:0: error: Function is missing a return type annotation [no-untyped-def] apps/job/tests/test_kanban_reorder_priority.py:0: note: Use "-> None" if function does not return a value -apps/job/tests/test_kanban_reorder_priority.py:0: error: Incompatible types in assignment (expression has type "apps.client.models.Client", variable has type "django.test.client.Client") [assignment] apps/job/tests/test_kanban_reorder_priority.py:0: error: Function is missing a type annotation [no-untyped-def] -apps/job/tests/test_kanban_reorder_priority.py:0: error: Incompatible type for "client" of "Job" (got "django.test.client.Client", expected "apps.client.models.Client | Combinable | None") [misc] apps/job/tests/test_kanban_reorder_priority.py:0: error: Function is missing a type annotation [no-untyped-def] apps/job/tests/test_kanban_reorder_priority.py:0: error: Function is missing a return type annotation [no-untyped-def] apps/job/tests/test_kanban_reorder_priority.py:0: note: Use "-> None" if function does not return a value @@ -2585,38 +2611,169 @@ apps/job/tests/test_kanban_reorder_priority.py:0: error: Function is missing a r apps/job/tests/test_kanban_reorder_priority.py:0: note: Use "-> None" if function does not return a value apps/job/tests/test_kanban_reorder_priority.py:0: error: Call to untyped function "_make_job" in typed context [no-untyped-call] apps/job/tests/test_kanban_reorder_priority.py:0: error: Call to untyped function "_make_job" in typed context [no-untyped-call] -apps/workflow/api/reports/payroll_reconciliation.py:0: error: Module "drf_spectacular.utils" does not explicitly export attribute "OpenApiTypes" [attr-defined] -apps/job/mixins.py:0: error: Missing type arguments for generic type "GenericAPIView" [type-arg] -apps/job/mixins.py:0: error: Function is missing a type annotation [no-untyped-def] -apps/job/mixins.py:0: error: Function is missing a type annotation [no-untyped-def] -apps/job/mixins.py:0: error: Call to untyped function "get_job_by_number" in typed context [no-untyped-call] -apps/job/mixins.py:0: error: Function is missing a type annotation [no-untyped-def] -apps/job/mixins.py:0: error: Call to untyped function "get_job_or_404_response" in typed context [no-untyped-call] -apps/purchasing/serializers.py:0: error: Missing type arguments for generic type "Serializer" [type-arg] -apps/purchasing/serializers.py:0: error: Missing type arguments for generic type "Serializer" [type-arg] -apps/purchasing/serializers.py:0: error: Missing type arguments for generic type "Serializer" [type-arg] -apps/purchasing/serializers.py:0: error: Missing type arguments for generic type "Serializer" [type-arg] -apps/purchasing/serializers.py:0: error: Missing type arguments for generic type "ModelSerializer" [type-arg] -apps/purchasing/serializers.py:0: error: Function is missing a type annotation for one or more parameters [no-untyped-def] -apps/purchasing/serializers.py:0: error: Function is missing a type annotation for one or more parameters [no-untyped-def] -apps/purchasing/serializers.py:0: error: Missing type arguments for generic type "ModelSerializer" [type-arg] -apps/purchasing/serializers.py:0: error: Missing type arguments for generic type "ModelSerializer" [type-arg] -apps/purchasing/serializers.py:0: error: Function is missing a type annotation for one or more parameters [no-untyped-def] -apps/purchasing/serializers.py:0: error: Missing type arguments for generic type "Serializer" [type-arg] -apps/purchasing/serializers.py:0: error: Missing type arguments for generic type "Serializer" [type-arg] -apps/purchasing/serializers.py:0: error: Missing type arguments for generic type "Serializer" [type-arg] -apps/purchasing/serializers.py:0: error: Missing type arguments for generic type "Serializer" [type-arg] -apps/purchasing/serializers.py:0: error: Missing type arguments for generic type "Serializer" [type-arg] -apps/purchasing/serializers.py:0: error: Missing type arguments for generic type "Serializer" [type-arg] -apps/purchasing/serializers.py:0: error: Missing type arguments for generic type "Serializer" [type-arg] -apps/purchasing/serializers.py:0: error: Missing type arguments for generic type "Serializer" [type-arg] -apps/purchasing/serializers.py:0: error: Missing type arguments for generic type "Serializer" [type-arg] -apps/purchasing/serializers.py:0: error: Argument "default" to "DecimalField" has incompatible type "int"; expected "Decimal | Callable[[], Decimal] | _Empty | None" [arg-type] -apps/purchasing/serializers.py:0: error: Missing type arguments for generic type "Serializer" [type-arg] -apps/purchasing/serializers.py:0: error: Argument "default" to "DecimalField" has incompatible type "int"; expected "Decimal | Callable[[], Decimal] | _Empty | None" [arg-type] -apps/purchasing/serializers.py:0: error: Missing type arguments for generic type "Serializer" [type-arg] -apps/purchasing/serializers.py:0: error: Missing type arguments for generic type "Serializer" [type-arg] -apps/purchasing/serializers.py:0: error: Missing type arguments for generic type "Serializer" [type-arg] +apps/workflow/views/session_replay_view.py:0: error: Function is missing a type annotation [no-untyped-def] +apps/workflow/views/session_replay_view.py:0: error: Call to untyped function "has_permission" in typed context [no-untyped-call] +apps/workflow/views/session_replay_view.py:0: error: Function is missing a type annotation [no-untyped-def] +apps/workflow/views/session_replay_view.py:0: error: Function is missing a type annotation [no-untyped-def] +apps/workflow/views/session_replay_view.py:0: error: Function is missing a type annotation [no-untyped-def] +apps/workflow/views/session_replay_view.py:0: error: Function is missing a type annotation [no-untyped-def] +apps/workflow/views/session_replay_view.py:0: error: Function is missing a type annotation [no-untyped-def] +apps/workflow/views/company_defaults_logo_api.py:0: error: Argument 1 to "join" has incompatible type "str | None"; expected "str" [arg-type] +apps/workflow/views/company_defaults_logo_api.py:0: error: Function is missing a type annotation [no-untyped-def] +apps/workflow/views/company_defaults_logo_api.py:0: error: Function is missing a type annotation [no-untyped-def] +apps/workflow/views/company_defaults_logo_api.py:0: error: Call to untyped function "_remove_if_user_uploaded" in typed context [no-untyped-call] +apps/workflow/views/company_defaults_logo_api.py:0: error: Call to untyped function "save" in typed context [no-untyped-call] +apps/workflow/views/company_defaults_logo_api.py:0: error: Function is missing a type annotation [no-untyped-def] +apps/workflow/views/company_defaults_logo_api.py:0: error: Call to untyped function "_remove_if_user_uploaded" in typed context [no-untyped-call] +apps/workflow/views/company_defaults_logo_api.py:0: error: Call to untyped function "save" in typed context [no-untyped-call] +apps/workflow/views/company_defaults_api.py:0: error: Function is missing a type annotation [no-untyped-def] +apps/workflow/views/company_defaults_api.py:0: error: Function is missing a type annotation [no-untyped-def] +apps/workflow/views/company_defaults_api.py:0: error: Function is missing a type annotation [no-untyped-def] +apps/operations/views/workshop_schedule_view.py:0: error: Incompatible types in assignment (expression has type "list[JobProjection]", variable has type "QuerySet[JobProjection, JobProjection]") [assignment] +apps/timesheet/serializers/modern_timesheet_serializers.py:0: error: Missing type arguments for generic type "ModelSerializer" [type-arg] +apps/timesheet/serializers/modern_timesheet_serializers.py:0: error: Function is missing a type annotation for one or more parameters [no-untyped-def] +apps/timesheet/serializers/modern_timesheet_serializers.py:0: error: Function is missing a type annotation for one or more parameters [no-untyped-def] +apps/timesheet/serializers/modern_timesheet_serializers.py:0: error: Function is missing a type annotation for one or more parameters [no-untyped-def] +apps/timesheet/serializers/modern_timesheet_serializers.py:0: error: Missing type arguments for generic type "Serializer" [type-arg] +apps/timesheet/serializers/modern_timesheet_serializers.py:0: error: Missing type arguments for generic type "Serializer" [type-arg] +apps/timesheet/serializers/modern_timesheet_serializers.py:0: error: Missing type arguments for generic type "Serializer" [type-arg] +apps/timesheet/serializers/modern_timesheet_serializers.py:0: error: Missing type arguments for generic type "Serializer" [type-arg] +apps/timesheet/serializers/modern_timesheet_serializers.py:0: error: Missing type arguments for generic type "Serializer" [type-arg] +apps/timesheet/serializers/modern_timesheet_serializers.py:0: error: Missing type arguments for generic type "Serializer" [type-arg] +apps/timesheet/serializers/modern_timesheet_serializers.py:0: error: Missing type arguments for generic type "Serializer" [type-arg] +apps/timesheet/serializers/modern_timesheet_serializers.py:0: error: Function is missing a type annotation [no-untyped-def] +apps/timesheet/serializers/modern_timesheet_serializers.py:0: error: Missing type arguments for generic type "Serializer" [type-arg] +apps/timesheet/serializers/modern_timesheet_serializers.py:0: error: Missing type arguments for generic type "Serializer" [type-arg] +apps/timesheet/serializers/modern_timesheet_serializers.py:0: error: Missing type arguments for generic type "Serializer" [type-arg] +apps/timesheet/serializers/modern_timesheet_serializers.py:0: error: Function is missing a type annotation [no-untyped-def] +apps/timesheet/serializers/modern_timesheet_serializers.py:0: error: Missing type arguments for generic type "Serializer" [type-arg] +apps/timesheet/serializers/modern_timesheet_serializers.py:0: error: Function is missing a type annotation [no-untyped-def] +apps/timesheet/serializers/modern_timesheet_serializers.py:0: error: Missing type arguments for generic type "Serializer" [type-arg] +apps/timesheet/serializers/modern_timesheet_serializers.py:0: error: Function is missing a type annotation [no-untyped-def] +apps/timesheet/serializers/modern_timesheet_serializers.py:0: error: Missing type arguments for generic type "Serializer" [type-arg] +apps/timesheet/serializers/modern_timesheet_serializers.py:0: error: Missing type arguments for generic type "Serializer" [type-arg] +apps/timesheet/serializers/modern_timesheet_serializers.py:0: error: Missing type arguments for generic type "Serializer" [type-arg] +apps/job/views/job_profitability_report_views.py:0: error: Function is missing a type annotation for one or more parameters [no-untyped-def] +apps/job/views/job_profitability_report_views.py:0: error: Missing return statement [return] +apps/job/views/job_files_collection_view.py:0: error: Function is missing a type annotation [no-untyped-def] +apps/job/views/job_files_collection_view.py:0: error: Argument 1 to "join" has incompatible type "str | None"; expected "str" [arg-type] +apps/job/views/job_files_collection_view.py:0: error: "Callable[[str], None]" has no attribute "delay" [attr-defined] +apps/job/views/job_files_collection_view.py:0: error: Argument "user_id" to "persist_app_error" has incompatible type "str | None"; expected "str" [arg-type] +apps/job/views/job_files_collection_view.py:0: error: Function is missing a type annotation [no-untyped-def] +apps/job/views/job_files_collection_view.py:0: error: Call to untyped function "save_file" in typed context [no-untyped-call] +apps/job/views/job_files_collection_view.py:0: error: Function is missing a type annotation [no-untyped-def] +apps/job/views/job_file_thumbnail_view.py:0: error: Function is missing a type annotation [no-untyped-def] +apps/job/views/job_file_detail_view.py:0: error: Function is missing a type annotation [no-untyped-def] +apps/job/views/job_file_detail_view.py:0: error: Function is missing a type annotation [no-untyped-def] +apps/job/views/job_file_detail_view.py:0: error: Argument 1 to "join" has incompatible type "str | None"; expected "str" [arg-type] +apps/job/views/job_file_detail_view.py:0: error: Function is missing a type annotation [no-untyped-def] +apps/job/views/job_file_detail_view.py:0: error: Argument 1 to "join" has incompatible type "str | None"; expected "str" [arg-type] +apps/job/views/job_file_detail_view.py:0: error: Function is missing a type annotation [no-untyped-def] +apps/job/views/job_file_detail_view.py:0: error: Argument 1 to "join" has incompatible type "str | None"; expected "str" [arg-type] +apps/job/views/data_quality_report_views.py:0: error: Function is missing a type annotation for one or more parameters [no-untyped-def] +apps/workflow/authentication.py:0: error: Function is missing a type annotation for one or more parameters [no-untyped-def] +apps/workflow/authentication.py:0: error: Returning Any from function declared to return "bool" [no-any-return] +apps/workflow/authentication.py:0: error: Function is missing a type annotation [no-untyped-def] +apps/workflow/authentication.py:0: error: Call to untyped function "get_raw_token_from_cookie" in typed context [no-untyped-call] +apps/workflow/authentication.py:0: error: "AbstractBaseUser" has no attribute "is_currently_active" [attr-defined] +apps/workflow/authentication.py:0: error: Function is missing a type annotation [no-untyped-def] +apps/workflow/authentication.py:0: error: Function is missing a type annotation [no-untyped-def] +apps/workflow/authentication.py:0: error: Call to untyped function "mark_used" in typed context [no-untyped-call] +apps/workflow/authentication.py:0: error: Function is missing a type annotation [no-untyped-def] +apps/workflow/authentication.py:0: error: Function is missing a type annotation [no-untyped-def] +apps/workflow/authentication.py:0: error: Function is missing a type annotation [no-untyped-def] +apps/workflow/authentication.py:0: error: Call to untyped function "mark_used" in typed context [no-untyped-call] +apps/workflow/extensions.py:0: error: Call to untyped function "__init_subclass__" in typed context [no-untyped-call] +apps/workflow/extensions.py:0: error: Function is missing a type annotation [no-untyped-def] +apps/accounts/serializers.py:0: error: Missing type arguments for generic type "Serializer" [type-arg] +apps/accounts/serializers.py:0: error: Returning Any from function declared to return "dict[str, Any]" [no-any-return] +apps/accounts/serializers.py:0: error: "to_internal_value" undefined in superclass [misc] +apps/accounts/serializers.py:0: error: Missing type arguments for generic type "ModelSerializer" [type-arg] +apps/accounts/serializers.py:0: error: Returning Any from function declared to return "Staff" [no-any-return] +apps/accounts/serializers.py:0: error: Returning Any from function declared to return "Staff" [no-any-return] +apps/accounts/serializers.py:0: error: Function is missing a type annotation [no-untyped-def] +apps/accounts/serializers.py:0: error: Missing type arguments for generic type "ModelSerializer" [type-arg] +apps/accounts/serializers.py:0: error: Missing type arguments for generic type "ModelSerializer" [type-arg] +apps/accounts/serializers.py:0: error: Missing type arguments for generic type "Serializer" [type-arg] +apps/accounts/serializers.py:0: error: Missing type arguments for generic type "Serializer" [type-arg] +apps/job/mixins.py:0: error: Missing type arguments for generic type "GenericAPIView" [type-arg] +apps/job/mixins.py:0: error: Function is missing a type annotation [no-untyped-def] +apps/job/mixins.py:0: error: Function is missing a type annotation [no-untyped-def] +apps/job/mixins.py:0: error: Call to untyped function "get_job_by_number" in typed context [no-untyped-call] +apps/job/mixins.py:0: error: Function is missing a type annotation [no-untyped-def] +apps/job/mixins.py:0: error: Call to untyped function "get_job_or_404_response" in typed context [no-untyped-call] +apps/workflow/views/xero_pay_item_viewset.py:0: error: Missing type arguments for generic type "ReadOnlyModelViewSet" [type-arg] +apps/workflow/views/app_error_view.py:0: error: Missing type arguments for generic type "ListAPIView" [type-arg] +apps/workflow/views/app_error_view.py:0: error: Missing type arguments for generic type "RetrieveAPIView" [type-arg] +apps/workflow/views/app_error_view.py:0: error: Function is missing a type annotation [no-untyped-def] +apps/workflow/views/app_error_view.py:0: error: Missing type arguments for generic type "ReadOnlyModelViewSet" [type-arg] +apps/workflow/views/app_error_view.py:0: error: Function is missing a type annotation [no-untyped-def] +apps/workflow/views/app_error_view.py:0: error: Function is missing a type annotation [no-untyped-def] +apps/workflow/views/ai_provider_viewset.py:0: error: Missing type arguments for generic type "ModelViewSet" [type-arg] +apps/workflow/views/ai_provider_viewset.py:0: error: Function is missing a return type annotation [no-untyped-def] +apps/workflow/views/ai_provider_viewset.py:0: error: Function is missing a return type annotation [no-untyped-def] +apps/workflow/views/ai_provider_viewset.py:0: error: Function is missing a type annotation [no-untyped-def] +apps/workflow/views/ai_provider_viewset.py:0: error: Function is missing a type annotation [no-untyped-def] +apps/quoting/views_scheduled_tasks.py:0: error: Missing type arguments for generic type "ReadOnlyModelViewSet" [type-arg] +apps/quoting/views_scheduled_tasks.py:0: error: Missing type arguments for generic type "ReadOnlyModelViewSet" [type-arg] +apps/process/views/form_viewsets.py:0: error: Function is missing a type annotation [no-untyped-def] +apps/process/views/form_viewsets.py:0: error: Function is missing a type annotation [no-untyped-def] +apps/process/views/form_viewsets.py:0: error: Missing type arguments for generic type "GenericViewSet" [type-arg] +apps/process/views/form_viewsets.py:0: error: Function is missing a return type annotation [no-untyped-def] +apps/process/views/form_viewsets.py:0: error: Function is missing a return type annotation [no-untyped-def] +apps/process/views/form_viewsets.py:0: error: Function is missing a return type annotation [no-untyped-def] +apps/process/views/form_viewsets.py:0: error: Call to untyped function "_apply_category_filter" in typed context [no-untyped-call] +apps/process/views/form_viewsets.py:0: error: Call to untyped function "_apply_common_filters" in typed context [no-untyped-call] +apps/process/views/form_viewsets.py:0: error: Function is missing a type annotation [no-untyped-def] +apps/process/views/form_viewsets.py:0: error: Function is missing a type annotation [no-untyped-def] +apps/process/views/form_viewsets.py:0: error: Function is missing a type annotation [no-untyped-def] +apps/process/views/form_viewsets.py:0: error: Argument "document_type" to "create_form" of "FormService" has incompatible type "Sequence[str]"; expected "str" [arg-type] +apps/process/views/form_viewsets.py:0: error: Function is missing a type annotation [no-untyped-def] +apps/process/views/form_viewsets.py:0: error: Function is missing a type annotation [no-untyped-def] +apps/process/views/form_viewsets.py:0: error: Call to untyped function "update" of "FormViewSet" in typed context [no-untyped-call] +apps/process/views/form_viewsets.py:0: error: Missing type arguments for generic type "GenericViewSet" [type-arg] +apps/process/views/form_viewsets.py:0: error: Function is missing a return type annotation [no-untyped-def] +apps/process/views/form_viewsets.py:0: error: Function is missing a return type annotation [no-untyped-def] +apps/process/views/form_viewsets.py:0: error: Function is missing a type annotation [no-untyped-def] +apps/process/views/form_viewsets.py:0: error: Function is missing a type annotation [no-untyped-def] +apps/process/views/form_viewsets.py:0: error: Function is missing a type annotation [no-untyped-def] +apps/process/views/form_viewsets.py:0: error: Call to untyped function "get_form" in typed context [no-untyped-call] +apps/process/views/form_viewsets.py:0: error: Function is missing a type annotation [no-untyped-def] +apps/process/views/form_viewsets.py:0: error: Function is missing a type annotation [no-untyped-def] +apps/process/views/form_viewsets.py:0: error: Function is missing a type annotation [no-untyped-def] +apps/workflow/tests/test_api_schema_coverage.py:0: error: Function is missing a type annotation [no-untyped-def] +apps/workflow/tests/test_api_schema_coverage.py:0: error: Call to untyped function "_get_all_url_patterns" in typed context [no-untyped-call] +apps/workflow/tests/test_api_schema_coverage.py:0: error: Missing type arguments for generic type "set" [type-arg] +apps/workflow/tests/test_api_schema_coverage.py:0: error: Call to untyped function "SchemaGenerator" in typed context [no-untyped-call] +apps/workflow/tests/test_api_schema_coverage.py:0: error: Call to untyped function "get_schema" in typed context [no-untyped-call] +apps/workflow/tests/test_api_schema_coverage.py:0: error: Function is missing a return type annotation [no-untyped-def] +apps/workflow/tests/test_api_schema_coverage.py:0: note: Use "-> None" if function does not return a value +apps/workflow/tests/test_api_schema_coverage.py:0: error: Call to untyped function "_get_all_url_patterns" in typed context [no-untyped-call] +apps/purchasing/serializers.py:0: error: Missing type arguments for generic type "Serializer" [type-arg] +apps/purchasing/serializers.py:0: error: Missing type arguments for generic type "Serializer" [type-arg] +apps/purchasing/serializers.py:0: error: Missing type arguments for generic type "Serializer" [type-arg] +apps/purchasing/serializers.py:0: error: Missing type arguments for generic type "Serializer" [type-arg] +apps/purchasing/serializers.py:0: error: Missing type arguments for generic type "ModelSerializer" [type-arg] +apps/purchasing/serializers.py:0: error: Function is missing a type annotation for one or more parameters [no-untyped-def] +apps/purchasing/serializers.py:0: error: Function is missing a type annotation for one or more parameters [no-untyped-def] +apps/purchasing/serializers.py:0: error: Missing type arguments for generic type "ModelSerializer" [type-arg] +apps/purchasing/serializers.py:0: error: Missing type arguments for generic type "ModelSerializer" [type-arg] +apps/purchasing/serializers.py:0: error: Function is missing a type annotation for one or more parameters [no-untyped-def] +apps/purchasing/serializers.py:0: error: Missing type arguments for generic type "Serializer" [type-arg] +apps/purchasing/serializers.py:0: error: Missing type arguments for generic type "Serializer" [type-arg] +apps/purchasing/serializers.py:0: error: Missing type arguments for generic type "Serializer" [type-arg] +apps/purchasing/serializers.py:0: error: Missing type arguments for generic type "Serializer" [type-arg] +apps/purchasing/serializers.py:0: error: Missing type arguments for generic type "Serializer" [type-arg] +apps/purchasing/serializers.py:0: error: Missing type arguments for generic type "Serializer" [type-arg] +apps/purchasing/serializers.py:0: error: Missing type arguments for generic type "Serializer" [type-arg] +apps/purchasing/serializers.py:0: error: Missing type arguments for generic type "Serializer" [type-arg] +apps/purchasing/serializers.py:0: error: Missing type arguments for generic type "Serializer" [type-arg] +apps/purchasing/serializers.py:0: error: Argument "default" to "DecimalField" has incompatible type "int"; expected "Decimal | Callable[[], Decimal] | _Empty | None" [arg-type] +apps/purchasing/serializers.py:0: error: Missing type arguments for generic type "Serializer" [type-arg] +apps/purchasing/serializers.py:0: error: Argument "default" to "DecimalField" has incompatible type "int"; expected "Decimal | Callable[[], Decimal] | _Empty | None" [arg-type] +apps/purchasing/serializers.py:0: error: Missing type arguments for generic type "Serializer" [type-arg] +apps/purchasing/serializers.py:0: error: Missing type arguments for generic type "Serializer" [type-arg] +apps/purchasing/serializers.py:0: error: Missing type arguments for generic type "Serializer" [type-arg] apps/purchasing/serializers.py:0: error: Missing type arguments for generic type "Serializer" [type-arg] apps/purchasing/serializers.py:0: error: Missing type arguments for generic type "Serializer" [type-arg] apps/purchasing/serializers.py:0: error: Missing type arguments for generic type "Serializer" [type-arg] @@ -2760,760 +2917,96 @@ apps/job/serializers/job_serializer.py:0: error: Missing type arguments for gene apps/job/serializers/job_serializer.py:0: error: Missing type arguments for generic type "Serializer" [type-arg] apps/job/serializers/job_serializer.py:0: error: Missing type arguments for generic type "Serializer" [type-arg] apps/job/serializers/job_serializer.py:0: error: Missing type arguments for generic type "Serializer" [type-arg] -apps/job/views/workshop_view.py:0: error: Module "drf_spectacular.utils" does not explicitly export attribute "OpenApiTypes" [attr-defined] -apps/job/views/workshop_view.py:0: error: Missing type arguments for generic type "ListAPIView" [type-arg] -apps/job/views/workshop_view.py:0: error: Function is missing a return type annotation [no-untyped-def] -apps/job/views/workshop_view.py:0: error: Incompatible type for lookup 'people__id': (got "UUID | None", expected "UUID | str") [misc] -apps/job/views/workshop_view.py:0: error: Function is missing a type annotation [no-untyped-def] -apps/job/views/workshop_view.py:0: error: Function is missing a type annotation [no-untyped-def] -apps/job/views/workshop_view.py:0: error: Function is missing a type annotation [no-untyped-def] -apps/job/views/workshop_view.py:0: error: Argument "job_id" to "persist_app_error" has incompatible type "Any | None"; expected "str" [arg-type] -apps/job/views/workshop_view.py:0: error: Function is missing a type annotation [no-untyped-def] -apps/client/views/client_rest_views.py:0: error: Module "drf_spectacular.utils" does not explicitly export attribute "OpenApiTypes" [attr-defined] -apps/client/views/client_rest_views.py:0: error: Argument 1 to "get_client_by_id" of "ClientRestService" has incompatible type "str"; expected "UUID" [arg-type] -apps/client/views/client_rest_views.py:0: error: Function is missing a return type annotation [no-untyped-def] -apps/client/views/client_rest_views.py:0: error: Argument 1 to "update_client" of "ClientRestService" has incompatible type "str"; expected "UUID" [arg-type] -apps/client/views/client_rest_views.py:0: error: Function is missing a return type annotation [no-untyped-def] -apps/client/views/client_rest_views.py:0: error: Incompatible types in assignment (expression has type "ClientDuplicateErrorResponseSerializer", variable has type "ClientErrorResponseSerializer") [assignment] -apps/client/views/client_rest_views.py:0: error: Function is missing a return type annotation [no-untyped-def] -apps/client/views/client_rest_views.py:0: error: Argument 1 to "get_job_contact" of "ClientRestService" has incompatible type "str"; expected "UUID" [arg-type] -apps/client/views/client_rest_views.py:0: error: Argument 1 to "update_job_contact" of "ClientRestService" has incompatible type "str"; expected "UUID" [arg-type] -apps/client/views/client_rest_views.py:0: error: Argument 3 to "update_job_contact" of "ClientRestService" has incompatible type "Staff | AnonymousUser"; expected "Staff" [arg-type] -apps/client/views/client_rest_views.py:0: error: Argument 1 to "get_client_jobs" of "ClientRestService" has incompatible type "str"; expected "UUID" [arg-type] -apps/client/tests/test_client_fts_search.py:0: error: Function is missing a return type annotation [no-untyped-def] -apps/client/tests/test_client_fts_search.py:0: error: Function is missing a type annotation for one or more parameters [no-untyped-def] -apps/client/tests/test_client_fts_search.py:0: error: Function is missing a return type annotation [no-untyped-def] -apps/client/tests/test_client_fts_search.py:0: error: Function is missing a type annotation [no-untyped-def] -apps/client/tests/test_client_fts_search.py:0: error: Function is missing a type annotation [no-untyped-def] -apps/client/tests/test_client_fts_search.py:0: error: Function is missing a type annotation [no-untyped-def] -apps/client/tests/test_client_fts_search.py:0: error: Function is missing a type annotation [no-untyped-def] -apps/client/tests/test_client_fts_search.py:0: error: Function is missing a type annotation [no-untyped-def] -apps/client/tests/test_client_fts_search.py:0: error: Function is missing a type annotation [no-untyped-def] -apps/client/tests/test_client_fts_search.py:0: error: Function is missing a type annotation [no-untyped-def] -apps/client/tests/test_client_fts_search.py:0: error: Function is missing a type annotation [no-untyped-def] -apps/client/tests/test_client_fts_search.py:0: error: Function is missing a type annotation [no-untyped-def] -apps/client/tests/test_client_fts_search.py:0: error: Function is missing a type annotation [no-untyped-def] -apps/client/tests/test_client_fts_search.py:0: error: Function is missing a type annotation [no-untyped-def] -apps/client/tests/test_client_fts_search.py:0: error: Function is missing a type annotation [no-untyped-def] -apps/client/tests/test_client_fts_search.py:0: error: Function is missing a type annotation [no-untyped-def] -apps/client/tests/test_client_fts_search.py:0: error: Function is missing a type annotation [no-untyped-def] -apps/client/tests/test_client_fts_search.py:0: error: Function is missing a type annotation [no-untyped-def] -apps/client/tests/test_client_fts_search.py:0: error: Function is missing a type annotation [no-untyped-def] -apps/client/tests/test_client_fts_search.py:0: error: Function is missing a type annotation [no-untyped-def] -apps/client/tests/test_client_fts_search.py:0: error: Function is missing a type annotation [no-untyped-def] -apps/client/tests/test_client_fts_search.py:0: error: Function is missing a type annotation [no-untyped-def] -apps/client/tests/test_client_fts_search.py:0: error: Incompatible types in assignment (expression has type "None", variable has type "Staff | AnonymousUser") [assignment] -apps/client/tests/test_client_fts_search.py:0: error: Function is missing a type annotation [no-untyped-def] -apps/accounting/views/kpi_view.py:0: error: Module "drf_spectacular.utils" does not explicitly export attribute "OpenApiTypes" [attr-defined] -apps/accounting/tests/test_sales_pipeline_service.py:0: error: "SalesPipelineServiceFixturesMixin" has no attribute "test_staff" [attr-defined] -apps/accounting/tests/test_sales_pipeline_service.py:0: error: "SalesPipelineServiceFixturesMixin" has no attribute "test_staff" [attr-defined] -apps/accounting/tests/test_sales_pipeline_service.py:0: error: "SalesPipelineServiceFixturesMixin" has no attribute "test_staff" [attr-defined] -apps/accounting/tests/test_sales_pipeline_service.py:0: error: "SalesPipelineServiceFixturesMixin" has no attribute "test_staff" [attr-defined] -apps/accounting/tests/test_sales_pipeline_service.py:0: error: "SalesPipelineServiceFixturesMixin" has no attribute "test_staff" [attr-defined] -apps/accounting/tests/test_sales_pipeline_service.py:0: error: "SalesPipelineServiceFixturesMixin" has no attribute "test_staff" [attr-defined] -apps/accounting/tests/test_sales_pipeline_service.py:0: error: Call to untyped function "save" in typed context [no-untyped-call] -apps/accounting/tests/test_sales_pipeline_service.py:0: error: Function is missing a return type annotation [no-untyped-def] -apps/accounting/tests/test_sales_pipeline_service.py:0: note: Use "-> None" if function does not return a value -apps/accounting/tests/test_sales_pipeline_service.py:0: error: Function is missing a return type annotation [no-untyped-def] -apps/accounting/tests/test_sales_pipeline_service.py:0: note: Use "-> None" if function does not return a value -apps/accounting/tests/test_sales_pipeline_service.py:0: error: Function is missing a return type annotation [no-untyped-def] -apps/accounting/tests/test_sales_pipeline_service.py:0: note: Use "-> None" if function does not return a value -apps/accounting/tests/test_sales_pipeline_service.py:0: error: Function is missing a return type annotation [no-untyped-def] -apps/accounting/tests/test_sales_pipeline_service.py:0: note: Use "-> None" if function does not return a value -apps/accounting/tests/test_sales_pipeline_service.py:0: error: Function is missing a return type annotation [no-untyped-def] -apps/accounting/tests/test_sales_pipeline_service.py:0: note: Use "-> None" if function does not return a value -apps/accounting/tests/test_sales_pipeline_service.py:0: error: Call to untyped function "save" in typed context [no-untyped-call] -apps/accounting/tests/test_sales_pipeline_service.py:0: error: Function is missing a return type annotation [no-untyped-def] -apps/accounting/tests/test_sales_pipeline_service.py:0: note: Use "-> None" if function does not return a value -apps/accounting/tests/test_sales_pipeline_service.py:0: error: Function is missing a return type annotation [no-untyped-def] -apps/accounting/tests/test_sales_pipeline_service.py:0: note: Use "-> None" if function does not return a value -apps/accounting/tests/test_sales_pipeline_service.py:0: error: Function is missing a return type annotation [no-untyped-def] -apps/accounting/tests/test_sales_pipeline_service.py:0: note: Use "-> None" if function does not return a value -apps/accounting/tests/test_sales_pipeline_service.py:0: error: Function is missing a return type annotation [no-untyped-def] -apps/accounting/tests/test_sales_pipeline_service.py:0: note: Use "-> None" if function does not return a value -apps/accounting/tests/test_sales_pipeline_service.py:0: error: Function is missing a return type annotation [no-untyped-def] -apps/accounting/tests/test_sales_pipeline_service.py:0: note: Use "-> None" if function does not return a value -apps/accounting/tests/test_sales_pipeline_service.py:0: error: Function is missing a return type annotation [no-untyped-def] -apps/accounting/tests/test_sales_pipeline_service.py:0: note: Use "-> None" if function does not return a value -apps/accounting/tests/test_sales_pipeline_service.py:0: error: Function is missing a return type annotation [no-untyped-def] -apps/accounting/tests/test_sales_pipeline_service.py:0: note: Use "-> None" if function does not return a value -apps/accounting/tests/test_sales_pipeline_service.py:0: error: Function is missing a return type annotation [no-untyped-def] -apps/accounting/tests/test_sales_pipeline_service.py:0: note: Use "-> None" if function does not return a value -apps/accounting/tests/test_sales_pipeline_service.py:0: error: Function is missing a return type annotation [no-untyped-def] -apps/accounting/tests/test_sales_pipeline_service.py:0: note: Use "-> None" if function does not return a value -apps/accounting/tests/test_sales_pipeline_service.py:0: error: Function is missing a return type annotation [no-untyped-def] -apps/accounting/tests/test_sales_pipeline_service.py:0: note: Use "-> None" if function does not return a value -apps/accounting/tests/test_sales_pipeline_service.py:0: error: Function is missing a return type annotation [no-untyped-def] -apps/accounting/tests/test_sales_pipeline_service.py:0: note: Use "-> None" if function does not return a value -apps/accounting/tests/test_sales_pipeline_service.py:0: error: Function is missing a return type annotation [no-untyped-def] -apps/accounting/tests/test_sales_pipeline_service.py:0: note: Use "-> None" if function does not return a value -apps/accounting/tests/test_sales_pipeline_service.py:0: error: Call to untyped function "save" in typed context [no-untyped-call] -apps/accounting/tests/test_sales_pipeline_service.py:0: error: Function is missing a return type annotation [no-untyped-def] -apps/accounting/tests/test_sales_pipeline_service.py:0: note: Use "-> None" if function does not return a value -apps/accounting/tests/test_sales_pipeline_service.py:0: error: Function is missing a return type annotation [no-untyped-def] -apps/accounting/tests/test_sales_pipeline_service.py:0: note: Use "-> None" if function does not return a value -apps/accounting/tests/test_sales_pipeline_service.py:0: error: Function is missing a return type annotation [no-untyped-def] -apps/accounting/tests/test_sales_pipeline_service.py:0: note: Use "-> None" if function does not return a value -apps/workflow/authentication.py:0: error: Function is missing a type annotation for one or more parameters [no-untyped-def] -apps/workflow/authentication.py:0: error: Returning Any from function declared to return "bool" [no-any-return] -apps/workflow/authentication.py:0: error: Function is missing a type annotation [no-untyped-def] -apps/workflow/authentication.py:0: error: Call to untyped function "get_raw_token_from_cookie" in typed context [no-untyped-call] -apps/workflow/authentication.py:0: error: "AbstractBaseUser" has no attribute "is_currently_active" [attr-defined] -apps/workflow/authentication.py:0: error: Function is missing a type annotation [no-untyped-def] -apps/workflow/authentication.py:0: error: Function is missing a type annotation [no-untyped-def] -apps/workflow/authentication.py:0: error: Call to untyped function "mark_used" in typed context [no-untyped-call] -apps/workflow/authentication.py:0: error: Function is missing a type annotation [no-untyped-def] -apps/workflow/authentication.py:0: error: Function is missing a type annotation [no-untyped-def] -apps/workflow/authentication.py:0: error: Function is missing a type annotation [no-untyped-def] -apps/workflow/authentication.py:0: error: Call to untyped function "mark_used" in typed context [no-untyped-call] -apps/accounts/serializers.py:0: error: Missing type arguments for generic type "Serializer" [type-arg] -apps/accounts/serializers.py:0: error: Returning Any from function declared to return "dict[str, Any]" [no-any-return] -apps/accounts/serializers.py:0: error: "to_internal_value" undefined in superclass [misc] -apps/accounts/serializers.py:0: error: Missing type arguments for generic type "ModelSerializer" [type-arg] -apps/accounts/serializers.py:0: error: Returning Any from function declared to return "Staff" [no-any-return] -apps/accounts/serializers.py:0: error: Returning Any from function declared to return "Staff" [no-any-return] -apps/accounts/serializers.py:0: error: Function is missing a type annotation [no-untyped-def] -apps/accounts/serializers.py:0: error: Missing type arguments for generic type "ModelSerializer" [type-arg] -apps/accounts/serializers.py:0: error: Missing type arguments for generic type "ModelSerializer" [type-arg] -apps/accounts/serializers.py:0: error: Missing type arguments for generic type "Serializer" [type-arg] -apps/accounts/serializers.py:0: error: Missing type arguments for generic type "Serializer" [type-arg] -apps/workflow/views/xero_pay_item_viewset.py:0: error: Missing type arguments for generic type "ReadOnlyModelViewSet" [type-arg] -apps/workflow/views/app_error_view.py:0: error: Missing type arguments for generic type "ListAPIView" [type-arg] -apps/workflow/views/app_error_view.py:0: error: Missing type arguments for generic type "RetrieveAPIView" [type-arg] -apps/workflow/views/app_error_view.py:0: error: Function is missing a type annotation [no-untyped-def] -apps/workflow/views/app_error_view.py:0: error: Missing type arguments for generic type "ReadOnlyModelViewSet" [type-arg] -apps/workflow/views/app_error_view.py:0: error: Function is missing a type annotation [no-untyped-def] -apps/workflow/views/app_error_view.py:0: error: Function is missing a type annotation [no-untyped-def] -apps/workflow/views/ai_provider_viewset.py:0: error: Missing type arguments for generic type "ModelViewSet" [type-arg] -apps/workflow/views/ai_provider_viewset.py:0: error: Function is missing a return type annotation [no-untyped-def] -apps/workflow/views/ai_provider_viewset.py:0: error: Function is missing a return type annotation [no-untyped-def] -apps/workflow/views/ai_provider_viewset.py:0: error: Function is missing a type annotation [no-untyped-def] -apps/workflow/views/ai_provider_viewset.py:0: error: Function is missing a type annotation [no-untyped-def] -apps/quoting/views_scheduled_tasks.py:0: error: Missing type arguments for generic type "ReadOnlyModelViewSet" [type-arg] -apps/quoting/views_scheduled_tasks.py:0: error: Missing type arguments for generic type "ReadOnlyModelViewSet" [type-arg] -apps/process/views/form_viewsets.py:0: error: Function is missing a type annotation [no-untyped-def] -apps/process/views/form_viewsets.py:0: error: Function is missing a type annotation [no-untyped-def] -apps/process/views/form_viewsets.py:0: error: Missing type arguments for generic type "GenericViewSet" [type-arg] -apps/process/views/form_viewsets.py:0: error: Function is missing a return type annotation [no-untyped-def] -apps/process/views/form_viewsets.py:0: error: Function is missing a return type annotation [no-untyped-def] -apps/process/views/form_viewsets.py:0: error: Function is missing a return type annotation [no-untyped-def] -apps/process/views/form_viewsets.py:0: error: Call to untyped function "_apply_category_filter" in typed context [no-untyped-call] -apps/process/views/form_viewsets.py:0: error: Call to untyped function "_apply_common_filters" in typed context [no-untyped-call] -apps/process/views/form_viewsets.py:0: error: Function is missing a type annotation [no-untyped-def] -apps/process/views/form_viewsets.py:0: error: Function is missing a type annotation [no-untyped-def] -apps/process/views/form_viewsets.py:0: error: Function is missing a type annotation [no-untyped-def] -apps/process/views/form_viewsets.py:0: error: Argument "document_type" to "create_form" of "FormService" has incompatible type "Sequence[str]"; expected "str" [arg-type] -apps/process/views/form_viewsets.py:0: error: Function is missing a type annotation [no-untyped-def] -apps/process/views/form_viewsets.py:0: error: Function is missing a type annotation [no-untyped-def] -apps/process/views/form_viewsets.py:0: error: Call to untyped function "update" of "FormViewSet" in typed context [no-untyped-call] -apps/process/views/form_viewsets.py:0: error: Missing type arguments for generic type "GenericViewSet" [type-arg] -apps/process/views/form_viewsets.py:0: error: Function is missing a return type annotation [no-untyped-def] -apps/process/views/form_viewsets.py:0: error: Function is missing a return type annotation [no-untyped-def] -apps/process/views/form_viewsets.py:0: error: Function is missing a type annotation [no-untyped-def] -apps/process/views/form_viewsets.py:0: error: Function is missing a type annotation [no-untyped-def] -apps/process/views/form_viewsets.py:0: error: Function is missing a type annotation [no-untyped-def] -apps/process/views/form_viewsets.py:0: error: Call to untyped function "get_form" in typed context [no-untyped-call] -apps/process/views/form_viewsets.py:0: error: Function is missing a type annotation [no-untyped-def] -apps/process/views/form_viewsets.py:0: error: Function is missing a type annotation [no-untyped-def] -apps/process/views/form_viewsets.py:0: error: Function is missing a type annotation [no-untyped-def] -apps/client/views/supplier_pickup_address_viewset.py:0: error: Missing type arguments for generic type "ModelViewSet" [type-arg] -apps/client/views/supplier_pickup_address_viewset.py:0: error: Function is missing a type annotation [no-untyped-def] -apps/client/views/supplier_pickup_address_viewset.py:0: error: Function is missing a return type annotation [no-untyped-def] -apps/client/views/supplier_pickup_address_viewset.py:0: error: Function is missing a type annotation [no-untyped-def] -apps/client/views/client_contact_viewset.py:0: error: Missing type arguments for generic type "ModelViewSet" [type-arg] -apps/client/views/client_contact_viewset.py:0: error: Function is missing a type annotation [no-untyped-def] -apps/client/views/client_contact_viewset.py:0: error: Function is missing a return type annotation [no-untyped-def] -apps/client/views/client_contact_viewset.py:0: error: Function is missing a type annotation [no-untyped-def] -apps/client/views/client_contact_method_viewset.py:0: error: Missing type arguments for generic type "ModelViewSet" [type-arg] -apps/client/views/client_contact_method_viewset.py:0: error: Function is missing a type annotation [no-untyped-def] -apps/client/views/client_contact_method_viewset.py:0: error: Function is missing a return type annotation [no-untyped-def] -apps/client/views/client_contact_method_viewset.py:0: error: Incompatible type for lookup 'contact_id': (got "str", expected "ClientContact | UUID | None") [misc] -apps/purchasing/views/supplier_search_rest_view.py:0: error: Module "drf_spectacular.utils" does not explicitly export attribute "OpenApiTypes" [attr-defined] -apps/purchasing/tests/test_serializers.py:0: error: Function is missing a return type annotation [no-untyped-def] -apps/purchasing/tests/test_serializers.py:0: note: Use "-> None" if function does not return a value -apps/purchasing/tests/test_serializers.py:0: error: Function is missing a return type annotation [no-untyped-def] -apps/purchasing/tests/test_serializers.py:0: note: Use "-> None" if function does not return a value -apps/purchasing/tests/test_serializers.py:0: error: "PurchaseOrder" has no attribute "detail_lines" [attr-defined] -apps/purchasing/tests/test_serializers.py:0: error: Function is missing a return type annotation [no-untyped-def] -apps/purchasing/tests/test_serializers.py:0: note: Use "-> None" if function does not return a value -apps/purchasing/tests/test_serializers.py:0: error: "PurchaseOrder" has no attribute "detail_lines" [attr-defined] -apps/purchasing/services/stock_search_service.py:0: error: Argument 1 to "add" of "set" has incompatible type "tuple[float, ...]"; expected "tuple[float, float]" [arg-type] -apps/purchasing/services/stock_search_service.py:0: error: Argument 1 to "add" of "set" has incompatible type "tuple[float, ...]"; expected "tuple[float, float]" [arg-type] -apps/purchasing/services/stock_search_service.py:0: error: Function is missing a return type annotation [no-untyped-def] -apps/purchasing/services/stock_search_service.py:0: error: Function is missing a type annotation for one or more parameters [no-untyped-def] -apps/purchasing/services/stock_search_service.py:0: error: Incompatible return value type (got "ReturnDict[Any, Any]", expected "list[dict[str, Any]]") [return-value] -apps/purchasing/services/stock_search_service.py:0: error: Missing return statement [return] -apps/purchasing/services/stock_search_service.py:0: error: Missing return statement [return] -apps/purchasing/services/stock_search_service.py:0: error: Incompatible types in assignment (expression has type "QuerySet[Stock, Stock]", variable has type "list[Stock]") [assignment] -apps/job/views/job_costline_views.py:0: error: Function is missing a return type annotation [no-untyped-def] -apps/job/views/job_costline_views.py:0: error: Function is missing a type annotation [no-untyped-def] -apps/job/views/job_costline_views.py:0: error: Dict entry 0 has incompatible type "str": "str | _StrPromise"; expected "str": "str" [dict-item] -apps/job/views/job_costline_views.py:0: error: Function is missing a return type annotation [no-untyped-def] -apps/job/views/job_costline_views.py:0: error: Function is missing a type annotation [no-untyped-def] -apps/job/views/job_costline_views.py:0: error: Function is missing a type annotation for one or more parameters [no-untyped-def] -apps/job/views/job_costline_views.py:0: error: Function is missing a type annotation [no-untyped-def] -apps/job/views/job_costline_views.py:0: error: Call to untyped function "delete" in typed context [no-untyped-call] -apps/job/views/job_costline_views.py:0: error: Function is missing a type annotation [no-untyped-def] -apps/job/views/job_costline_views.py:0: error: Function is missing a return type annotation [no-untyped-def] -apps/job/views/job_costline_views.py:0: error: Function is missing a type annotation for one or more parameters [no-untyped-def] -apps/job/views/job_costline_views.py:0: error: Function is missing a return type annotation [no-untyped-def] -apps/job/views/job_costline_views.py:0: error: Call to untyped function "save" in typed context [no-untyped-call] -apps/job/views/workshop_pdf_view.py:0: error: Function is missing a type annotation [no-untyped-def] -apps/job/views/month_end_rest_view.py:0: error: Function is missing a return type annotation [no-untyped-def] -apps/job/views/month_end_rest_view.py:0: error: Function is missing a type annotation [no-untyped-def] -apps/job/views/month_end_rest_view.py:0: error: Function is missing a type annotation [no-untyped-def] -apps/job/views/modern_timesheet_views.py:0: error: Function is missing a return type annotation [no-untyped-def] -apps/job/views/modern_timesheet_views.py:0: error: Function is missing a type annotation [no-untyped-def] -apps/job/views/modern_timesheet_views.py:0: error: Call to untyped function "get_serializer_class" in typed context [no-untyped-call] -apps/job/views/modern_timesheet_views.py:0: error: Function is missing a type annotation [no-untyped-def] -apps/job/views/modern_timesheet_views.py:0: error: Item "None" of "CostLine@AnnotatedWith[TypedDict({'calculated_total_cost': Any, 'calculated_total_rev': Any})] | None" has no attribute "id" [union-attr] -apps/job/views/modern_timesheet_views.py:0: error: Item "None" of "CostLine@AnnotatedWith[TypedDict({'calculated_total_cost': Any, 'calculated_total_rev': Any})] | None" has no attribute "meta" [union-attr] -apps/job/views/modern_timesheet_views.py:0: error: Item "None" of "CostLine@AnnotatedWith[TypedDict({'calculated_total_cost': Any, 'calculated_total_rev': Any})] | None" has no attribute "quantity" [union-attr] -apps/job/views/modern_timesheet_views.py:0: error: Item "None" of "CostLine@AnnotatedWith[TypedDict({'calculated_total_cost': Any, 'calculated_total_rev': Any})] | None" has no attribute "unit_cost" [union-attr] -apps/job/views/modern_timesheet_views.py:0: error: Item "None" of "CostLine@AnnotatedWith[TypedDict({'calculated_total_cost': Any, 'calculated_total_rev': Any})] | None" has no attribute "calculated_total_cost" [union-attr] -apps/job/views/modern_timesheet_views.py:0: error: Function is missing a type annotation [no-untyped-def] -apps/job/views/modern_timesheet_views.py:0: error: Item "None" of "CostSet | None" has no attribute "rev" [union-attr] -apps/job/views/modern_timesheet_views.py:0: error: Function is missing a type annotation [no-untyped-def] -apps/job/views/modern_timesheet_views.py:0: error: Function is missing a type annotation [no-untyped-def] -apps/job/views/delivery_docket_view.py:0: error: Function is missing a type annotation [no-untyped-def] -apps/job/views/archive_completed_jobs_view.py:0: error: Missing type arguments for generic type "ListAPIView" [type-arg] -apps/job/views/archive_completed_jobs_view.py:0: error: Function is missing a return type annotation [no-untyped-def] -apps/job/views/archive_completed_jobs_view.py:0: error: Call to untyped function "get_paid_complete_jobs" in typed context [no-untyped-call] -apps/job/views/archive_completed_jobs_view.py:0: error: Function is missing a type annotation [no-untyped-def] -apps/job/views/archive_completed_jobs_view.py:0: error: Call to untyped function "archive_complete_jobs" in typed context [no-untyped-call] -apps/client/tests/test_client_invoice_summary.py:0: error: Function is missing a type annotation [no-untyped-def] -apps/client/tests/test_client_invoice_summary.py:0: error: Function is missing a type annotation [no-untyped-def] -apps/client/tests/test_client_invoice_summary.py:0: error: Function is missing a type annotation [no-untyped-def] -apps/client/tests/test_client_invoice_summary.py:0: error: Function is missing a type annotation [no-untyped-def] -apps/client/tests/test_client_invoice_summary.py:0: error: Call to untyped function "date_to_datetime" in typed context [no-untyped-call] -apps/client/tests/test_client_invoice_summary.py:0: error: Function is missing a return type annotation [no-untyped-def] -apps/client/tests/test_client_invoice_summary.py:0: note: Use "-> None" if function does not return a value -apps/client/tests/test_client_invoice_summary.py:0: error: Function is missing a return type annotation [no-untyped-def] -apps/client/tests/test_client_invoice_summary.py:0: note: Use "-> None" if function does not return a value -apps/workflow/middleware.py:0: error: Function is missing a type annotation [no-untyped-def] -apps/workflow/middleware.py:0: error: Function is missing a type annotation [no-untyped-def] -apps/workflow/middleware.py:0: error: Function is missing a type annotation [no-untyped-def] -apps/workflow/middleware.py:0: error: Call to untyped function "authenticate" in typed context [no-untyped-call] -apps/accounts/views/user_profile_view.py:0: error: Module "drf_spectacular.utils" does not explicitly export attribute "OpenApiTypes" [attr-defined] -apps/accounts/views/token_view.py:0: error: Function is missing a type annotation [no-untyped-def] -apps/accounts/views/token_view.py:0: error: Missing type arguments for generic type "dict" [type-arg] -apps/accounts/views/token_view.py:0: error: Item "None" of "Any | None" has no attribute "total_seconds" [union-attr] -apps/accounts/views/token_view.py:0: error: Item "None" of "Any | None" has no attribute "total_seconds" [union-attr] -apps/accounts/views/token_view.py:0: error: Function is missing a type annotation [no-untyped-def] -apps/accounts/views/token_view.py:0: error: Missing type arguments for generic type "dict" [type-arg] -apps/accounts/views/token_view.py:0: error: Item "None" of "Any | None" has no attribute "total_seconds" [union-attr] -apps/accounts/views/staff_views.py:0: error: Missing type arguments for generic type "ListAPIView" [type-arg] -apps/accounts/views/staff_views.py:0: error: Function is missing a type annotation [no-untyped-def] -apps/accounts/views/staff_views.py:0: error: Call to untyped function "get_queryset" in typed context [no-untyped-call] -apps/accounts/views/staff_views.py:0: error: Function is missing a return type annotation [no-untyped-def] -apps/accounts/views/staff_views.py:0: error: Function is missing a return type annotation [no-untyped-def] -apps/accounts/views/staff_views.py:0: error: Function is missing a type annotation [no-untyped-def] -apps/accounts/views/staff_api.py:0: error: Missing type arguments for generic type "ListCreateAPIView" [type-arg] -apps/accounts/views/staff_api.py:0: error: Function is missing a return type annotation [no-untyped-def] -apps/accounts/views/staff_api.py:0: error: Function is missing a return type annotation [no-untyped-def] -apps/accounts/views/staff_api.py:0: error: Function is missing a type annotation [no-untyped-def] -apps/accounts/views/staff_api.py:0: error: Missing type arguments for generic type "RetrieveUpdateDestroyAPIView" [type-arg] -apps/accounts/views/staff_api.py:0: error: Function is missing a return type annotation [no-untyped-def] -apps/accounts/views/staff_api.py:0: error: Function is missing a type annotation [no-untyped-def] -apps/workflow/extensions.py:0: error: Call to untyped function "__init_subclass__" in typed context [no-untyped-call] -apps/workflow/extensions.py:0: error: Function is missing a type annotation [no-untyped-def] -apps/purchasing/views/stock_search_rest_view.py:0: error: Module "drf_spectacular.utils" does not explicitly export attribute "OpenApiTypes" [attr-defined] -apps/purchasing/tests/test_stock_fts_search.py:0: error: Function is missing a type annotation [no-untyped-def] -apps/purchasing/tests/test_stock_fts_search.py:0: error: Function is missing a type annotation [no-untyped-def] -apps/purchasing/tests/test_stock_fts_search.py:0: error: Call to untyped function "_stock" in typed context [no-untyped-call] -apps/purchasing/tests/test_stock_fts_search.py:0: error: Function is missing a type annotation [no-untyped-def] -apps/purchasing/tests/test_stock_fts_search.py:0: error: Call to untyped function "_stock" in typed context [no-untyped-call] -apps/purchasing/tests/test_stock_fts_search.py:0: error: Function is missing a type annotation [no-untyped-def] -apps/purchasing/tests/test_stock_fts_search.py:0: error: Call to untyped function "_stock" in typed context [no-untyped-call] -apps/purchasing/tests/test_stock_fts_search.py:0: error: Function is missing a type annotation [no-untyped-def] -apps/purchasing/tests/test_stock_fts_search.py:0: error: Function is missing a type annotation [no-untyped-def] -apps/purchasing/tests/test_stock_fts_search.py:0: error: Function is missing a type annotation [no-untyped-def] -apps/purchasing/tests/test_stock_fts_search.py:0: error: Call to untyped function "_stock" in typed context [no-untyped-call] -apps/purchasing/tests/test_stock_fts_search.py:0: error: Call to untyped function "_stock" in typed context [no-untyped-call] -apps/purchasing/tests/test_stock_fts_search.py:0: error: Function is missing a type annotation [no-untyped-def] -apps/purchasing/tests/test_stock_fts_search.py:0: error: Call to untyped function "_stock" in typed context [no-untyped-call] -apps/purchasing/tests/test_stock_fts_search.py:0: error: Call to untyped function "_stock" in typed context [no-untyped-call] -apps/purchasing/tests/test_stock_fts_search.py:0: error: Function is missing a type annotation [no-untyped-def] -apps/purchasing/tests/test_stock_fts_search.py:0: error: Call to untyped function "_stock" in typed context [no-untyped-call] -apps/purchasing/tests/test_stock_fts_search.py:0: error: Call to untyped function "_stock" in typed context [no-untyped-call] -apps/purchasing/tests/test_stock_fts_search.py:0: error: Function is missing a type annotation [no-untyped-def] -apps/purchasing/tests/test_stock_fts_search.py:0: error: Call to untyped function "_stock" in typed context [no-untyped-call] -apps/purchasing/tests/test_stock_fts_search.py:0: error: Function is missing a type annotation [no-untyped-def] -apps/purchasing/tests/test_stock_fts_search.py:0: error: Call to untyped function "_stock" in typed context [no-untyped-call] -apps/purchasing/tests/test_stock_fts_search.py:0: error: Call to untyped function "_stock" in typed context [no-untyped-call] -apps/purchasing/tests/test_stock_fts_search.py:0: error: Function is missing a type annotation [no-untyped-def] -apps/purchasing/tests/test_stock_fts_search.py:0: error: Call to untyped function "_stock" in typed context [no-untyped-call] -apps/purchasing/tests/test_stock_fts_search.py:0: error: Function is missing a type annotation [no-untyped-def] -apps/purchasing/tests/test_stock_fts_search.py:0: error: Call to untyped function "_stock" in typed context [no-untyped-call] -apps/purchasing/tests/test_stock_fts_search.py:0: error: Call to untyped function "_stock" in typed context [no-untyped-call] -apps/purchasing/tests/test_stock_fts_search.py:0: error: Call to untyped function "_stock" in typed context [no-untyped-call] -apps/purchasing/tests/test_stock_fts_search.py:0: error: Function is missing a type annotation [no-untyped-def] -apps/purchasing/tests/test_stock_fts_search.py:0: error: Call to untyped function "_stock" in typed context [no-untyped-call] -apps/purchasing/tests/test_stock_fts_search.py:0: error: Call to untyped function "_stock" in typed context [no-untyped-call] -apps/purchasing/tests/test_stock_fts_search.py:0: error: Call to untyped function "_stock" in typed context [no-untyped-call] -apps/purchasing/tests/test_stock_fts_search.py:0: error: Function is missing a type annotation [no-untyped-def] -apps/purchasing/tests/test_stock_fts_search.py:0: error: Function is missing a type annotation [no-untyped-def] -apps/purchasing/tests/test_stock_fts_search.py:0: error: Function is missing a type annotation [no-untyped-def] -apps/purchasing/tests/test_stock_fts_search.py:0: error: Function is missing a type annotation [no-untyped-def] -apps/purchasing/tests/test_stock_fts_search.py:0: error: Function is missing a type annotation [no-untyped-def] -apps/purchasing/tests/test_stock_fts_search.py:0: error: Call to untyped function "_stock" in typed context [no-untyped-call] -apps/purchasing/tests/test_stock_fts_search.py:0: error: Call to untyped function "_stock" in typed context [no-untyped-call] -apps/purchasing/tests/test_stock_fts_search.py:0: error: Call to untyped function "_stock" in typed context [no-untyped-call] -apps/purchasing/tests/test_stock_fts_search.py:0: error: Function is missing a type annotation [no-untyped-def] -apps/purchasing/tests/test_stock_fts_search.py:0: error: Call to untyped function "_stock" in typed context [no-untyped-call] -apps/purchasing/tests/test_stock_fts_search.py:0: error: Function is missing a type annotation [no-untyped-def] -apps/purchasing/tests/test_stock_fts_search.py:0: error: Call to untyped function "_stock" in typed context [no-untyped-call] -apps/purchasing/tests/test_stock_fts_search.py:0: error: Call to untyped function "_stock" in typed context [no-untyped-call] -apps/purchasing/tests/test_stock_fts_search.py:0: error: Call to untyped function "_stock" in typed context [no-untyped-call] -apps/purchasing/tests/test_stock_fts_search.py:0: error: Function is missing a type annotation [no-untyped-def] -apps/purchasing/tests/test_stock_fts_search.py:0: error: Call to untyped function "_stock" in typed context [no-untyped-call] -apps/purchasing/tests/test_stock_fts_search.py:0: error: Call to untyped function "_stock" in typed context [no-untyped-call] -apps/purchasing/tests/test_stock_fts_search.py:0: error: Call to untyped function "_stock" in typed context [no-untyped-call] -apps/purchasing/tests/test_stock_fts_search.py:0: error: Function is missing a type annotation [no-untyped-def] -apps/purchasing/tests/test_stock_fts_search.py:0: error: Call to untyped function "_stock" in typed context [no-untyped-call] -apps/purchasing/tests/test_stock_fts_search.py:0: error: Call to untyped function "_stock" in typed context [no-untyped-call] -apps/purchasing/tests/test_stock_fts_search.py:0: error: Call to untyped function "_stock" in typed context [no-untyped-call] -apps/purchasing/tests/test_stock_fts_search.py:0: error: Call to untyped function "_stock" in typed context [no-untyped-call] -apps/purchasing/tests/test_stock_fts_search.py:0: error: Function is missing a type annotation [no-untyped-def] -apps/purchasing/tests/test_stock_fts_search.py:0: error: Call to untyped function "_stock" in typed context [no-untyped-call] -apps/purchasing/tests/test_stock_fts_search.py:0: error: Call to untyped function "_stock" in typed context [no-untyped-call] -apps/purchasing/tests/test_stock_fts_search.py:0: error: Call to untyped function "_stock" in typed context [no-untyped-call] -apps/purchasing/tests/test_stock_fts_search.py:0: error: Function is missing a type annotation [no-untyped-def] -apps/purchasing/tests/test_stock_fts_search.py:0: error: Call to untyped function "_stock" in typed context [no-untyped-call] -apps/purchasing/tests/test_stock_fts_search.py:0: error: Call to untyped function "_stock" in typed context [no-untyped-call] -apps/purchasing/tests/test_stock_fts_search.py:0: error: Call to untyped function "_stock" in typed context [no-untyped-call] -apps/purchasing/tests/test_stock_fts_search.py:0: error: Function is missing a type annotation [no-untyped-def] -apps/purchasing/tests/test_stock_fts_search.py:0: error: Call to untyped function "_stock" in typed context [no-untyped-call] -apps/purchasing/tests/test_stock_fts_search.py:0: error: Call to untyped function "_stock" in typed context [no-untyped-call] -apps/purchasing/tests/test_stock_fts_search.py:0: error: Function is missing a return type annotation [no-untyped-def] -apps/purchasing/tests/test_stock_fts_search.py:0: note: Use "-> None" if function does not return a value -apps/purchasing/tests/test_stock_fts_search.py:0: error: Function is missing a return type annotation [no-untyped-def] -apps/purchasing/tests/test_stock_fts_search.py:0: note: Use "-> None" if function does not return a value -apps/purchasing/tests/test_stock_fts_search.py:0: error: Call to untyped function "_stock" in typed context [no-untyped-call] -apps/purchasing/tests/test_stock_fts_search.py:0: error: Call to untyped function "_stock" in typed context [no-untyped-call] -apps/purchasing/tests/test_stock_fts_search.py:0: error: Function is missing a return type annotation [no-untyped-def] -apps/purchasing/tests/test_stock_fts_search.py:0: note: Use "-> None" if function does not return a value -apps/purchasing/tests/test_stock_fts_search.py:0: error: Call to untyped function "_stock" in typed context [no-untyped-call] -apps/purchasing/tests/test_stock_fts_search.py:0: error: Call to untyped function "_stock" in typed context [no-untyped-call] -apps/purchasing/tests/test_stock_fts_search.py:0: error: Function is missing a type annotation [no-untyped-def] -apps/purchasing/tests/test_stock_fts_search.py:0: error: Function is missing a type annotation [no-untyped-def] -apps/purchasing/tests/test_stock_fts_search.py:0: error: Function is missing a type annotation [no-untyped-def] -apps/purchasing/tests/test_stock_fts_search.py:0: error: Call to untyped function "_stock" in typed context [no-untyped-call] -apps/purchasing/tests/test_stock_fts_search.py:0: error: Call to untyped function "_stock" in typed context [no-untyped-call] -apps/purchasing/tests/test_stock_fts_search.py:0: error: Function is missing a type annotation [no-untyped-def] -apps/purchasing/tests/test_stock_fts_search.py:0: error: Call to untyped function "_stock" in typed context [no-untyped-call] -apps/purchasing/tests/test_stock_fts_search.py:0: error: Function is missing a type annotation [no-untyped-def] -apps/purchasing/tests/test_stock_fts_search.py:0: error: Function is missing a type annotation [no-untyped-def] -apps/purchasing/tests/test_stock_fts_search.py:0: error: Function is missing a type annotation [no-untyped-def] -apps/purchasing/tests/test_stock_fts_search.py:0: error: Call to untyped function "_stock" in typed context [no-untyped-call] -apps/job/tests/test_modern_timesheet_views.py:0: error: Function is missing a return type annotation [no-untyped-def] -apps/job/tests/test_modern_timesheet_views.py:0: note: Use "-> None" if function does not return a value -apps/job/tests/test_modern_timesheet_views.py:0: error: Incompatible types in assignment (expression has type "apps.client.models.Client", variable has type "django.test.client.Client") [assignment] -apps/job/tests/test_modern_timesheet_views.py:0: error: Function is missing a return type annotation [no-untyped-def] -apps/job/tests/test_modern_timesheet_views.py:0: note: Use "-> None" if function does not return a value -apps/job/tests/test_modern_timesheet_views.py:0: error: Item "None" of "XeroPayItem | None" has no attribute "name" [union-attr] -apps/job/views/kanban_view_api.py:0: error: Function is missing a return type annotation [no-untyped-def] -apps/job/views/kanban_view_api.py:0: error: Function is missing a type annotation [no-untyped-def] -apps/job/views/kanban_view_api.py:0: error: Call to untyped function "get_serializer_class" in typed context [no-untyped-call] -apps/job/views/kanban_view_api.py:0: error: Function is missing a return type annotation [no-untyped-def] -apps/job/views/kanban_view_api.py:0: error: Function is missing a type annotation [no-untyped-def] -apps/job/views/kanban_view_api.py:0: error: Call to untyped function "get_serializer_class" in typed context [no-untyped-call] -apps/job/views/kanban_view_api.py:0: error: "str" has no attribute "HTTP_500_INTERNAL_SERVER_ERROR" [attr-defined] -apps/job/views/kanban_view_api.py:0: error: Incompatible types in assignment (expression has type "list[str]", target has type "str") [assignment] -apps/job/views/job_quote_chat_views.py:0: error: Function is missing a return type annotation [no-untyped-def] -apps/job/views/job_quote_chat_views.py:0: error: Function is missing a type annotation [no-untyped-def] -apps/job/views/job_quote_chat_views.py:0: error: Function is missing a type annotation [no-untyped-def] -apps/job/views/job_quote_chat_views.py:0: error: Function is missing a type annotation [no-untyped-def] -apps/job/views/job_quote_chat_views.py:0: error: Function is missing a return type annotation [no-untyped-def] -apps/job/views/job_quote_chat_views.py:0: error: Function is missing a type annotation [no-untyped-def] -apps/job/views/job_quote_chat_views.py:0: error: Call to untyped function "get_job_or_404" in typed context [no-untyped-call] -apps/job/views/job_quote_chat_views.py:0: error: Call to untyped function "get_job_or_404_response" in typed context [no-untyped-call] -apps/job/views/job_quote_chat_views.py:0: error: Function is missing a type annotation [no-untyped-def] -apps/job/views/job_quote_chat_views.py:0: error: Call to untyped function "get_job_or_404" in typed context [no-untyped-call] -apps/job/views/job_quote_chat_views.py:0: error: Call to untyped function "get_job_or_404_response" in typed context [no-untyped-call] -apps/job/views/job_quote_chat_views.py:0: error: "JobQuoteChatSerializer" has no attribute "get" [attr-defined] -apps/job/views/job_quote_chat_views.py:0: error: Function is missing a type annotation [no-untyped-def] -apps/job/views/job_quote_chat_views.py:0: error: Call to untyped function "get_job_or_404" in typed context [no-untyped-call] -apps/job/views/job_quote_chat_views.py:0: error: Call to untyped function "get_job_or_404_response" in typed context [no-untyped-call] -apps/job/views/job_quote_chat_views.py:0: error: Function is missing a type annotation [no-untyped-def] -apps/job/views/job_quote_chat_views.py:0: error: Call to untyped function "get_job_or_404" in typed context [no-untyped-call] -apps/job/views/job_quote_chat_views.py:0: error: Call to untyped function "get_message_or_404" in typed context [no-untyped-call] -apps/job/views/job_quote_chat_views.py:0: error: Call to untyped function "get_job_or_404_response" in typed context [no-untyped-call] -apps/job/views/job_quote_chat_views.py:0: error: Function is missing a type annotation [no-untyped-def] -apps/job/views/job_quote_chat_views.py:0: error: Call to untyped function "get_job_or_404" in typed context [no-untyped-call] -apps/job/views/job_quote_chat_views.py:0: error: Call to untyped function "get_message_or_404" in typed context [no-untyped-call] -apps/job/views/job_quote_chat_views.py:0: error: Call to untyped function "get_job_or_404_response" in typed context [no-untyped-call] -apps/job/views/job_costing_views.py:0: error: Function is missing a type annotation [no-untyped-def] -apps/job/views/job_costing_views.py:0: error: Function is missing a return type annotation [no-untyped-def] -apps/job/views/job_costing_views.py:0: error: Function is missing a type annotation [no-untyped-def] -apps/job/views/job_costing_views.py:0: error: Function is missing a type annotation [no-untyped-def] -apps/job/views/job_costing_views.py:0: error: Call to untyped function "_archive_quote_data" in typed context [no-untyped-call] -apps/job/views/job_costing_views.py:0: error: Function is missing a type annotation [no-untyped-def] -apps/job/views/assign_job_view.py:0: error: Function is missing a type annotation [no-untyped-def] -apps/job/views/assign_job_view.py:0: error: Call to untyped function "assign_staff_to_job" of "JobStaffService" in typed context [no-untyped-call] -apps/job/views/assign_job_view.py:0: error: Function is missing a type annotation [no-untyped-def] -apps/job/views/assign_job_view.py:0: error: Call to untyped function "remove_staff_from_job" of "JobStaffService" in typed context [no-untyped-call] -apps/job/services/job_rest_service.py:0: error: Function is missing a type annotation for one or more parameters [no-untyped-def] -apps/job/services/job_rest_service.py:0: error: Function is missing a type annotation for one or more parameters [no-untyped-def] -apps/job/services/job_rest_service.py:0: error: Missing return statement [return] -apps/job/services/job_rest_service.py:0: error: Incompatible return value type (got "ReturnDict[Any, Any]", expected "list[dict[str, Any]]") [return-value] -apps/job/services/job_rest_service.py:0: error: Incompatible return value type (got "ReturnDict[Any, Any]", expected "list[dict[str, Any]]") [return-value] -apps/job/services/job_rest_service.py:0: error: Value of type "JobDeltaRejection@AnnotatedWith[TypedDict({'occurrence_count': int, 'first_seen': Any, 'last_seen': Any, 'latest_id': Any})]" is not indexable [index] -apps/job/services/job_rest_service.py:0: error: Value of type "JobDeltaRejection@AnnotatedWith[TypedDict({'occurrence_count': int, 'first_seen': Any, 'last_seen': Any, 'latest_id': Any})]" is not indexable [index] -apps/job/services/job_rest_service.py:0: error: Value of type "JobDeltaRejection@AnnotatedWith[TypedDict({'occurrence_count': int, 'first_seen': Any, 'last_seen': Any, 'latest_id': Any})]" is not indexable [index] -apps/job/services/job_rest_service.py:0: error: Value of type "JobDeltaRejection@AnnotatedWith[TypedDict({'occurrence_count': int, 'first_seen': Any, 'last_seen': Any, 'latest_id': Any})]" is not indexable [index] -apps/job/services/job_rest_service.py:0: error: Value of type "JobDeltaRejection@AnnotatedWith[TypedDict({'occurrence_count': int, 'first_seen': Any, 'last_seen': Any, 'latest_id': Any})]" is not indexable [index] -apps/job/services/job_rest_service.py:0: error: Value of type "JobDeltaRejection@AnnotatedWith[TypedDict({'occurrence_count': int, 'first_seen': Any, 'last_seen': Any, 'latest_id': Any})]" is not indexable [index] -apps/job/services/job_rest_service.py:0: error: "ValueError" has no attribute "delta_rejection_context" [attr-defined] -apps/job/services/job_rest_service.py:0: error: Assignment to variable "exc" outside except: block [misc] -apps/job/services/job_rest_service.py:0: error: Trying to read deleted variable "exc" [misc] -apps/job/services/job_rest_service.py:0: error: Trying to read deleted variable "exc" [misc] -apps/job/services/job_rest_service.py:0: error: Incompatible type for lookup 'client_id': (got "UUID | Any | None", expected "UUID | str") [misc] -apps/job/services/job_rest_service.py:0: error: Missing return statement [return] -apps/job/services/job_rest_service.py:0: error: Call to untyped function "create_safe" of "JobEvent" in typed context [no-untyped-call] -apps/job/services/job_rest_service.py:0: error: Argument "key" to "sort" of "list" has incompatible type "Callable[[dict[str, int | datetime | UUID | str | Any | None]], int | datetime | UUID | str | Any | None]"; expected "Callable[[dict[str, int | datetime | UUID | str | Any | None]], SupportsDunderLT[Any] | SupportsDunderGT[Any]]" [arg-type] -apps/job/services/job_rest_service.py:0: error: Incompatible return value type (got "int | datetime | UUID | str | Any | None", expected "SupportsDunderLT[Any] | SupportsDunderGT[Any]") [return-value] -apps/job/services/job_rest_service.py:0: error: Incompatible default for parameter "week" (default has type "None", parameter has type "date") [assignment] -apps/job/services/job_rest_service.py:0: note: PEP 484 prohibits implicit Optional. Accordingly, mypy has changed its default to no_implicit_optional=True -apps/job/services/job_rest_service.py:0: note: Use https://github.com/hauntsaninja/no_implicit_optional to automatically upgrade your codebase -apps/job/services/job_rest_service.py:0: error: "Job" has no attribute "pricings" [attr-defined] -apps/workflow/tests/test_access_logging_middleware.py:0: error: Function is missing a return type annotation [no-untyped-def] -apps/workflow/tests/test_access_logging_middleware.py:0: error: Function is missing a return type annotation [no-untyped-def] -apps/workflow/tests/test_api_schema_coverage.py:0: error: Function is missing a type annotation [no-untyped-def] -apps/workflow/tests/test_api_schema_coverage.py:0: error: Call to untyped function "_get_all_url_patterns" in typed context [no-untyped-call] -apps/workflow/tests/test_api_schema_coverage.py:0: error: Missing type arguments for generic type "set" [type-arg] -apps/workflow/tests/test_api_schema_coverage.py:0: error: Call to untyped function "SchemaGenerator" in typed context [no-untyped-call] -apps/workflow/tests/test_api_schema_coverage.py:0: error: Call to untyped function "get_schema" in typed context [no-untyped-call] -apps/workflow/tests/test_api_schema_coverage.py:0: error: Function is missing a return type annotation [no-untyped-def] -apps/workflow/tests/test_api_schema_coverage.py:0: note: Use "-> None" if function does not return a value -apps/workflow/tests/test_api_schema_coverage.py:0: error: Call to untyped function "_get_all_url_patterns" in typed context [no-untyped-call] -apps/job/views/job_rest_views.py:0: error: Module "drf_spectacular.utils" does not explicitly export attribute "OpenApiTypes" [attr-defined] -apps/job/views/job_rest_views.py:0: error: Function is missing a return type annotation [no-untyped-def] -apps/job/views/job_rest_views.py:0: error: Function is missing a type annotation [no-untyped-def] -apps/job/views/job_rest_views.py:0: error: Function is missing a type annotation for one or more parameters [no-untyped-def] -apps/job/views/job_rest_views.py:0: error: Returning Any from function declared to return "dict[str, Any]" [no-any-return] -apps/job/views/job_rest_views.py:0: error: Function is missing a type annotation for one or more parameters [no-untyped-def] -apps/job/views/job_rest_views.py:0: error: Function is missing a type annotation for one or more parameters [no-untyped-def] -apps/job/views/job_rest_views.py:0: error: Function is missing a type annotation for one or more parameters [no-untyped-def] -apps/job/views/job_rest_views.py:0: error: Function is missing a return type annotation [no-untyped-def] -apps/job/views/job_rest_views.py:0: error: Function is missing a type annotation [no-untyped-def] -apps/job/views/job_rest_views.py:0: error: Call to untyped function "get_serializer_class" in typed context [no-untyped-call] -apps/job/views/job_rest_views.py:0: error: Function is missing a type annotation [no-untyped-def] -apps/job/views/job_rest_views.py:0: error: Function is missing a return type annotation [no-untyped-def] -apps/job/views/job_rest_views.py:0: error: Function is missing a type annotation [no-untyped-def] -apps/job/views/job_rest_views.py:0: error: Call to untyped function "get_serializer_class" in typed context [no-untyped-call] -apps/job/views/job_rest_views.py:0: error: Function is missing a type annotation [no-untyped-def] -apps/job/views/job_rest_views.py:0: error: Function is missing a type annotation [no-untyped-def] -apps/job/views/job_rest_views.py:0: error: Function is missing a type annotation [no-untyped-def] -apps/job/views/job_rest_views.py:0: error: Function is missing a type annotation [no-untyped-def] -apps/job/views/job_rest_views.py:0: error: Function is missing a type annotation [no-untyped-def] -apps/job/views/job_rest_views.py:0: error: Function is missing a return type annotation [no-untyped-def] -apps/job/views/job_rest_views.py:0: error: Function is missing a type annotation [no-untyped-def] -apps/job/views/job_rest_views.py:0: error: Function is missing a type annotation [no-untyped-def] -apps/job/views/job_rest_views.py:0: error: Function is missing a type annotation [no-untyped-def] -apps/job/views/job_rest_views.py:0: error: Argument 1 to "get_weekly_metrics" of "JobRestService" has incompatible type "date | None"; expected "date" [arg-type] -apps/job/views/job_rest_views.py:0: error: Function is missing a type annotation [no-untyped-def] -apps/job/views/job_rest_views.py:0: error: Function is missing a type annotation [no-untyped-def] -apps/job/views/job_rest_views.py:0: error: Function is missing a type annotation [no-untyped-def] -apps/job/views/job_rest_views.py:0: error: Function is missing a type annotation [no-untyped-def] -apps/job/views/job_rest_views.py:0: error: Function is missing a type annotation [no-untyped-def] -apps/job/views/job_rest_views.py:0: error: Call to untyped function "calculate_profit_margin" in typed context [no-untyped-call] -apps/job/views/job_rest_views.py:0: error: Call to untyped function "calculate_profit_margin" in typed context [no-untyped-call] -apps/job/views/job_rest_views.py:0: error: Call to untyped function "calculate_profit_margin" in typed context [no-untyped-call] -apps/job/views/job_rest_views.py:0: error: Function is missing a type annotation [no-untyped-def] -apps/job/views/job_rest_views.py:0: error: "type[Job]" has no attribute "STATUS_CHOICES" [attr-defined] -apps/job/views/job_rest_views.py:0: error: Function is missing a type annotation [no-untyped-def] -apps/job/views/job_rest_views.py:0: error: Function is missing a return type annotation [no-untyped-def] -apps/job/views/job_rest_views.py:0: error: Function is missing a type annotation for one or more parameters [no-untyped-def] -apps/job/views/job_rest_views.py:0: error: Function is missing a type annotation [no-untyped-def] -apps/job/views/job_rest_views.py:0: error: Function is missing a type annotation [no-untyped-def] -apps/job/views/job_rest_views.py:0: error: Function is missing a type annotation [no-untyped-def] -apps/job/views/job_rest_views.py:0: error: Function is missing a type annotation [no-untyped-def] -apps/job/views/job_rest_views.py:0: error: Function is missing a type annotation [no-untyped-def] -apps/job/tests/test_job_rest_service.py:0: error: Function is missing a return type annotation [no-untyped-def] -apps/job/tests/test_job_rest_service.py:0: note: Use "-> None" if function does not return a value -apps/job/tests/test_job_rest_service.py:0: error: Function is missing a return type annotation [no-untyped-def] -apps/job/tests/test_job_rest_service.py:0: note: Use "-> None" if function does not return a value -apps/job/tests/test_job_delta_rejection_grouping.py:0: error: Function is missing a type annotation [no-untyped-def] -apps/job/tests/test_job_delta_rejection_grouping.py:0: error: Function is missing a type annotation [no-untyped-def] -apps/job/tests/test_job_delta_rejection_grouping.py:0: error: Function is missing a type annotation [no-untyped-def] -apps/job/tests/test_job_delta_rejection_grouping.py:0: error: Function is missing a type annotation [no-untyped-def] -apps/job/tests/test_job_delta_rejection_grouping.py:0: error: Function is missing a type annotation [no-untyped-def] -apps/job/tests/test_event_deduplication.py:0: error: Function is missing a return type annotation [no-untyped-def] -apps/job/tests/test_event_deduplication.py:0: note: Use "-> None" if function does not return a value -apps/job/tests/test_event_deduplication.py:0: error: Function is missing a return type annotation [no-untyped-def] -apps/job/tests/test_event_deduplication.py:0: note: Use "-> None" if function does not return a value -apps/job/tests/test_event_deduplication.py:0: error: Function is missing a return type annotation [no-untyped-def] -apps/job/tests/test_event_deduplication.py:0: note: Use "-> None" if function does not return a value -apps/job/tests/test_event_deduplication.py:0: error: Call to untyped function "create_safe" of "JobEvent" in typed context [no-untyped-call] -apps/job/tests/test_event_deduplication.py:0: error: Call to untyped function "create_safe" of "JobEvent" in typed context [no-untyped-call] -apps/job/tests/test_event_deduplication.py:0: error: Function is missing a return type annotation [no-untyped-def] -apps/job/tests/test_event_deduplication.py:0: note: Use "-> None" if function does not return a value -apps/job/tests/test_event_deduplication.py:0: error: Function is missing a return type annotation [no-untyped-def] -apps/job/tests/test_event_deduplication.py:0: note: Use "-> None" if function does not return a value -apps/job/tests/test_event_deduplication.py:0: error: Function is missing a return type annotation [no-untyped-def] -apps/job/tests/test_event_deduplication.py:0: note: Use "-> None" if function does not return a value -apps/job/tests/test_event_deduplication.py:0: error: Function is missing a return type annotation [no-untyped-def] -apps/job/tests/test_event_deduplication.py:0: note: Use "-> None" if function does not return a value -apps/job/tests/test_job_event_tracking.py:0: error: Function is missing a return type annotation [no-untyped-def] -apps/job/tests/test_job_event_tracking.py:0: note: Use "-> None" if function does not return a value -apps/job/tests/test_job_event_tracking.py:0: error: Function is missing a return type annotation [no-untyped-def] -apps/job/tests/test_job_event_tracking.py:0: note: Use "-> None" if function does not return a value -apps/job/tests/test_job_event_tracking.py:0: error: Item "None" of "JobEvent | None" has no attribute "staff" [union-attr] -apps/job/tests/test_job_event_tracking.py:0: error: Item "None" of "JobEvent | None" has no attribute "delta_before" [union-attr] -apps/job/tests/test_job_event_tracking.py:0: error: Value of type "Any | None" is not indexable [index] -apps/job/tests/test_job_event_tracking.py:0: error: Item "None" of "JobEvent | None" has no attribute "delta_after" [union-attr] -apps/job/tests/test_job_event_tracking.py:0: error: Value of type "Any | None" is not indexable [index] -apps/job/tests/test_job_event_tracking.py:0: error: Function is missing a return type annotation [no-untyped-def] -apps/job/tests/test_job_event_tracking.py:0: note: Use "-> None" if function does not return a value -apps/job/tests/test_job_event_tracking.py:0: error: Function is missing a return type annotation [no-untyped-def] -apps/job/tests/test_job_event_tracking.py:0: note: Use "-> None" if function does not return a value -apps/job/tests/test_job_event_tracking.py:0: error: Function is missing a return type annotation [no-untyped-def] -apps/job/tests/test_job_event_tracking.py:0: note: Use "-> None" if function does not return a value -apps/job/tests/test_job_event_tracking.py:0: error: Item "None" of "JobEvent | None" has no attribute "delta_before" [union-attr] -apps/job/tests/test_job_event_tracking.py:0: error: Argument 2 to "assertIn" of "TestCase" has incompatible type "Any | None"; expected "Iterable[Any] | Container[Any]" [arg-type] -apps/job/tests/test_job_event_tracking.py:0: error: Item "None" of "JobEvent | None" has no attribute "delta_after" [union-attr] -apps/job/tests/test_job_event_tracking.py:0: error: Value of type "Any | None" is not indexable [index] -apps/job/tests/test_job_event_tracking.py:0: error: Function is missing a return type annotation [no-untyped-def] -apps/job/tests/test_job_event_tracking.py:0: note: Use "-> None" if function does not return a value -apps/job/tests/test_job_event_tracking.py:0: error: Function is missing a return type annotation [no-untyped-def] -apps/job/tests/test_job_event_tracking.py:0: note: Use "-> None" if function does not return a value -apps/job/tests/test_job_event_tracking.py:0: error: Item "None" of "JobEvent | None" has no attribute "delta_before" [union-attr] -apps/job/tests/test_job_event_tracking.py:0: error: Argument 2 to "assertIn" of "TestCase" has incompatible type "Any | None"; expected "Iterable[Any] | Container[Any]" [arg-type] -apps/job/tests/test_job_event_tracking.py:0: error: Item "None" of "JobEvent | None" has no attribute "delta_before" [union-attr] -apps/job/tests/test_job_event_tracking.py:0: error: Argument 2 to "assertIn" of "TestCase" has incompatible type "Any | None"; expected "Iterable[Any] | Container[Any]" [arg-type] -apps/job/tests/test_job_event_tracking.py:0: error: Function is missing a return type annotation [no-untyped-def] -apps/job/tests/test_job_event_tracking.py:0: note: Use "-> None" if function does not return a value -apps/job/tests/test_job_event_tracking.py:0: error: Function is missing a return type annotation [no-untyped-def] -apps/job/tests/test_job_event_tracking.py:0: note: Use "-> None" if function does not return a value -apps/job/tests/test_job_event_tracking.py:0: error: Item "None" of "JobEvent | None" has no attribute "schema_version" [union-attr] -apps/job/tests/test_job_event_tracking.py:0: error: Item "None" of "JobEvent | None" has no attribute "change_id" [union-attr] -apps/job/tests/test_job_event_tracking.py:0: error: Item "None" of "JobEvent | None" has no attribute "delta_meta" [union-attr] -apps/job/tests/test_job_event_tracking.py:0: error: Item "None" of "JobEvent | None" has no attribute "delta_checksum" [union-attr] -apps/job/tests/test_job_event_tracking.py:0: error: Function is missing a return type annotation [no-untyped-def] -apps/job/tests/test_job_event_tracking.py:0: note: Use "-> None" if function does not return a value -apps/job/tests/test_job_event_tracking.py:0: error: Item "None" of "JobEvent | None" has no attribute "event_type" [union-attr] -apps/job/tests/test_job_event_tracking.py:0: error: Function is missing a return type annotation [no-untyped-def] -apps/job/tests/test_job_event_tracking.py:0: note: Use "-> None" if function does not return a value -apps/job/tests/test_job_event_tracking.py:0: error: Function is missing a return type annotation [no-untyped-def] -apps/job/tests/test_job_event_tracking.py:0: note: Use "-> None" if function does not return a value -apps/job/tests/test_job_event_tracking.py:0: error: Function is missing a return type annotation [no-untyped-def] -apps/job/tests/test_job_event_tracking.py:0: note: Use "-> None" if function does not return a value -apps/job/tests/test_job_event_tracking.py:0: error: Function is missing a return type annotation [no-untyped-def] -apps/job/tests/test_job_event_tracking.py:0: note: Use "-> None" if function does not return a value -apps/job/tests/test_job_event_tracking.py:0: error: Function is missing a return type annotation [no-untyped-def] -apps/job/tests/test_job_event_tracking.py:0: note: Use "-> None" if function does not return a value -apps/job/tests/test_job_event_tracking.py:0: error: Function is missing a return type annotation [no-untyped-def] -apps/job/tests/test_job_event_tracking.py:0: note: Use "-> None" if function does not return a value -apps/job/tests/test_job_event_tracking.py:0: error: Function is missing a return type annotation [no-untyped-def] -apps/job/tests/test_job_event_tracking.py:0: note: Use "-> None" if function does not return a value -apps/job/tests/test_job_event_tracking.py:0: error: Function is missing a return type annotation [no-untyped-def] -apps/job/tests/test_job_event_tracking.py:0: note: Use "-> None" if function does not return a value -apps/job/tests/test_job_event_tracking.py:0: error: Function is missing a type annotation [no-untyped-def] -apps/job/tests/test_job_event_tracking.py:0: error: Function is missing a return type annotation [no-untyped-def] -apps/job/tests/test_job_event_tracking.py:0: note: Use "-> None" if function does not return a value -apps/job/tests/test_job_event_tracking.py:0: error: Call to untyped function "_event" in typed context [no-untyped-call] -apps/job/tests/test_job_event_tracking.py:0: error: Function is missing a return type annotation [no-untyped-def] -apps/job/tests/test_job_event_tracking.py:0: note: Use "-> None" if function does not return a value -apps/job/tests/test_job_event_tracking.py:0: error: Call to untyped function "_event" in typed context [no-untyped-call] -apps/job/tests/test_job_event_tracking.py:0: error: Function is missing a return type annotation [no-untyped-def] -apps/job/tests/test_job_event_tracking.py:0: note: Use "-> None" if function does not return a value -apps/job/tests/test_job_event_tracking.py:0: error: Call to untyped function "_event" in typed context [no-untyped-call] -apps/job/tests/test_job_event_tracking.py:0: error: Function is missing a return type annotation [no-untyped-def] -apps/job/tests/test_job_event_tracking.py:0: note: Use "-> None" if function does not return a value -apps/job/tests/test_job_event_tracking.py:0: error: Call to untyped function "_event" in typed context [no-untyped-call] -apps/job/tests/test_job_event_tracking.py:0: error: Function is missing a return type annotation [no-untyped-def] -apps/job/tests/test_job_event_tracking.py:0: note: Use "-> None" if function does not return a value -apps/job/tests/test_job_event_tracking.py:0: error: Call to untyped function "_event" in typed context [no-untyped-call] -apps/job/tests/test_job_event_tracking.py:0: error: Function is missing a return type annotation [no-untyped-def] -apps/job/tests/test_job_event_tracking.py:0: note: Use "-> None" if function does not return a value -apps/job/tests/test_job_event_tracking.py:0: error: Call to untyped function "_event" in typed context [no-untyped-call] -apps/job/tests/test_job_event_tracking.py:0: error: Function is missing a return type annotation [no-untyped-def] -apps/job/tests/test_job_event_tracking.py:0: note: Use "-> None" if function does not return a value -apps/job/tests/test_job_event_tracking.py:0: error: Call to untyped function "_event" in typed context [no-untyped-call] -apps/job/tests/test_job_event_tracking.py:0: error: Function is missing a return type annotation [no-untyped-def] -apps/job/tests/test_job_event_tracking.py:0: note: Use "-> None" if function does not return a value -apps/job/tests/test_job_event_tracking.py:0: error: Call to untyped function "_event" in typed context [no-untyped-call] -apps/job/tests/test_job_event_tracking.py:0: error: Function is missing a return type annotation [no-untyped-def] -apps/job/tests/test_job_event_tracking.py:0: note: Use "-> None" if function does not return a value -apps/job/tests/test_job_event_tracking.py:0: error: Call to untyped function "_event" in typed context [no-untyped-call] -apps/job/tests/test_job_event_tracking.py:0: error: Function is missing a return type annotation [no-untyped-def] -apps/job/tests/test_job_event_tracking.py:0: note: Use "-> None" if function does not return a value -apps/job/tests/test_job_event_tracking.py:0: error: Call to untyped function "_event" in typed context [no-untyped-call] -apps/job/tests/test_job_event_tracking.py:0: error: Function is missing a return type annotation [no-untyped-def] -apps/job/tests/test_job_event_tracking.py:0: note: Use "-> None" if function does not return a value -apps/job/tests/test_job_event_tracking.py:0: error: Call to untyped function "_event" in typed context [no-untyped-call] -apps/job/tests/test_job_event_tracking.py:0: error: Function is missing a return type annotation [no-untyped-def] -apps/job/tests/test_job_event_tracking.py:0: note: Use "-> None" if function does not return a value -apps/job/tests/test_job_event_tracking.py:0: error: Call to untyped function "_event" in typed context [no-untyped-call] -apps/job/tests/test_job_event_tracking.py:0: error: Function is missing a return type annotation [no-untyped-def] -apps/job/tests/test_job_event_tracking.py:0: note: Use "-> None" if function does not return a value -apps/job/tests/test_job_event_tracking.py:0: error: Call to untyped function "_event" in typed context [no-untyped-call] -apps/job/tests/test_job_event_tracking.py:0: error: Function is missing a return type annotation [no-untyped-def] -apps/job/tests/test_job_event_tracking.py:0: note: Use "-> None" if function does not return a value -apps/job/tests/test_job_event_tracking.py:0: error: Call to untyped function "_event" in typed context [no-untyped-call] -apps/job/tests/test_job_event_tracking.py:0: error: Function is missing a return type annotation [no-untyped-def] -apps/job/tests/test_job_event_tracking.py:0: note: Use "-> None" if function does not return a value -apps/job/tests/test_job_event_tracking.py:0: error: Call to untyped function "_event" in typed context [no-untyped-call] -apps/job/tests/test_job_event_tracking.py:0: error: Function is missing a return type annotation [no-untyped-def] -apps/job/tests/test_job_event_tracking.py:0: note: Use "-> None" if function does not return a value -apps/job/tests/test_job_event_tracking.py:0: error: Call to untyped function "_event" in typed context [no-untyped-call] -apps/job/tests/test_job_event_tracking.py:0: error: Function is missing a return type annotation [no-untyped-def] -apps/job/tests/test_job_event_tracking.py:0: note: Use "-> None" if function does not return a value -apps/job/tests/test_job_event_tracking.py:0: error: Call to untyped function "_event" in typed context [no-untyped-call] -apps/job/tests/test_job_event_tracking.py:0: error: Function is missing a return type annotation [no-untyped-def] -apps/job/tests/test_job_event_tracking.py:0: note: Use "-> None" if function does not return a value -apps/job/tests/test_job_event_tracking.py:0: error: Function is missing a return type annotation [no-untyped-def] -apps/job/tests/test_job_event_tracking.py:0: note: Use "-> None" if function does not return a value -apps/job/tests/test_job_event_tracking.py:0: error: Function is missing a return type annotation [no-untyped-def] -apps/job/tests/test_job_event_tracking.py:0: note: Use "-> None" if function does not return a value -apps/job/tests/test_job_event_tracking.py:0: error: Function is missing a return type annotation [no-untyped-def] -apps/job/tests/test_job_event_tracking.py:0: note: Use "-> None" if function does not return a value -apps/job/tests/test_job_event_tracking.py:0: error: Function is missing a return type annotation [no-untyped-def] -apps/job/tests/test_job_event_tracking.py:0: note: Use "-> None" if function does not return a value -apps/job/tests/test_job_event_tracking.py:0: error: Function is missing a type annotation [no-untyped-def] -apps/job/tests/test_job_event_tracking.py:0: error: Function is missing a return type annotation [no-untyped-def] -apps/job/tests/test_job_event_tracking.py:0: note: Use "-> None" if function does not return a value -apps/job/tests/test_job_event_tracking.py:0: error: Call to untyped function "_make_job" in typed context [no-untyped-call] -apps/job/tests/test_job_event_tracking.py:0: error: Call to untyped function "_make_job" in typed context [no-untyped-call] -apps/job/tests/test_job_event_tracking.py:0: error: Call to untyped function "_make_job" in typed context [no-untyped-call] -apps/job/tests/test_job_event_tracking.py:0: error: Item "None" of "JobEvent | None" has no attribute "detail" [union-attr] -apps/job/tests/test_job_event_tracking.py:0: error: Function is missing a return type annotation [no-untyped-def] -apps/job/tests/test_job_event_tracking.py:0: note: Use "-> None" if function does not return a value -apps/job/tests/test_job_event_tracking.py:0: error: Call to untyped function "_make_job" in typed context [no-untyped-call] -apps/job/tests/test_job_event_tracking.py:0: error: Function is missing a return type annotation [no-untyped-def] -apps/job/tests/test_job_event_tracking.py:0: note: Use "-> None" if function does not return a value -apps/job/tests/test_job_event_tracking.py:0: error: Call to untyped function "_make_job" in typed context [no-untyped-call] -apps/job/tests/test_job_event_tracking.py:0: error: Call to untyped function "_make_job" in typed context [no-untyped-call] -apps/job/tests/test_job_event_tracking.py:0: error: Call to untyped function "_make_job" in typed context [no-untyped-call] -apps/job/tests/test_job_event_tracking.py:0: error: Call to untyped function "_make_job" in typed context [no-untyped-call] -apps/job/tests/test_job_event_tracking.py:0: error: Call to untyped function "_make_job" in typed context [no-untyped-call] -apps/job/tests/test_job_event_tracking.py:0: error: Call to untyped function "_make_job" in typed context [no-untyped-call] -apps/job/tests/test_job_event_tracking.py:0: error: Call to untyped function "_make_job" in typed context [no-untyped-call] -apps/job/tests/test_job_event_tracking.py:0: error: Item "None" of "JobEvent | None" has no attribute "event_type" [union-attr] -apps/job/tests/test_job_event_tracking.py:0: error: Item "None" of "JobEvent | None" has no attribute "detail" [union-attr] -apps/job/tests/test_job_event_tracking.py:0: error: Item "None" of "JobEvent | None" has no attribute "description" [union-attr] -apps/job/tests/test_job_event_tracking.py:0: error: Call to untyped function "update" in typed context [no-untyped-call] -apps/job/tests/test_job_event_tracking.py:0: error: Call to untyped function "update" in typed context [no-untyped-call] -apps/job/tests/test_job_event_tracking.py:0: error: Call to untyped function "untracked_update" in typed context [no-untyped-call] -apps/job/tests/test_job_event_tracking.py:0: error: Call to untyped function "update" in typed context [no-untyped-call] -apps/workflow/services/llm_service.py:0: error: Module "litellm" does not explicitly export attribute "set_verbose" [attr-defined] -apps/workflow/services/llm_service.py:0: error: Function is missing a return type annotation [no-untyped-def] -apps/workflow/services/llm_service.py:0: error: Call to untyped function "get_default" of "AIProvider" in typed context [no-untyped-call] -apps/workflow/services/llm_service.py:0: error: No overload variant of "get" of "dict" matches argument types "str", "str" [call-overload] -apps/workflow/services/llm_service.py:0: note: Possible overload variants: -apps/workflow/services/llm_service.py:0: note: def get(self, AIProviderTypes, None = ..., /) -> str | None -apps/workflow/services/llm_service.py:0: note: def get(self, AIProviderTypes, str, /) -> str -apps/workflow/services/llm_service.py:0: note: def [_T] get(self, AIProviderTypes, _T, /) -> str | _T -apps/workflow/services/llm_service.py:0: error: Function is missing a type annotation for one or more parameters [no-untyped-def] -apps/workflow/services/llm_service.py:0: error: Missing type arguments for generic type "dict" [type-arg] -apps/workflow/services/llm_service.py:0: error: Missing type arguments for generic type "dict" [type-arg] -apps/workflow/services/llm_service.py:0: error: Missing type arguments for generic type "dict" [type-arg] -apps/workflow/services/llm_service.py:0: error: Function is missing a type annotation for one or more parameters [no-untyped-def] -apps/workflow/services/llm_service.py:0: error: Missing type arguments for generic type "dict" [type-arg] -apps/workflow/services/llm_service.py:0: error: Missing type arguments for generic type "dict" [type-arg] -apps/workflow/services/llm_service.py:0: error: Returning Any from function declared to return "dict[Any, Any]" [no-any-return] -apps/workflow/services/llm_service.py:0: error: Function is missing a type annotation for one or more parameters [no-untyped-def] -apps/workflow/services/llm_service.py:0: error: Returning Any from function declared to return "str" [no-any-return] -apps/workflow/services/llm_service.py:0: error: Missing type arguments for generic type "dict" [type-arg] -apps/workflow/services/llm_service.py:0: error: Missing type arguments for generic type "dict" [type-arg] -apps/workflow/services/llm_service.py:0: error: Function is missing a type annotation for one or more parameters [no-untyped-def] -apps/workflow/services/llm_service.py:0: error: Function is missing a type annotation for one or more parameters [no-untyped-def] -apps/workflow/services/llm_service.py:0: error: Missing type arguments for generic type "dict" [type-arg] -apps/process/services/safety_ai_service.py:0: error: Function is missing a return type annotation [no-untyped-def] -apps/process/services/safety_ai_service.py:0: note: Use "-> None" if function does not return a value -apps/process/services/safety_ai_service.py:0: error: Missing type arguments for generic type "dict" [type-arg] -apps/process/services/safety_ai_service.py:0: error: Item "str" of "dict[Any, Any] | str" has no attribute "setdefault" [union-attr] -apps/process/services/safety_ai_service.py:0: error: Item "str" of "dict[Any, Any] | str" has no attribute "setdefault" [union-attr] -apps/process/services/safety_ai_service.py:0: error: Item "str" of "dict[Any, Any] | str" has no attribute "setdefault" [union-attr] -apps/process/services/safety_ai_service.py:0: error: Item "str" of "dict[Any, Any] | str" has no attribute "setdefault" [union-attr] -apps/process/services/safety_ai_service.py:0: error: Item "str" of "dict[Any, Any] | str" has no attribute "setdefault" [union-attr] -apps/process/services/safety_ai_service.py:0: error: Item "str" of "dict[Any, Any] | str" has no attribute "setdefault" [union-attr] -apps/process/services/safety_ai_service.py:0: error: Incompatible return value type (got "dict[Any, Any] | str", expected "dict[str, Any]") [return-value] -apps/process/services/safety_ai_service.py:0: error: Item "str" of "dict[Any, Any] | str" has no attribute "setdefault" [union-attr] -apps/process/services/safety_ai_service.py:0: error: Item "str" of "dict[Any, Any] | str" has no attribute "setdefault" [union-attr] -apps/process/services/safety_ai_service.py:0: error: Item "str" of "dict[Any, Any] | str" has no attribute "setdefault" [union-attr] -apps/process/services/safety_ai_service.py:0: error: Item "str" of "dict[Any, Any] | str" has no attribute "setdefault" [union-attr] -apps/process/services/safety_ai_service.py:0: error: Item "str" of "dict[Any, Any] | str" has no attribute "setdefault" [union-attr] -apps/process/services/safety_ai_service.py:0: error: Item "str" of "dict[Any, Any] | str" has no attribute "setdefault" [union-attr] -apps/process/services/safety_ai_service.py:0: error: Incompatible return value type (got "dict[Any, Any] | str", expected "dict[str, Any]") [return-value] -apps/process/services/safety_ai_service.py:0: error: Item "str" of "dict[Any, Any] | str" has no attribute "setdefault" [union-attr] -apps/process/services/safety_ai_service.py:0: error: Item "str" of "dict[Any, Any] | str" has no attribute "setdefault" [union-attr] -apps/process/services/safety_ai_service.py:0: error: Item "str" of "dict[Any, Any] | str" has no attribute "setdefault" [union-attr] -apps/process/services/safety_ai_service.py:0: error: Item "str" of "dict[Any, Any] | str" has no attribute "setdefault" [union-attr] -apps/process/services/safety_ai_service.py:0: error: Item "str" of "dict[Any, Any] | str" has no attribute "setdefault" [union-attr] -apps/process/services/safety_ai_service.py:0: error: Item "str" of "dict[Any, Any] | str" has no attribute "setdefault" [union-attr] -apps/process/services/safety_ai_service.py:0: error: Incompatible return value type (got "dict[Any, Any] | str", expected "dict[str, Any]") [return-value] -apps/process/services/safety_ai_service.py:0: error: Returning Any from function declared to return "list[str]" [no-any-return] -apps/process/services/safety_ai_service.py:0: error: Item "str" of "dict[Any, Any] | str" has no attribute "get" [union-attr] -apps/process/services/safety_ai_service.py:0: error: Returning Any from function declared to return "list[dict[str, str]]" [no-any-return] -apps/process/services/safety_ai_service.py:0: error: Item "str" of "dict[Any, Any] | str" has no attribute "get" [union-attr] -apps/process/services/safety_ai_service.py:0: error: Returning Any from function declared to return "str" [no-any-return] -apps/process/services/safety_ai_service.py:0: error: Item "str" of "dict[Any, Any] | str" has no attribute "get" [union-attr] -apps/process/services/safety_ai_service.py:0: error: Item "str" of "dict[Any, Any] | str" has no attribute "setdefault" [union-attr] -apps/process/services/safety_ai_service.py:0: error: Item "str" of "dict[Any, Any] | str" has no attribute "setdefault" [union-attr] -apps/process/services/safety_ai_service.py:0: error: Item "str" of "dict[Any, Any] | str" has no attribute "setdefault" [union-attr] -apps/process/services/safety_ai_service.py:0: error: Item "str" of "dict[Any, Any] | str" has no attribute "setdefault" [union-attr] -apps/process/services/safety_ai_service.py:0: error: Item "str" of "dict[Any, Any] | str" has no attribute "setdefault" [union-attr] -apps/process/services/safety_ai_service.py:0: error: Item "str" of "dict[Any, Any] | str" has no attribute "setdefault" [union-attr] -apps/process/services/safety_ai_service.py:0: error: Incompatible return value type (got "dict[Any, Any] | str", expected "dict[str, Any]") [return-value] -apps/job/services/quote_mode_controller.py:0: error: Function is missing a return type annotation [no-untyped-def] -apps/job/services/quote_mode_controller.py:0: note: Use "-> None" if function does not return a value -apps/job/services/quote_mode_controller.py:0: error: Function is missing a type annotation for one or more parameters [no-untyped-def] -apps/job/services/quote_mode_controller.py:0: error: Missing type arguments for generic type "dict" [type-arg] -apps/job/services/quote_mode_controller.py:0: error: Returning Any from function declared to return "dict[str, Any]" [no-any-return] -apps/job/services/quote_mode_controller.py:0: error: Missing type arguments for generic type "dict" [type-arg] -apps/job/services/quote_mode_controller.py:0: error: Missing type arguments for generic type "dict" [type-arg] -apps/job/services/quote_mode_controller.py:0: error: Cannot call function of unknown type [operator] -apps/job/services/quote_mode_controller.py:0: error: Returning Any from function declared to return "str" [no-any-return] -apps/job/services/quote_mode_controller.py:0: error: Incompatible return value type (got "None", expected "str") [return-value] -apps/job/services/mcp_chat_service.py:0: error: Item "None" of "Client | None" has no attribute "name" [union-attr] -apps/job/services/mcp_chat_service.py:0: error: Dict entry 1 has incompatible type "str": "str | None"; expected "str": "str" [dict-item] -apps/job/services/mcp_chat_service.py:0: error: Dict entry 2 has incompatible type "str": "list[dict[str, dict[str, Any | str] | Any | str]]"; expected "str": "str" [dict-item] -apps/quoting/services/pdf_import_service.py:0: error: Function is missing a return type annotation [no-untyped-def] -apps/quoting/services/pdf_import_service.py:0: note: Use "-> None" if function does not return a value -apps/quoting/services/pdf_import_service.py:0: error: Missing type arguments for generic type "Dict" [type-arg] -apps/quoting/services/pdf_import_service.py:0: error: "object" has no attribute "append" [attr-defined] -apps/quoting/services/pdf_import_service.py:0: error: Missing type arguments for generic type "Dict" [type-arg] -apps/quoting/services/pdf_import_service.py:0: error: Missing type arguments for generic type "Dict" [type-arg] -apps/quoting/services/pdf_import_service.py:0: error: Incompatible return value type (got "None", expected "SupplierProduct") [return-value] -apps/quoting/services/pdf_import_service.py:0: error: Missing type arguments for generic type "Dict" [type-arg] -apps/quoting/services/pdf_import_service.py:0: error: Function is missing a return type annotation [no-untyped-def] -apps/quoting/services/pdf_import_service.py:0: error: Missing type arguments for generic type "Dict" [type-arg] -apps/quoting/services/pdf_import_service.py:0: error: Missing type arguments for generic type "Dict" [type-arg] -apps/quoting/services/pdf_import_service.py:0: error: Missing type arguments for generic type "Dict" [type-arg] -apps/quoting/services/pdf_import_service.py:0: error: Missing type arguments for generic type "Dict" [type-arg] -apps/quoting/services/pdf_import_service.py:0: error: Missing type arguments for generic type "Dict" [type-arg] -apps/quoting/services/pdf_import_service.py:0: error: Function is missing a return type annotation [no-untyped-def] -apps/quoting/services/pdf_import_service.py:0: note: Use "-> None" if function does not return a value -apps/quoting/scrapers/base.py:0: error: Function is missing a type annotation for one or more parameters [no-untyped-def] -apps/quoting/scrapers/base.py:0: error: Function is missing a return type annotation [no-untyped-def] -apps/quoting/scrapers/base.py:0: error: Incompatible types in assignment (expression has type "WebDriver", variable has type "None") [assignment] -apps/quoting/scrapers/base.py:0: error: Function is missing a return type annotation [no-untyped-def] -apps/quoting/scrapers/base.py:0: note: Use "-> None" if function does not return a value -apps/quoting/scrapers/base.py:0: error: Function is missing a return type annotation [no-untyped-def] -apps/quoting/scrapers/base.py:0: note: Use "-> None" if function does not return a value -apps/quoting/scrapers/base.py:0: error: Function is missing a type annotation [no-untyped-def] -apps/quoting/scrapers/base.py:0: error: Function is missing a return type annotation [no-untyped-def] -apps/quoting/scrapers/base.py:0: note: Use "-> None" if function does not return a value -apps/quoting/scrapers/base.py:0: error: Function is missing a return type annotation [no-untyped-def] -apps/quoting/scrapers/base.py:0: note: Use "-> None" if function does not return a value -apps/quoting/scrapers/base.py:0: error: Call to untyped function "setup_driver" in typed context [no-untyped-call] -apps/quoting/scrapers/base.py:0: error: Call to untyped function "login" of "BaseScraper" in typed context [no-untyped-call] -apps/quoting/scrapers/base.py:0: error: Call to untyped function "get_product_urls" of "BaseScraper" in typed context [no-untyped-call] -apps/quoting/scrapers/base.py:0: error: Call to untyped function "scrape_product" of "BaseScraper" in typed context [no-untyped-call] -apps/quoting/scrapers/base.py:0: error: Call to untyped function "save_products" in typed context [no-untyped-call] -apps/quoting/scrapers/base.py:0: error: Call to untyped function "save_products" in typed context [no-untyped-call] -apps/quoting/scrapers/base.py:0: error: Call to untyped function "cleanup" in typed context [no-untyped-call] -apps/quoting/scrapers/base.py:0: error: Function is missing a type annotation [no-untyped-def] -apps/process/services/procedure_service.py:0: error: Function is missing a return type annotation [no-untyped-def] -apps/process/services/procedure_service.py:0: note: Use "-> None" if function does not return a value -apps/process/services/procedure_service.py:0: error: Call to untyped function "SafetyAIService" in typed context [no-untyped-call] -apps/process/services/procedure_service.py:0: error: Call to untyped function "GoogleDocsService" in typed context [no-untyped-call] +apps/workflow/views/xero/xero_quote_manager.py:0: error: Function is missing a type annotation [no-untyped-def] +apps/workflow/views/xero/xero_quote_manager.py:0: error: Function is missing a return type annotation [no-untyped-def] +apps/workflow/views/xero/xero_quote_manager.py:0: error: Item "None" of "Job | None" has no attribute "quote" [union-attr] +apps/workflow/views/xero/xero_quote_manager.py:0: error: Item "None" of "Job | None" has no attribute "quote" [union-attr] +apps/workflow/views/xero/xero_quote_manager.py:0: error: Function is missing a return type annotation [no-untyped-def] +apps/workflow/views/xero/xero_quote_manager.py:0: error: Item "None" of "Job | None" has no attribute "quoted" [union-attr] +apps/workflow/views/xero/xero_quote_manager.py:0: error: Function is missing a return type annotation [no-untyped-def] +apps/workflow/views/xero/xero_quote_manager.py:0: note: Use "-> None" if function does not return a value +apps/workflow/views/xero/xero_quote_manager.py:0: error: Item "None" of "Job | None" has no attribute "quoted" [union-attr] +apps/workflow/views/xero/xero_quote_manager.py:0: error: Item "None" of "Job | None" has no attribute "id" [union-attr] +apps/workflow/views/xero/xero_quote_manager.py:0: error: Argument "client_external_id" to "QuotePayload" has incompatible type "str | None"; expected "str" [arg-type] +apps/workflow/views/xero/xero_quote_manager.py:0: error: Function is missing a return type annotation [no-untyped-def] +apps/workflow/views/xero/xero_quote_manager.py:0: error: Call to untyped function "validate_company" in typed context [no-untyped-call] +apps/workflow/views/xero/xero_quote_manager.py:0: error: Call to untyped function "state_valid_for_xero" in typed context [no-untyped-call] +apps/workflow/views/xero/xero_quote_manager.py:0: error: Incompatible type for "xero_id" of "Quote" (got "str | None", expected "str | UUID") [misc] +apps/workflow/views/xero/xero_quote_manager.py:0: error: Item "None" of "Job | None" has no attribute "save" [union-attr] +apps/workflow/views/xero/xero_quote_manager.py:0: error: Item "None" of "Job | None" has no attribute "id" [union-attr] +apps/workflow/views/xero/xero_quote_manager.py:0: error: Argument 2 to "_add_xero_history_note" of "XeroDocumentManager" has incompatible type "str | None"; expected "str" [arg-type] +apps/workflow/views/xero/xero_quote_manager.py:0: error: Function is missing a return type annotation [no-untyped-def] +apps/workflow/views/xero/xero_quote_manager.py:0: error: Call to untyped function "validate_company" in typed context [no-untyped-call] +apps/workflow/views/xero/xero_quote_manager.py:0: error: Call to untyped function "get_xero_id" in typed context [no-untyped-call] +apps/workflow/views/xero/xero_quote_manager.py:0: error: Item "None" of "Job | None" has no attribute "quote" [union-attr] +apps/workflow/views/xero/xero_quote_manager.py:0: error: Item "None" of "Job | None" has no attribute "id" [union-attr] +apps/workflow/views/xero/xero_quote_manager.py:0: error: Item "None" of "Job | None" has no attribute "quote" [union-attr] +apps/workflow/views/xero/xero_quote_manager.py:0: error: Item "None" of "Job | None" has no attribute "quote" [union-attr] +apps/workflow/views/xero/xero_quote_manager.py:0: error: Item "None" of "Job | None" has no attribute "quote" [union-attr] +apps/workflow/views/xero/xero_quote_manager.py:0: error: Item "None" of "Job | None" has no attribute "id" [union-attr] +apps/workflow/views/xero/xero_quote_manager.py:0: error: Item "None" of "Job | None" has no attribute "save" [union-attr] +apps/workflow/views/xero/xero_po_manager.py:0: error: Argument 1 to "DocumentLineItem" has incompatible type "**dict[str, object]"; expected "str" [arg-type] +apps/workflow/views/xero/xero_po_manager.py:0: error: Argument 1 to "DocumentLineItem" has incompatible type "**dict[str, object]"; expected "Decimal" [arg-type] +apps/workflow/views/xero/xero_po_manager.py:0: error: Argument 1 to "DocumentLineItem" has incompatible type "**dict[str, object]"; expected "str | None" [arg-type] +apps/workflow/views/xero/xero_po_manager.py:0: error: Argument "supplier_external_id" to "POPayload" has incompatible type "str | None"; expected "str" [arg-type] +apps/workflow/views/xero/xero_po_manager.py:0: error: Function is missing a type annotation for one or more parameters [no-untyped-def] +apps/workflow/views/xero/xero_po_manager.py:0: error: Call to untyped function "save" in typed context [no-untyped-call] +apps/workflow/views/xero/xero_po_manager.py:0: error: Missing type arguments for generic type "dict" [type-arg] +apps/workflow/views/xero/xero_po_manager.py:0: error: Missing type arguments for generic type "dict" [type-arg] +apps/workflow/views/xero/xero_po_manager.py:0: error: Call to untyped function "validate_for_xero_sync" in typed context [no-untyped-call] +apps/workflow/views/xero/xero_po_manager.py:0: error: Missing type arguments for generic type "dict" [type-arg] +apps/workflow/views/xero/xero_po_manager.py:0: error: Call to untyped function "save" in typed context [no-untyped-call] +apps/workflow/views/xero/xero_po_manager.py:0: error: Function is missing a return type annotation [no-untyped-def] +apps/workflow/views/xero/xero_po_manager.py:0: note: Use "-> None" if function does not return a value +apps/workflow/views/xero/xero_invoice_manager.py:0: error: Function is missing a return type annotation [no-untyped-def] +apps/workflow/views/xero/xero_invoice_manager.py:0: error: Cannot resolve keyword 'created_at' into field. Choices are: amount_due, billing_metadata, company, company_id, date, django_created_at, django_updated_at, due_date, id, job, job_id, line_items, number, online_url, raw_json, status, tax, total_excl_tax, total_incl_tax, xero_id, xero_last_modified, xero_last_synced, xero_tenant_id [misc] +apps/workflow/views/xero/xero_invoice_manager.py:0: error: Function is missing a return type annotation [no-untyped-def] +apps/workflow/views/xero/xero_invoice_manager.py:0: error: Item "None" of "Job | None" has no attribute "paid" [union-attr] +apps/workflow/views/xero/xero_invoice_manager.py:0: error: Argument "client_external_id" to "InvoicePayload" has incompatible type "str | None"; expected "str" [arg-type] +apps/workflow/views/xero/xero_invoice_manager.py:0: error: Function is missing a return type annotation [no-untyped-def] +apps/workflow/views/xero/xero_invoice_manager.py:0: error: Missing type arguments for generic type "dict" [type-arg] +apps/workflow/views/xero/xero_invoice_manager.py:0: error: Call to untyped function "validate_company" in typed context [no-untyped-call] +apps/workflow/views/xero/xero_invoice_manager.py:0: error: Call to untyped function "state_valid_for_xero" in typed context [no-untyped-call] +apps/workflow/views/xero/xero_invoice_manager.py:0: error: Item "None" of "Job | None" has no attribute "save" [union-attr] +apps/workflow/views/xero/xero_invoice_manager.py:0: error: Item "None" of "Job | None" has no attribute "id" [union-attr] +apps/workflow/views/xero/xero_invoice_manager.py:0: error: Argument 2 to "_add_xero_history_note" of "XeroDocumentManager" has incompatible type "str | None"; expected "str" [arg-type] +apps/workflow/views/xero/xero_invoice_manager.py:0: error: Argument 1 to "_attach_workshop_pdf" of "XeroInvoiceManager" has incompatible type "str | None"; expected "str" [arg-type] +apps/workflow/views/xero/xero_invoice_manager.py:0: error: Missing type arguments for generic type "dict" [type-arg] +apps/workflow/views/xero/xero_invoice_manager.py:0: error: Function is missing a return type annotation [no-untyped-def] +apps/workflow/views/xero/xero_invoice_manager.py:0: error: Call to untyped function "validate_company" in typed context [no-untyped-call] +apps/workflow/views/xero/xero_invoice_manager.py:0: error: Call to untyped function "get_xero_id" in typed context [no-untyped-call] +apps/workflow/views/xero/xero_invoice_manager.py:0: error: Item "None" of "Job | None" has no attribute "save" [union-attr] +apps/company/tests/test_company_fts_search.py:0: error: Function is missing a return type annotation [no-untyped-def] +apps/company/tests/test_company_fts_search.py:0: error: Function is missing a type annotation for one or more parameters [no-untyped-def] +apps/company/tests/test_company_fts_search.py:0: error: Function is missing a return type annotation [no-untyped-def] +apps/company/tests/test_company_fts_search.py:0: error: Function is missing a type annotation [no-untyped-def] +apps/company/tests/test_company_fts_search.py:0: error: Function is missing a type annotation [no-untyped-def] +apps/company/tests/test_company_fts_search.py:0: error: Function is missing a type annotation [no-untyped-def] +apps/company/tests/test_company_fts_search.py:0: error: Function is missing a type annotation [no-untyped-def] +apps/company/tests/test_company_fts_search.py:0: error: Function is missing a type annotation [no-untyped-def] +apps/company/tests/test_company_fts_search.py:0: error: Function is missing a type annotation [no-untyped-def] +apps/company/tests/test_company_fts_search.py:0: error: Function is missing a type annotation [no-untyped-def] +apps/company/tests/test_company_fts_search.py:0: error: Function is missing a type annotation [no-untyped-def] +apps/company/tests/test_company_fts_search.py:0: error: Function is missing a type annotation [no-untyped-def] +apps/company/tests/test_company_fts_search.py:0: error: Function is missing a type annotation [no-untyped-def] +apps/company/tests/test_company_fts_search.py:0: error: Function is missing a type annotation [no-untyped-def] +apps/company/tests/test_company_fts_search.py:0: error: Function is missing a type annotation [no-untyped-def] +apps/company/tests/test_company_fts_search.py:0: error: Function is missing a type annotation [no-untyped-def] +apps/company/tests/test_company_fts_search.py:0: error: Function is missing a type annotation [no-untyped-def] +apps/company/tests/test_company_fts_search.py:0: error: Function is missing a type annotation [no-untyped-def] +apps/company/tests/test_company_fts_search.py:0: error: Function is missing a type annotation [no-untyped-def] +apps/company/tests/test_company_fts_search.py:0: error: Function is missing a type annotation [no-untyped-def] +apps/company/tests/test_company_fts_search.py:0: error: Function is missing a type annotation [no-untyped-def] +apps/company/tests/test_company_fts_search.py:0: error: Function is missing a type annotation [no-untyped-def] +apps/company/tests/test_company_fts_search.py:0: error: Incompatible types in assignment (expression has type "None", variable has type "Staff | AnonymousUser") [assignment] +apps/company/tests/test_company_fts_search.py:0: error: Function is missing a type annotation [no-untyped-def] apps/job/services/chat_service.py:0: error: Call to untyped function "QuoteModeController" in typed context [no-untyped-call] -apps/job/services/chat_service.py:0: error: Item "None" of "Client | None" has no attribute "name" [union-attr] +apps/job/services/chat_service.py:0: error: Item "None" of "Company | None" has no attribute "name" [union-attr] apps/job/services/chat_service.py:0: error: Returning Any from function declared to return "str" [no-any-return] apps/job/services/chat_service.py:0: error: Cannot call function of unknown type [operator] +apps/quoting/views.py:0: error: Function is missing a type annotation [no-untyped-def] +apps/quoting/views.py:0: error: Call to untyped function "PDFDataValidationService" in typed context [no-untyped-call] +apps/quoting/views.py:0: error: Call to untyped function "PDFImportService" in typed context [no-untyped-call] apps/quoting/scrapers/steel_and_tube.py:0: error: Function is missing a return type annotation [no-untyped-def] apps/quoting/scrapers/steel_and_tube.py:0: error: "None" has no attribute "get" [attr-defined] apps/quoting/scrapers/steel_and_tube.py:0: error: Value of type variable "D" of "WebDriverWait" cannot be "None" [type-var] @@ -3617,6 +3110,7 @@ apps/process/views/procedure_viewsets.py:0: error: Function is missing a type an apps/process/views/procedure_viewsets.py:0: error: Call to untyped function "SafetyAIService" in typed context [no-untyped-call] apps/process/views/procedure_viewsets.py:0: error: Function is missing a type annotation [no-untyped-def] apps/process/views/procedure_viewsets.py:0: error: Call to untyped function "SafetyAIService" in typed context [no-untyped-call] +apps/process/urls_rest.py:0: error: Function is missing a type annotation [no-untyped-def] apps/process/tests/test_procedure_service.py:0: error: Function is missing a return type annotation [no-untyped-def] apps/process/tests/test_procedure_service.py:0: error: Call to untyped function "ProcedureService" in typed context [no-untyped-call] apps/process/tests/test_procedure_service.py:0: error: Function is missing a return type annotation [no-untyped-def] @@ -3628,7 +3122,113 @@ apps/process/tests/test_procedure_service.py:0: error: Call to untyped function apps/process/tests/test_procedure_service.py:0: error: Function is missing a return type annotation [no-untyped-def] apps/process/tests/test_procedure_service.py:0: note: Use "-> None" if function does not return a value apps/process/tests/test_procedure_service.py:0: error: Call to untyped function "_make_service" in typed context [no-untyped-call] -apps/job/views/job_quote_chat_api.py:0: error: Function is missing a type annotation [no-untyped-def] +apps/workflow/api/reports/payroll_reconciliation.py:0: error: Module "drf_spectacular.utils" does not explicitly export attribute "OpenApiTypes" [attr-defined] +apps/job/views/workshop_view.py:0: error: Module "drf_spectacular.utils" does not explicitly export attribute "OpenApiTypes" [attr-defined] +apps/job/views/workshop_view.py:0: error: Missing type arguments for generic type "ListAPIView" [type-arg] +apps/job/views/workshop_view.py:0: error: Function is missing a return type annotation [no-untyped-def] +apps/job/views/workshop_view.py:0: error: Incompatible type for lookup 'people__id': (got "UUID | None", expected "UUID | str") [misc] +apps/job/views/workshop_view.py:0: error: Function is missing a type annotation [no-untyped-def] +apps/job/views/workshop_view.py:0: error: Function is missing a type annotation [no-untyped-def] +apps/job/views/workshop_view.py:0: error: Function is missing a type annotation [no-untyped-def] +apps/job/views/workshop_view.py:0: error: Argument "job_id" to "persist_app_error" has incompatible type "Any | None"; expected "str" [arg-type] +apps/job/views/workshop_view.py:0: error: Function is missing a type annotation [no-untyped-def] +apps/workflow/middleware.py:0: error: Function is missing a type annotation [no-untyped-def] +apps/workflow/middleware.py:0: error: Function is missing a type annotation [no-untyped-def] +apps/workflow/middleware.py:0: error: Function is missing a type annotation [no-untyped-def] +apps/workflow/middleware.py:0: error: Call to untyped function "authenticate" in typed context [no-untyped-call] +apps/accounts/views/user_profile_view.py:0: error: Module "drf_spectacular.utils" does not explicitly export attribute "OpenApiTypes" [attr-defined] +apps/accounts/views/token_view.py:0: error: Function is missing a type annotation [no-untyped-def] +apps/accounts/views/token_view.py:0: error: Missing type arguments for generic type "dict" [type-arg] +apps/accounts/views/token_view.py:0: error: Item "None" of "Any | None" has no attribute "total_seconds" [union-attr] +apps/accounts/views/token_view.py:0: error: Item "None" of "Any | None" has no attribute "total_seconds" [union-attr] +apps/accounts/views/token_view.py:0: error: Function is missing a type annotation [no-untyped-def] +apps/accounts/views/token_view.py:0: error: Missing type arguments for generic type "dict" [type-arg] +apps/accounts/views/token_view.py:0: error: Item "None" of "Any | None" has no attribute "total_seconds" [union-attr] +apps/accounts/views/staff_views.py:0: error: Missing type arguments for generic type "ListAPIView" [type-arg] +apps/accounts/views/staff_views.py:0: error: Function is missing a type annotation [no-untyped-def] +apps/accounts/views/staff_views.py:0: error: Call to untyped function "get_queryset" in typed context [no-untyped-call] +apps/accounts/views/staff_views.py:0: error: Function is missing a return type annotation [no-untyped-def] +apps/accounts/views/staff_views.py:0: error: Function is missing a return type annotation [no-untyped-def] +apps/accounts/views/staff_views.py:0: error: Function is missing a type annotation [no-untyped-def] +apps/accounts/views/staff_api.py:0: error: Missing type arguments for generic type "ListCreateAPIView" [type-arg] +apps/accounts/views/staff_api.py:0: error: Function is missing a return type annotation [no-untyped-def] +apps/accounts/views/staff_api.py:0: error: Function is missing a return type annotation [no-untyped-def] +apps/accounts/views/staff_api.py:0: error: Function is missing a type annotation [no-untyped-def] +apps/accounts/views/staff_api.py:0: error: Missing type arguments for generic type "RetrieveUpdateDestroyAPIView" [type-arg] +apps/accounts/views/staff_api.py:0: error: Function is missing a return type annotation [no-untyped-def] +apps/accounts/views/staff_api.py:0: error: Function is missing a type annotation [no-untyped-def] +apps/purchasing/views/supplier_search_rest_view.py:0: error: Module "drf_spectacular.utils" does not explicitly export attribute "OpenApiTypes" [attr-defined] +apps/purchasing/tests/test_serializers.py:0: error: Function is missing a return type annotation [no-untyped-def] +apps/purchasing/tests/test_serializers.py:0: note: Use "-> None" if function does not return a value +apps/purchasing/tests/test_serializers.py:0: error: Function is missing a return type annotation [no-untyped-def] +apps/purchasing/tests/test_serializers.py:0: note: Use "-> None" if function does not return a value +apps/purchasing/tests/test_serializers.py:0: error: "PurchaseOrder" has no attribute "detail_lines" [attr-defined] +apps/purchasing/tests/test_serializers.py:0: error: Function is missing a return type annotation [no-untyped-def] +apps/purchasing/tests/test_serializers.py:0: note: Use "-> None" if function does not return a value +apps/purchasing/tests/test_serializers.py:0: error: "PurchaseOrder" has no attribute "detail_lines" [attr-defined] +apps/purchasing/services/stock_search_service.py:0: error: Argument 1 to "add" of "set" has incompatible type "tuple[float, ...]"; expected "tuple[float, float]" [arg-type] +apps/purchasing/services/stock_search_service.py:0: error: Argument 1 to "add" of "set" has incompatible type "tuple[float, ...]"; expected "tuple[float, float]" [arg-type] +apps/purchasing/services/stock_search_service.py:0: error: Function is missing a return type annotation [no-untyped-def] +apps/purchasing/services/stock_search_service.py:0: error: Function is missing a type annotation for one or more parameters [no-untyped-def] +apps/purchasing/services/stock_search_service.py:0: error: Incompatible return value type (got "ReturnDict[Any, Any]", expected "list[dict[str, Any]]") [return-value] +apps/purchasing/services/stock_search_service.py:0: error: Missing return statement [return] +apps/purchasing/services/stock_search_service.py:0: error: Missing return statement [return] +apps/purchasing/services/stock_search_service.py:0: error: Incompatible types in assignment (expression has type "QuerySet[Stock, Stock]", variable has type "list[Stock]") [assignment] +apps/job/views/job_costline_views.py:0: error: Function is missing a return type annotation [no-untyped-def] +apps/job/views/job_costline_views.py:0: error: Function is missing a type annotation [no-untyped-def] +apps/job/views/job_costline_views.py:0: error: Dict entry 0 has incompatible type "str": "str | _StrPromise"; expected "str": "str" [dict-item] +apps/job/views/job_costline_views.py:0: error: Function is missing a return type annotation [no-untyped-def] +apps/job/views/job_costline_views.py:0: error: Function is missing a type annotation [no-untyped-def] +apps/job/views/job_costline_views.py:0: error: Function is missing a type annotation for one or more parameters [no-untyped-def] +apps/job/views/job_costline_views.py:0: error: Function is missing a type annotation [no-untyped-def] +apps/job/views/job_costline_views.py:0: error: Call to untyped function "delete" in typed context [no-untyped-call] +apps/job/views/job_costline_views.py:0: error: Function is missing a type annotation [no-untyped-def] +apps/job/views/job_costline_views.py:0: error: Function is missing a return type annotation [no-untyped-def] +apps/job/views/job_costline_views.py:0: error: Function is missing a type annotation for one or more parameters [no-untyped-def] +apps/job/views/job_costline_views.py:0: error: Function is missing a return type annotation [no-untyped-def] +apps/job/views/job_costline_views.py:0: error: Call to untyped function "save" in typed context [no-untyped-call] +apps/job/views/workshop_pdf_view.py:0: error: Function is missing a type annotation [no-untyped-def] +apps/job/views/month_end_rest_view.py:0: error: Function is missing a return type annotation [no-untyped-def] +apps/job/views/month_end_rest_view.py:0: error: Function is missing a type annotation [no-untyped-def] +apps/job/views/month_end_rest_view.py:0: error: Function is missing a type annotation [no-untyped-def] +apps/job/views/modern_timesheet_views.py:0: error: Function is missing a return type annotation [no-untyped-def] +apps/job/views/modern_timesheet_views.py:0: error: Function is missing a type annotation [no-untyped-def] +apps/job/views/modern_timesheet_views.py:0: error: Call to untyped function "get_serializer_class" in typed context [no-untyped-call] +apps/job/views/modern_timesheet_views.py:0: error: Function is missing a type annotation [no-untyped-def] +apps/job/views/modern_timesheet_views.py:0: error: Item "None" of "CostLine@AnnotatedWith[TypedDict({'calculated_total_cost': Any, 'calculated_total_rev': Any})] | None" has no attribute "id" [union-attr] +apps/job/views/modern_timesheet_views.py:0: error: Item "None" of "CostLine@AnnotatedWith[TypedDict({'calculated_total_cost': Any, 'calculated_total_rev': Any})] | None" has no attribute "meta" [union-attr] +apps/job/views/modern_timesheet_views.py:0: error: Item "None" of "CostLine@AnnotatedWith[TypedDict({'calculated_total_cost': Any, 'calculated_total_rev': Any})] | None" has no attribute "quantity" [union-attr] +apps/job/views/modern_timesheet_views.py:0: error: Item "None" of "CostLine@AnnotatedWith[TypedDict({'calculated_total_cost': Any, 'calculated_total_rev': Any})] | None" has no attribute "unit_cost" [union-attr] +apps/job/views/modern_timesheet_views.py:0: error: Item "None" of "CostLine@AnnotatedWith[TypedDict({'calculated_total_cost': Any, 'calculated_total_rev': Any})] | None" has no attribute "calculated_total_cost" [union-attr] +apps/job/views/modern_timesheet_views.py:0: error: Function is missing a type annotation [no-untyped-def] +apps/job/views/modern_timesheet_views.py:0: error: Item "None" of "CostSet | None" has no attribute "rev" [union-attr] +apps/job/views/modern_timesheet_views.py:0: error: Function is missing a type annotation [no-untyped-def] +apps/job/views/modern_timesheet_views.py:0: error: Function is missing a type annotation [no-untyped-def] +apps/job/views/delivery_docket_view.py:0: error: Function is missing a type annotation [no-untyped-def] +apps/job/views/archive_completed_jobs_view.py:0: error: Missing type arguments for generic type "ListAPIView" [type-arg] +apps/job/views/archive_completed_jobs_view.py:0: error: Function is missing a return type annotation [no-untyped-def] +apps/job/views/archive_completed_jobs_view.py:0: error: Call to untyped function "get_paid_complete_jobs" in typed context [no-untyped-call] +apps/job/views/archive_completed_jobs_view.py:0: error: Function is missing a type annotation [no-untyped-def] +apps/job/views/archive_completed_jobs_view.py:0: error: Call to untyped function "archive_complete_jobs" in typed context [no-untyped-call] +apps/workflow/tests/test_xero_document_raw_json.py:0: error: Function is missing a type annotation [no-untyped-def] +apps/workflow/tests/test_xero_document_raw_json.py:0: error: Function is missing a type annotation [no-untyped-def] +apps/workflow/tests/test_xero_document_raw_json.py:0: error: Function is missing a type annotation [no-untyped-def] +apps/workflow/tests/test_xero_document_raw_json.py:0: error: Function is missing a type annotation [no-untyped-def] +apps/workflow/tests/test_xero_document_raw_json.py:0: error: Function is missing a type annotation [no-untyped-def] +apps/workflow/tests/test_xero_document_raw_json.py:0: error: Function is missing a type annotation [no-untyped-def] +apps/workflow/tests/test_xero_document_raw_json.py:0: error: Function is missing a return type annotation [no-untyped-def] +apps/workflow/tests/test_xero_document_raw_json.py:0: note: Use "-> None" if function does not return a value +apps/workflow/tests/test_xero_document_raw_json.py:0: error: Function is missing a return type annotation [no-untyped-def] +apps/workflow/tests/test_xero_document_raw_json.py:0: note: Use "-> None" if function does not return a value +apps/workflow/tests/test_xero_document_raw_json.py:0: error: Call to untyped function "_FakeProvider" in typed context [no-untyped-call] +apps/workflow/tests/test_xero_document_raw_json.py:0: error: Incompatible types in assignment (expression has type "_FakeProvider", variable has type "AccountingProvider") [assignment] +apps/workflow/tests/test_xero_document_raw_json.py:0: error: Cannot assign to a method [method-assign] +apps/workflow/tests/test_xero_document_raw_json.py:0: error: Incompatible types in assignment (expression has type "def _attach_workshop_pdf(self, external_id: str) -> None", variable has type "def _attach_workshop_pdf(self, invoice_external_id: str) -> str | None") [assignment] +apps/workflow/tests/test_xero_document_raw_json.py:0: error: Function is missing a return type annotation [no-untyped-def] +apps/workflow/tests/test_xero_document_raw_json.py:0: note: Use "-> None" if function does not return a value +apps/workflow/tests/test_xero_document_raw_json.py:0: error: Call to untyped function "_FakeProvider" in typed context [no-untyped-call] +apps/workflow/tests/test_xero_document_raw_json.py:0: error: Call to untyped function "XeroQuoteManager" in typed context [no-untyped-call] +apps/workflow/tests/test_xero_document_raw_json.py:0: error: Incompatible types in assignment (expression has type "_FakeProvider", variable has type "AccountingProvider") [assignment] apps/job/tests/test_quote_modes.py:0: error: Function is missing a return type annotation [no-untyped-def] apps/job/tests/test_quote_modes.py:0: note: Use "-> None" if function does not return a value apps/job/tests/test_quote_modes.py:0: error: Function is missing a return type annotation [no-untyped-def] @@ -3664,7 +3264,6 @@ apps/job/tests/test_chat_service.py:0: note: PEP 484 prohibits implicit Optional apps/job/tests/test_chat_service.py:0: note: Use https://github.com/hauntsaninja/no_implicit_optional to automatically upgrade your codebase apps/job/tests/test_chat_service.py:0: error: Function is missing a return type annotation [no-untyped-def] apps/job/tests/test_chat_service.py:0: note: Use "-> None" if function does not return a value -apps/job/tests/test_chat_service.py:0: error: Incompatible types in assignment (expression has type "apps.client.models.Client", variable has type "django.test.client.Client") [assignment] apps/job/tests/test_chat_service.py:0: error: Function is missing a return type annotation [no-untyped-def] apps/job/tests/test_chat_service.py:0: note: Use "-> None" if function does not return a value apps/job/tests/test_chat_service.py:0: error: Function is missing a return type annotation [no-untyped-def] @@ -3675,7 +3274,6 @@ apps/job/tests/test_chat_service.py:0: error: Function is missing a return type apps/job/tests/test_chat_service.py:0: note: Use "-> None" if function does not return a value apps/job/tests/test_chat_service.py:0: error: Function is missing a return type annotation [no-untyped-def] apps/job/tests/test_chat_service.py:0: note: Use "-> None" if function does not return a value -apps/job/tests/test_chat_service.py:0: error: "Client" has no attribute "name" [attr-defined] apps/job/tests/test_chat_service.py:0: error: Function is missing a return type annotation [no-untyped-def] apps/job/tests/test_chat_service.py:0: note: Use "-> None" if function does not return a value apps/job/tests/test_chat_service.py:0: error: Function is missing a return type annotation [no-untyped-def] @@ -3688,7 +3286,6 @@ apps/job/tests/test_chat_service.py:0: error: Function is missing a return type apps/job/tests/test_chat_service.py:0: note: Use "-> None" if function does not return a value apps/job/tests/test_chat_service.py:0: error: Function is missing a return type annotation [no-untyped-def] apps/job/tests/test_chat_service.py:0: note: Use "-> None" if function does not return a value -apps/job/tests/test_chat_service.py:0: error: Incompatible types in assignment (expression has type "apps.client.models.Client", variable has type "django.test.client.Client") [assignment] apps/job/tests/test_chat_service.py:0: error: Function is missing a return type annotation [no-untyped-def] apps/job/tests/test_chat_service.py:0: note: Use "-> None" if function does not return a value apps/job/tests/test_chat_service.py:0: error: Function is missing a type annotation [no-untyped-def] @@ -3697,7 +3294,6 @@ apps/job/tests/test_chat_service.py:0: error: Function is missing a type annotat apps/job/tests/test_chat_service.py:0: error: Function is missing a type annotation [no-untyped-def] apps/job/tests/test_chat_service.py:0: error: Function is missing a return type annotation [no-untyped-def] apps/job/tests/test_chat_service.py:0: note: Use "-> None" if function does not return a value -apps/job/tests/test_chat_service.py:0: error: Incompatible types in assignment (expression has type "apps.client.models.Client", variable has type "django.test.client.Client") [assignment] apps/job/tests/test_chat_service.py:0: error: Function is missing a return type annotation [no-untyped-def] apps/job/tests/test_chat_service.py:0: note: Use "-> None" if function does not return a value apps/job/tests/test_chat_service.py:0: error: Function is missing a return type annotation [no-untyped-def] @@ -3710,17 +3306,14 @@ apps/job/tests/test_chat_service.py:0: error: Function is missing a return type apps/job/tests/test_chat_service.py:0: note: Use "-> None" if function does not return a value apps/job/tests/test_chat_service.py:0: error: Function is missing a return type annotation [no-untyped-def] apps/job/tests/test_chat_service.py:0: note: Use "-> None" if function does not return a value -apps/job/tests/test_chat_service.py:0: error: Incompatible types in assignment (expression has type "apps.client.models.Client", variable has type "django.test.client.Client") [assignment] apps/job/tests/test_chat_service.py:0: error: Function is missing a type annotation [no-untyped-def] apps/job/tests/test_chat_service.py:0: error: Function is missing a return type annotation [no-untyped-def] apps/job/tests/test_chat_service.py:0: note: Use "-> None" if function does not return a value apps/job/tests/test_chat_service.py:0: error: Function is missing a return type annotation [no-untyped-def] apps/job/tests/test_chat_service.py:0: note: Use "-> None" if function does not return a value -apps/job/tests/test_chat_service.py:0: error: Incompatible types in assignment (expression has type "apps.client.models.Client", variable has type "django.test.client.Client") [assignment] apps/job/tests/test_chat_service.py:0: error: Function is missing a type annotation [no-untyped-def] apps/job/tests/test_chat_performance.py:0: error: Function is missing a return type annotation [no-untyped-def] apps/job/tests/test_chat_performance.py:0: note: Use "-> None" if function does not return a value -apps/job/tests/test_chat_performance.py:0: error: Incompatible types in assignment (expression has type "apps.client.models.Client", variable has type "django.test.client.Client") [assignment] apps/job/tests/test_chat_performance.py:0: error: Function is missing a return type annotation [no-untyped-def] apps/job/tests/test_chat_performance.py:0: note: Use "-> None" if function does not return a value apps/job/tests/test_chat_performance.py:0: error: Function is missing a return type annotation [no-untyped-def] @@ -3750,16 +3343,16 @@ apps/workflow/api/xero/transforms.py:0: error: Function is missing a type annota apps/workflow/api/xero/transforms.py:0: error: Call to untyped function "clean_json" in typed context [no-untyped-call] apps/workflow/api/xero/transforms.py:0: error: Call to untyped function "serialize_xero_object" in typed context [no-untyped-call] apps/workflow/api/xero/transforms.py:0: error: Function is missing a type annotation [no-untyped-def] -apps/workflow/api/xero/transforms.py:0: error: Call to untyped function "sync_clients" in typed context [no-untyped-call] +apps/workflow/api/xero/transforms.py:0: error: Call to untyped function "sync_companies" in typed context [no-untyped-call] apps/workflow/api/xero/transforms.py:0: error: Function is missing a type annotation [no-untyped-def] -apps/workflow/api/xero/transforms.py:0: error: Call to untyped function "sync_clients" in typed context [no-untyped-call] +apps/workflow/api/xero/transforms.py:0: error: Call to untyped function "sync_companies" in typed context [no-untyped-call] apps/workflow/api/xero/transforms.py:0: error: Function is missing a type annotation [no-untyped-def] -apps/workflow/api/xero/transforms.py:0: error: Call to untyped function "sync_client_from_xero_contact" in typed context [no-untyped-call] -apps/workflow/api/xero/transforms.py:0: error: Call to untyped function "get_or_fetch_client" in typed context [no-untyped-call] +apps/workflow/api/xero/transforms.py:0: error: Call to untyped function "sync_company_from_xero_contact" in typed context [no-untyped-call] +apps/workflow/api/xero/transforms.py:0: error: Call to untyped function "get_or_fetch_company" in typed context [no-untyped-call] apps/workflow/api/xero/transforms.py:0: error: Function is missing a type annotation [no-untyped-def] apps/workflow/api/xero/transforms.py:0: error: Function is missing a type annotation for one or more parameters [no-untyped-def] apps/workflow/api/xero/transforms.py:0: error: Function is missing a type annotation [no-untyped-def] -apps/workflow/api/xero/transforms.py:0: error: Call to untyped function "resolve_client_from_xero_contact" in typed context [no-untyped-call] +apps/workflow/api/xero/transforms.py:0: error: Call to untyped function "resolve_company_from_xero_contact" in typed context [no-untyped-call] apps/workflow/api/xero/transforms.py:0: error: Call to untyped function "process_xero_data" in typed context [no-untyped-call] apps/workflow/api/xero/transforms.py:0: error: Function is missing a type annotation [no-untyped-def] apps/workflow/api/xero/transforms.py:0: error: Function is missing a type annotation [no-untyped-def] @@ -3777,10 +3370,10 @@ apps/workflow/api/xero/transforms.py:0: error: Function is missing a type annota apps/workflow/api/xero/transforms.py:0: error: Call to untyped function "process_xero_data" in typed context [no-untyped-call] apps/workflow/api/xero/transforms.py:0: error: Call to untyped function "save" in typed context [no-untyped-call] apps/workflow/api/xero/transforms.py:0: error: Function is missing a type annotation [no-untyped-def] -apps/workflow/api/xero/transforms.py:0: error: Call to untyped function "resolve_client_from_xero_contact" in typed context [no-untyped-call] +apps/workflow/api/xero/transforms.py:0: error: Call to untyped function "resolve_company_from_xero_contact" in typed context [no-untyped-call] apps/workflow/api/xero/transforms.py:0: error: Call to untyped function "process_xero_data" in typed context [no-untyped-call] apps/workflow/api/xero/transforms.py:0: error: Function is missing a type annotation [no-untyped-def] -apps/workflow/api/xero/transforms.py:0: error: Call to untyped function "resolve_client_from_xero_contact" in typed context [no-untyped-call] +apps/workflow/api/xero/transforms.py:0: error: Call to untyped function "resolve_company_from_xero_contact" in typed context [no-untyped-call] apps/workflow/api/xero/transforms.py:0: error: Call to untyped function "process_xero_data" in typed context [no-untyped-call] apps/workflow/api/xero/transforms.py:0: error: Incompatible type for "po_number" of "PurchaseOrder" (got "Any | None", expected "str | int | Combinable") [misc] apps/workflow/api/xero/transforms.py:0: error: Incompatible type for "order_date" of "PurchaseOrder" (got "Any | None", expected "str | date | Combinable") [misc] @@ -3816,7 +3409,7 @@ apps/workflow/api/xero/stock_sync.py:0: error: Function is missing a return type apps/workflow/api/xero/stock_sync.py:0: error: Call to untyped function "save" in typed context [no-untyped-call] apps/workflow/api/xero/push.py:0: error: Function is missing a type annotation [no-untyped-def] apps/workflow/api/xero/push.py:0: error: Function is missing a type annotation [no-untyped-def] -apps/workflow/api/xero/push.py:0: error: Call to untyped function "get_or_fetch_client" in typed context [no-untyped-call] +apps/workflow/api/xero/push.py:0: error: Call to untyped function "get_or_fetch_company" in typed context [no-untyped-call] apps/workflow/api/xero/push.py:0: error: Function is missing a type annotation for one or more parameters [no-untyped-def] apps/workflow/api/xero/push.py:0: error: Call to untyped function "sync_time_entries_bulk" in typed context [no-untyped-call] apps/workflow/api/xero/push.py:0: error: Call to untyped function "sync_expense_entries_bulk" in typed context [no-untyped-call] @@ -3876,7 +3469,7 @@ apps/workflow/api/xero/seed.py:0: error: Unsupported operand types for + ("objec apps/workflow/api/xero/seed.py:0: error: Function is missing a type annotation [no-untyped-def] apps/workflow/api/xero/seed.py:0: error: Call to untyped function "process_xero_data" in typed context [no-untyped-call] apps/workflow/api/xero/seed.py:0: error: Call to untyped function "save" in typed context [no-untyped-call] -apps/workflow/api/xero/seed.py:0: error: Call to untyped function "get_final_client" in typed context [no-untyped-call] +apps/workflow/api/xero/seed.py:0: error: Call to untyped function "get_final_company" in typed context [no-untyped-call] apps/workflow/api/xero/seed.py:0: error: Function is missing a type annotation [no-untyped-def] apps/workflow/api/xero/seed.py:0: error: Call to untyped function "process_xero_data" in typed context [no-untyped-call] apps/workflow/api/xero/seed.py:0: error: Call to untyped function "set_invoice_or_bill_fields" in typed context [no-untyped-call] @@ -3917,6 +3510,18 @@ apps/workflow/api/xero/sync.py:0: error: Call to untyped function "deep_sync_xer apps/workflow/api/xero/sync.py:0: error: Call to untyped function "save" in typed context [no-untyped-call] apps/workflow/api/xero/sync.py:0: error: Call to untyped function "one_way_sync_all_xero_data" in typed context [no-untyped-call] apps/workflow/api/xero/sync.py:0: error: Call to untyped function "save" in typed context [no-untyped-call] +apps/workflow/tests/test_xero_payroll_pay_run_flow.py:0: error: Function is missing a type annotation [no-untyped-def] +apps/workflow/tests/test_xero_payroll_pay_run_flow.py:0: error: Function is missing a type annotation [no-untyped-def] +apps/workflow/tests/test_xero_payroll_pay_run_flow.py:0: error: Call to untyped function "_draft" in typed context [no-untyped-call] +apps/workflow/tests/test_xero_payroll_pay_run_flow.py:0: error: Function is missing a type annotation [no-untyped-def] +apps/workflow/tests/test_xero_payroll_pay_run_flow.py:0: error: Function is missing a type annotation [no-untyped-def] +apps/workflow/tests/test_xero_payroll_pay_run_flow.py:0: error: Call to untyped function "_draft" in typed context [no-untyped-call] +apps/workflow/tests/test_xero_payroll_pay_run_flow.py:0: error: Function is missing a type annotation [no-untyped-def] +apps/workflow/tests/test_xero_payroll_pay_run_flow.py:0: error: Function is missing a type annotation [no-untyped-def] +apps/workflow/tests/test_xero_payroll_leave.py:0: error: Function is missing a type annotation [no-untyped-def] +apps/workflow/tests/test_xero_payroll_leave.py:0: error: Function is missing a type annotation [no-untyped-def] +apps/workflow/tests/test_xero_payroll_leave.py:0: error: Function is missing a type annotation [no-untyped-def] +apps/workflow/tests/test_xero_payroll_leave.py:0: error: Function is missing a type annotation [no-untyped-def] apps/purchasing/services/delivery_receipt_service.py:0: error: Function is missing a type annotation for one or more parameters [no-untyped-def] apps/purchasing/services/delivery_receipt_service.py:0: error: Missing type arguments for generic type "Dict" [type-arg] apps/purchasing/services/delivery_receipt_service.py:0: error: Missing type arguments for generic type "Dict" [type-arg] @@ -3935,15 +3540,218 @@ apps/purchasing/services/delivery_receipt_service.py:0: error: Call to untyped f apps/purchasing/services/delivery_receipt_service.py:0: error: Function is missing a type annotation for one or more parameters [no-untyped-def] apps/purchasing/services/delivery_receipt_service.py:0: error: Missing type arguments for generic type "dict" [type-arg] apps/purchasing/services/delivery_receipt_service.py:0: error: Call to untyped function "get_stock_holding_job" of "Stock" in typed context [no-untyped-call] -apps/process/urls_rest.py:0: error: Function is missing a type annotation [no-untyped-def] -apps/job/views/quote_sync_views.py:0: error: Function is missing a type annotation [no-untyped-def] -apps/job/views/quote_sync_views.py:0: error: Call to untyped function "draft_line_to_dict" in typed context [no-untyped-call] -apps/job/views/quote_sync_views.py:0: error: Call to untyped function "draft_line_to_dict" in typed context [no-untyped-call] -apps/job/views/quote_sync_views.py:0: error: Call to untyped function "draft_line_to_dict" in typed context [no-untyped-call] -apps/job/views/quote_sync_views.py:0: error: Dict entry 0 has incompatible type "str": "bool"; expected "str": "str" [dict-item] -apps/job/views/quote_sync_views.py:0: error: Incompatible types in assignment (expression has type "ApplyQuoteErrorResponseSerializer", variable has type "QuoteSyncErrorResponseSerializer") [assignment] -apps/job/views/quote_sync_views.py:0: error: Dict entry 0 has incompatible type "str": "bool"; expected "str": "str" [dict-item] -apps/job/views/quote_sync_views.py:0: error: Incompatible types in assignment (expression has type "ApplyQuoteErrorResponseSerializer", variable has type "QuoteSyncErrorResponseSerializer") [assignment] +apps/accounting/views/kpi_view.py:0: error: Module "drf_spectacular.utils" does not explicitly export attribute "OpenApiTypes" [attr-defined] +apps/accounting/tests/test_sales_pipeline_service.py:0: error: "SalesPipelineServiceFixturesMixin" has no attribute "test_staff" [attr-defined] +apps/accounting/tests/test_sales_pipeline_service.py:0: error: "SalesPipelineServiceFixturesMixin" has no attribute "test_staff" [attr-defined] +apps/accounting/tests/test_sales_pipeline_service.py:0: error: "SalesPipelineServiceFixturesMixin" has no attribute "test_staff" [attr-defined] +apps/accounting/tests/test_sales_pipeline_service.py:0: error: "SalesPipelineServiceFixturesMixin" has no attribute "test_staff" [attr-defined] +apps/accounting/tests/test_sales_pipeline_service.py:0: error: "SalesPipelineServiceFixturesMixin" has no attribute "test_staff" [attr-defined] +apps/accounting/tests/test_sales_pipeline_service.py:0: error: "SalesPipelineServiceFixturesMixin" has no attribute "test_staff" [attr-defined] +apps/accounting/tests/test_sales_pipeline_service.py:0: error: Call to untyped function "save" in typed context [no-untyped-call] +apps/accounting/tests/test_sales_pipeline_service.py:0: error: Function is missing a return type annotation [no-untyped-def] +apps/accounting/tests/test_sales_pipeline_service.py:0: note: Use "-> None" if function does not return a value +apps/accounting/tests/test_sales_pipeline_service.py:0: error: Function is missing a return type annotation [no-untyped-def] +apps/accounting/tests/test_sales_pipeline_service.py:0: note: Use "-> None" if function does not return a value +apps/accounting/tests/test_sales_pipeline_service.py:0: error: Function is missing a return type annotation [no-untyped-def] +apps/accounting/tests/test_sales_pipeline_service.py:0: note: Use "-> None" if function does not return a value +apps/accounting/tests/test_sales_pipeline_service.py:0: error: Function is missing a return type annotation [no-untyped-def] +apps/accounting/tests/test_sales_pipeline_service.py:0: note: Use "-> None" if function does not return a value +apps/accounting/tests/test_sales_pipeline_service.py:0: error: Function is missing a return type annotation [no-untyped-def] +apps/accounting/tests/test_sales_pipeline_service.py:0: note: Use "-> None" if function does not return a value +apps/accounting/tests/test_sales_pipeline_service.py:0: error: Call to untyped function "save" in typed context [no-untyped-call] +apps/accounting/tests/test_sales_pipeline_service.py:0: error: Function is missing a return type annotation [no-untyped-def] +apps/accounting/tests/test_sales_pipeline_service.py:0: note: Use "-> None" if function does not return a value +apps/accounting/tests/test_sales_pipeline_service.py:0: error: Function is missing a return type annotation [no-untyped-def] +apps/accounting/tests/test_sales_pipeline_service.py:0: note: Use "-> None" if function does not return a value +apps/accounting/tests/test_sales_pipeline_service.py:0: error: Function is missing a return type annotation [no-untyped-def] +apps/accounting/tests/test_sales_pipeline_service.py:0: note: Use "-> None" if function does not return a value +apps/accounting/tests/test_sales_pipeline_service.py:0: error: Function is missing a return type annotation [no-untyped-def] +apps/accounting/tests/test_sales_pipeline_service.py:0: note: Use "-> None" if function does not return a value +apps/accounting/tests/test_sales_pipeline_service.py:0: error: Function is missing a return type annotation [no-untyped-def] +apps/accounting/tests/test_sales_pipeline_service.py:0: note: Use "-> None" if function does not return a value +apps/accounting/tests/test_sales_pipeline_service.py:0: error: Function is missing a return type annotation [no-untyped-def] +apps/accounting/tests/test_sales_pipeline_service.py:0: note: Use "-> None" if function does not return a value +apps/accounting/tests/test_sales_pipeline_service.py:0: error: Function is missing a return type annotation [no-untyped-def] +apps/accounting/tests/test_sales_pipeline_service.py:0: note: Use "-> None" if function does not return a value +apps/accounting/tests/test_sales_pipeline_service.py:0: error: Function is missing a return type annotation [no-untyped-def] +apps/accounting/tests/test_sales_pipeline_service.py:0: note: Use "-> None" if function does not return a value +apps/accounting/tests/test_sales_pipeline_service.py:0: error: Function is missing a return type annotation [no-untyped-def] +apps/accounting/tests/test_sales_pipeline_service.py:0: note: Use "-> None" if function does not return a value +apps/accounting/tests/test_sales_pipeline_service.py:0: error: Function is missing a return type annotation [no-untyped-def] +apps/accounting/tests/test_sales_pipeline_service.py:0: note: Use "-> None" if function does not return a value +apps/accounting/tests/test_sales_pipeline_service.py:0: error: Function is missing a return type annotation [no-untyped-def] +apps/accounting/tests/test_sales_pipeline_service.py:0: note: Use "-> None" if function does not return a value +apps/accounting/tests/test_sales_pipeline_service.py:0: error: Function is missing a return type annotation [no-untyped-def] +apps/accounting/tests/test_sales_pipeline_service.py:0: note: Use "-> None" if function does not return a value +apps/accounting/tests/test_sales_pipeline_service.py:0: error: Call to untyped function "save" in typed context [no-untyped-call] +apps/accounting/tests/test_sales_pipeline_service.py:0: error: Function is missing a return type annotation [no-untyped-def] +apps/accounting/tests/test_sales_pipeline_service.py:0: note: Use "-> None" if function does not return a value +apps/accounting/tests/test_sales_pipeline_service.py:0: error: Function is missing a return type annotation [no-untyped-def] +apps/accounting/tests/test_sales_pipeline_service.py:0: note: Use "-> None" if function does not return a value +apps/accounting/tests/test_sales_pipeline_service.py:0: error: Function is missing a return type annotation [no-untyped-def] +apps/accounting/tests/test_sales_pipeline_service.py:0: note: Use "-> None" if function does not return a value +apps/workflow/tests/test_access_logging_middleware.py:0: error: Function is missing a return type annotation [no-untyped-def] +apps/workflow/tests/test_access_logging_middleware.py:0: error: Function is missing a return type annotation [no-untyped-def] +apps/purchasing/views/stock_search_rest_view.py:0: error: Module "drf_spectacular.utils" does not explicitly export attribute "OpenApiTypes" [attr-defined] +apps/purchasing/tests/test_stock_fts_search.py:0: error: Function is missing a type annotation [no-untyped-def] +apps/purchasing/tests/test_stock_fts_search.py:0: error: Function is missing a type annotation [no-untyped-def] +apps/purchasing/tests/test_stock_fts_search.py:0: error: Call to untyped function "_stock" in typed context [no-untyped-call] +apps/purchasing/tests/test_stock_fts_search.py:0: error: Function is missing a type annotation [no-untyped-def] +apps/purchasing/tests/test_stock_fts_search.py:0: error: Call to untyped function "_stock" in typed context [no-untyped-call] +apps/purchasing/tests/test_stock_fts_search.py:0: error: Function is missing a type annotation [no-untyped-def] +apps/purchasing/tests/test_stock_fts_search.py:0: error: Call to untyped function "_stock" in typed context [no-untyped-call] +apps/purchasing/tests/test_stock_fts_search.py:0: error: Function is missing a type annotation [no-untyped-def] +apps/purchasing/tests/test_stock_fts_search.py:0: error: Function is missing a type annotation [no-untyped-def] +apps/purchasing/tests/test_stock_fts_search.py:0: error: Function is missing a type annotation [no-untyped-def] +apps/purchasing/tests/test_stock_fts_search.py:0: error: Call to untyped function "_stock" in typed context [no-untyped-call] +apps/purchasing/tests/test_stock_fts_search.py:0: error: Call to untyped function "_stock" in typed context [no-untyped-call] +apps/purchasing/tests/test_stock_fts_search.py:0: error: Function is missing a type annotation [no-untyped-def] +apps/purchasing/tests/test_stock_fts_search.py:0: error: Call to untyped function "_stock" in typed context [no-untyped-call] +apps/purchasing/tests/test_stock_fts_search.py:0: error: Call to untyped function "_stock" in typed context [no-untyped-call] +apps/purchasing/tests/test_stock_fts_search.py:0: error: Function is missing a type annotation [no-untyped-def] +apps/purchasing/tests/test_stock_fts_search.py:0: error: Call to untyped function "_stock" in typed context [no-untyped-call] +apps/purchasing/tests/test_stock_fts_search.py:0: error: Call to untyped function "_stock" in typed context [no-untyped-call] +apps/purchasing/tests/test_stock_fts_search.py:0: error: Function is missing a type annotation [no-untyped-def] +apps/purchasing/tests/test_stock_fts_search.py:0: error: Call to untyped function "_stock" in typed context [no-untyped-call] +apps/purchasing/tests/test_stock_fts_search.py:0: error: Function is missing a type annotation [no-untyped-def] +apps/purchasing/tests/test_stock_fts_search.py:0: error: Call to untyped function "_stock" in typed context [no-untyped-call] +apps/purchasing/tests/test_stock_fts_search.py:0: error: Call to untyped function "_stock" in typed context [no-untyped-call] +apps/purchasing/tests/test_stock_fts_search.py:0: error: Function is missing a type annotation [no-untyped-def] +apps/purchasing/tests/test_stock_fts_search.py:0: error: Call to untyped function "_stock" in typed context [no-untyped-call] +apps/purchasing/tests/test_stock_fts_search.py:0: error: Function is missing a type annotation [no-untyped-def] +apps/purchasing/tests/test_stock_fts_search.py:0: error: Call to untyped function "_stock" in typed context [no-untyped-call] +apps/purchasing/tests/test_stock_fts_search.py:0: error: Call to untyped function "_stock" in typed context [no-untyped-call] +apps/purchasing/tests/test_stock_fts_search.py:0: error: Call to untyped function "_stock" in typed context [no-untyped-call] +apps/purchasing/tests/test_stock_fts_search.py:0: error: Function is missing a type annotation [no-untyped-def] +apps/purchasing/tests/test_stock_fts_search.py:0: error: Call to untyped function "_stock" in typed context [no-untyped-call] +apps/purchasing/tests/test_stock_fts_search.py:0: error: Call to untyped function "_stock" in typed context [no-untyped-call] +apps/purchasing/tests/test_stock_fts_search.py:0: error: Call to untyped function "_stock" in typed context [no-untyped-call] +apps/purchasing/tests/test_stock_fts_search.py:0: error: Function is missing a type annotation [no-untyped-def] +apps/purchasing/tests/test_stock_fts_search.py:0: error: Function is missing a type annotation [no-untyped-def] +apps/purchasing/tests/test_stock_fts_search.py:0: error: Function is missing a type annotation [no-untyped-def] +apps/purchasing/tests/test_stock_fts_search.py:0: error: Function is missing a type annotation [no-untyped-def] +apps/purchasing/tests/test_stock_fts_search.py:0: error: Function is missing a type annotation [no-untyped-def] +apps/purchasing/tests/test_stock_fts_search.py:0: error: Call to untyped function "_stock" in typed context [no-untyped-call] +apps/purchasing/tests/test_stock_fts_search.py:0: error: Call to untyped function "_stock" in typed context [no-untyped-call] +apps/purchasing/tests/test_stock_fts_search.py:0: error: Call to untyped function "_stock" in typed context [no-untyped-call] +apps/purchasing/tests/test_stock_fts_search.py:0: error: Function is missing a type annotation [no-untyped-def] +apps/purchasing/tests/test_stock_fts_search.py:0: error: Call to untyped function "_stock" in typed context [no-untyped-call] +apps/purchasing/tests/test_stock_fts_search.py:0: error: Function is missing a type annotation [no-untyped-def] +apps/purchasing/tests/test_stock_fts_search.py:0: error: Call to untyped function "_stock" in typed context [no-untyped-call] +apps/purchasing/tests/test_stock_fts_search.py:0: error: Call to untyped function "_stock" in typed context [no-untyped-call] +apps/purchasing/tests/test_stock_fts_search.py:0: error: Call to untyped function "_stock" in typed context [no-untyped-call] +apps/purchasing/tests/test_stock_fts_search.py:0: error: Function is missing a type annotation [no-untyped-def] +apps/purchasing/tests/test_stock_fts_search.py:0: error: Call to untyped function "_stock" in typed context [no-untyped-call] +apps/purchasing/tests/test_stock_fts_search.py:0: error: Call to untyped function "_stock" in typed context [no-untyped-call] +apps/purchasing/tests/test_stock_fts_search.py:0: error: Call to untyped function "_stock" in typed context [no-untyped-call] +apps/purchasing/tests/test_stock_fts_search.py:0: error: Function is missing a type annotation [no-untyped-def] +apps/purchasing/tests/test_stock_fts_search.py:0: error: Call to untyped function "_stock" in typed context [no-untyped-call] +apps/purchasing/tests/test_stock_fts_search.py:0: error: Call to untyped function "_stock" in typed context [no-untyped-call] +apps/purchasing/tests/test_stock_fts_search.py:0: error: Call to untyped function "_stock" in typed context [no-untyped-call] +apps/purchasing/tests/test_stock_fts_search.py:0: error: Call to untyped function "_stock" in typed context [no-untyped-call] +apps/purchasing/tests/test_stock_fts_search.py:0: error: Function is missing a type annotation [no-untyped-def] +apps/purchasing/tests/test_stock_fts_search.py:0: error: Call to untyped function "_stock" in typed context [no-untyped-call] +apps/purchasing/tests/test_stock_fts_search.py:0: error: Call to untyped function "_stock" in typed context [no-untyped-call] +apps/purchasing/tests/test_stock_fts_search.py:0: error: Call to untyped function "_stock" in typed context [no-untyped-call] +apps/purchasing/tests/test_stock_fts_search.py:0: error: Function is missing a type annotation [no-untyped-def] +apps/purchasing/tests/test_stock_fts_search.py:0: error: Call to untyped function "_stock" in typed context [no-untyped-call] +apps/purchasing/tests/test_stock_fts_search.py:0: error: Call to untyped function "_stock" in typed context [no-untyped-call] +apps/purchasing/tests/test_stock_fts_search.py:0: error: Call to untyped function "_stock" in typed context [no-untyped-call] +apps/purchasing/tests/test_stock_fts_search.py:0: error: Function is missing a type annotation [no-untyped-def] +apps/purchasing/tests/test_stock_fts_search.py:0: error: Call to untyped function "_stock" in typed context [no-untyped-call] +apps/purchasing/tests/test_stock_fts_search.py:0: error: Call to untyped function "_stock" in typed context [no-untyped-call] +apps/purchasing/tests/test_stock_fts_search.py:0: error: Function is missing a return type annotation [no-untyped-def] +apps/purchasing/tests/test_stock_fts_search.py:0: note: Use "-> None" if function does not return a value +apps/purchasing/tests/test_stock_fts_search.py:0: error: Function is missing a return type annotation [no-untyped-def] +apps/purchasing/tests/test_stock_fts_search.py:0: note: Use "-> None" if function does not return a value +apps/purchasing/tests/test_stock_fts_search.py:0: error: Call to untyped function "_stock" in typed context [no-untyped-call] +apps/purchasing/tests/test_stock_fts_search.py:0: error: Call to untyped function "_stock" in typed context [no-untyped-call] +apps/purchasing/tests/test_stock_fts_search.py:0: error: Function is missing a return type annotation [no-untyped-def] +apps/purchasing/tests/test_stock_fts_search.py:0: note: Use "-> None" if function does not return a value +apps/purchasing/tests/test_stock_fts_search.py:0: error: Call to untyped function "_stock" in typed context [no-untyped-call] +apps/purchasing/tests/test_stock_fts_search.py:0: error: Call to untyped function "_stock" in typed context [no-untyped-call] +apps/purchasing/tests/test_stock_fts_search.py:0: error: Function is missing a type annotation [no-untyped-def] +apps/purchasing/tests/test_stock_fts_search.py:0: error: Function is missing a type annotation [no-untyped-def] +apps/purchasing/tests/test_stock_fts_search.py:0: error: Function is missing a type annotation [no-untyped-def] +apps/purchasing/tests/test_stock_fts_search.py:0: error: Call to untyped function "_stock" in typed context [no-untyped-call] +apps/purchasing/tests/test_stock_fts_search.py:0: error: Call to untyped function "_stock" in typed context [no-untyped-call] +apps/purchasing/tests/test_stock_fts_search.py:0: error: Function is missing a type annotation [no-untyped-def] +apps/purchasing/tests/test_stock_fts_search.py:0: error: Call to untyped function "_stock" in typed context [no-untyped-call] +apps/purchasing/tests/test_stock_fts_search.py:0: error: Function is missing a type annotation [no-untyped-def] +apps/purchasing/tests/test_stock_fts_search.py:0: error: Function is missing a type annotation [no-untyped-def] +apps/purchasing/tests/test_stock_fts_search.py:0: error: Function is missing a type annotation [no-untyped-def] +apps/purchasing/tests/test_stock_fts_search.py:0: error: Call to untyped function "_stock" in typed context [no-untyped-call] +apps/job/tests/test_modern_timesheet_views.py:0: error: Function is missing a return type annotation [no-untyped-def] +apps/job/tests/test_modern_timesheet_views.py:0: note: Use "-> None" if function does not return a value +apps/job/tests/test_modern_timesheet_views.py:0: error: Function is missing a return type annotation [no-untyped-def] +apps/job/tests/test_modern_timesheet_views.py:0: note: Use "-> None" if function does not return a value +apps/job/tests/test_modern_timesheet_views.py:0: error: Item "None" of "XeroPayItem | None" has no attribute "name" [union-attr] +apps/job/views/kanban_view_api.py:0: error: Function is missing a return type annotation [no-untyped-def] +apps/job/views/kanban_view_api.py:0: error: Function is missing a type annotation [no-untyped-def] +apps/job/views/kanban_view_api.py:0: error: Call to untyped function "get_serializer_class" in typed context [no-untyped-call] +apps/job/views/kanban_view_api.py:0: error: Function is missing a return type annotation [no-untyped-def] +apps/job/views/kanban_view_api.py:0: error: Function is missing a type annotation [no-untyped-def] +apps/job/views/kanban_view_api.py:0: error: Call to untyped function "get_serializer_class" in typed context [no-untyped-call] +apps/job/views/kanban_view_api.py:0: error: "str" has no attribute "HTTP_500_INTERNAL_SERVER_ERROR" [attr-defined] +apps/job/views/kanban_view_api.py:0: error: Incompatible types in assignment (expression has type "list[str]", target has type "str") [assignment] +apps/job/views/job_quote_chat_views.py:0: error: Function is missing a return type annotation [no-untyped-def] +apps/job/views/job_quote_chat_views.py:0: error: Function is missing a type annotation [no-untyped-def] +apps/job/views/job_quote_chat_views.py:0: error: Function is missing a type annotation [no-untyped-def] +apps/job/views/job_quote_chat_views.py:0: error: Function is missing a type annotation [no-untyped-def] +apps/job/views/job_quote_chat_views.py:0: error: Function is missing a return type annotation [no-untyped-def] +apps/job/views/job_quote_chat_views.py:0: error: Function is missing a type annotation [no-untyped-def] +apps/job/views/job_quote_chat_views.py:0: error: Call to untyped function "get_job_or_404" in typed context [no-untyped-call] +apps/job/views/job_quote_chat_views.py:0: error: Call to untyped function "get_job_or_404_response" in typed context [no-untyped-call] +apps/job/views/job_quote_chat_views.py:0: error: Function is missing a type annotation [no-untyped-def] +apps/job/views/job_quote_chat_views.py:0: error: Call to untyped function "get_job_or_404" in typed context [no-untyped-call] +apps/job/views/job_quote_chat_views.py:0: error: Call to untyped function "get_job_or_404_response" in typed context [no-untyped-call] +apps/job/views/job_quote_chat_views.py:0: error: "JobQuoteChatSerializer" has no attribute "get" [attr-defined] +apps/job/views/job_quote_chat_views.py:0: error: Function is missing a type annotation [no-untyped-def] +apps/job/views/job_quote_chat_views.py:0: error: Call to untyped function "get_job_or_404" in typed context [no-untyped-call] +apps/job/views/job_quote_chat_views.py:0: error: Call to untyped function "get_job_or_404_response" in typed context [no-untyped-call] +apps/job/views/job_quote_chat_views.py:0: error: Function is missing a type annotation [no-untyped-def] +apps/job/views/job_quote_chat_views.py:0: error: Call to untyped function "get_job_or_404" in typed context [no-untyped-call] +apps/job/views/job_quote_chat_views.py:0: error: Call to untyped function "get_message_or_404" in typed context [no-untyped-call] +apps/job/views/job_quote_chat_views.py:0: error: Call to untyped function "get_job_or_404_response" in typed context [no-untyped-call] +apps/job/views/job_quote_chat_views.py:0: error: Function is missing a type annotation [no-untyped-def] +apps/job/views/job_quote_chat_views.py:0: error: Call to untyped function "get_job_or_404" in typed context [no-untyped-call] +apps/job/views/job_quote_chat_views.py:0: error: Call to untyped function "get_message_or_404" in typed context [no-untyped-call] +apps/job/views/job_quote_chat_views.py:0: error: Call to untyped function "get_job_or_404_response" in typed context [no-untyped-call] +apps/job/views/job_costing_views.py:0: error: Function is missing a type annotation [no-untyped-def] +apps/job/views/job_costing_views.py:0: error: Function is missing a return type annotation [no-untyped-def] +apps/job/views/job_costing_views.py:0: error: Function is missing a type annotation [no-untyped-def] +apps/job/views/job_costing_views.py:0: error: Function is missing a type annotation [no-untyped-def] +apps/job/views/job_costing_views.py:0: error: Call to untyped function "_archive_quote_data" in typed context [no-untyped-call] +apps/job/views/job_costing_views.py:0: error: Function is missing a type annotation [no-untyped-def] +apps/job/views/assign_job_view.py:0: error: Function is missing a type annotation [no-untyped-def] +apps/job/views/assign_job_view.py:0: error: Call to untyped function "assign_staff_to_job" of "JobStaffService" in typed context [no-untyped-call] +apps/job/views/assign_job_view.py:0: error: Function is missing a type annotation [no-untyped-def] +apps/job/views/assign_job_view.py:0: error: Call to untyped function "remove_staff_from_job" of "JobStaffService" in typed context [no-untyped-call] +apps/job/services/job_rest_service.py:0: error: Function is missing a type annotation for one or more parameters [no-untyped-def] +apps/job/services/job_rest_service.py:0: error: Function is missing a type annotation for one or more parameters [no-untyped-def] +apps/job/services/job_rest_service.py:0: error: Missing return statement [return] +apps/job/services/job_rest_service.py:0: error: Incompatible return value type (got "ReturnDict[Any, Any]", expected "list[dict[str, Any]]") [return-value] +apps/job/services/job_rest_service.py:0: error: Incompatible return value type (got "ReturnDict[Any, Any]", expected "list[dict[str, Any]]") [return-value] +apps/job/services/job_rest_service.py:0: error: Value of type "JobDeltaRejection@AnnotatedWith[TypedDict({'occurrence_count': int, 'first_seen': Any, 'last_seen': Any, 'latest_id': Any})]" is not indexable [index] +apps/job/services/job_rest_service.py:0: error: Value of type "JobDeltaRejection@AnnotatedWith[TypedDict({'occurrence_count': int, 'first_seen': Any, 'last_seen': Any, 'latest_id': Any})]" is not indexable [index] +apps/job/services/job_rest_service.py:0: error: Value of type "JobDeltaRejection@AnnotatedWith[TypedDict({'occurrence_count': int, 'first_seen': Any, 'last_seen': Any, 'latest_id': Any})]" is not indexable [index] +apps/job/services/job_rest_service.py:0: error: Value of type "JobDeltaRejection@AnnotatedWith[TypedDict({'occurrence_count': int, 'first_seen': Any, 'last_seen': Any, 'latest_id': Any})]" is not indexable [index] +apps/job/services/job_rest_service.py:0: error: Value of type "JobDeltaRejection@AnnotatedWith[TypedDict({'occurrence_count': int, 'first_seen': Any, 'last_seen': Any, 'latest_id': Any})]" is not indexable [index] +apps/job/services/job_rest_service.py:0: error: Value of type "JobDeltaRejection@AnnotatedWith[TypedDict({'occurrence_count': int, 'first_seen': Any, 'last_seen': Any, 'latest_id': Any})]" is not indexable [index] +apps/job/services/job_rest_service.py:0: error: "ValueError" has no attribute "delta_rejection_context" [attr-defined] +apps/job/services/job_rest_service.py:0: error: Assignment to variable "exc" outside except: block [misc] +apps/job/services/job_rest_service.py:0: error: Trying to read deleted variable "exc" [misc] +apps/job/services/job_rest_service.py:0: error: Trying to read deleted variable "exc" [misc] +apps/job/services/job_rest_service.py:0: error: Incompatible type for lookup 'company_id': (got "UUID | Any | None", expected "UUID | str") [misc] +apps/job/services/job_rest_service.py:0: error: Missing return statement [return] +apps/job/services/job_rest_service.py:0: error: Call to untyped function "create_safe" of "JobEvent" in typed context [no-untyped-call] +apps/job/services/job_rest_service.py:0: error: Argument "key" to "sort" of "list" has incompatible type "Callable[[dict[str, int | datetime | UUID | str | Any | None]], int | datetime | UUID | str | Any | None]"; expected "Callable[[dict[str, int | datetime | UUID | str | Any | None]], SupportsDunderLT[Any] | SupportsDunderGT[Any]]" [arg-type] +apps/job/services/job_rest_service.py:0: error: Incompatible return value type (got "int | datetime | UUID | str | Any | None", expected "SupportsDunderLT[Any] | SupportsDunderGT[Any]") [return-value] +apps/job/services/job_rest_service.py:0: error: Incompatible default for parameter "week" (default has type "None", parameter has type "date") [assignment] +apps/job/services/job_rest_service.py:0: note: PEP 484 prohibits implicit Optional. Accordingly, mypy has changed its default to no_implicit_optional=True +apps/job/services/job_rest_service.py:0: note: Use https://github.com/hauntsaninja/no_implicit_optional to automatically upgrade your codebase +apps/job/services/job_rest_service.py:0: error: "Job" has no attribute "pricings" [attr-defined] +apps/job/views/job_quote_chat_api.py:0: error: Function is missing a type annotation [no-untyped-def] apps/workflow/views/xero_apps_view.py:0: error: Missing type arguments for generic type "Serializer" [type-arg] apps/workflow/views/xero_apps_view.py:0: error: Missing type arguments for generic type "ModelViewSet" [type-arg] apps/workflow/views/xero_apps_view.py:0: error: Function is missing a return type annotation [no-untyped-def] @@ -3956,16 +3764,12 @@ apps/workflow/tests/test_xero_transform_contact_resolution.py:0: error: Function apps/workflow/tests/test_xero_transform_contact_resolution.py:0: error: Function is missing a type annotation for one or more parameters [no-untyped-def] apps/workflow/tests/test_xero_transform_contact_resolution.py:0: error: Function is missing a return type annotation [no-untyped-def] apps/workflow/tests/test_xero_transform_contact_resolution.py:0: note: Use "-> None" if function does not return a value -apps/workflow/tests/test_xero_transform_contact_resolution.py:0: error: Incompatible types in assignment (expression has type "apps.client.models.Client", variable has type "django.test.client.Client") [assignment] apps/workflow/tests/test_xero_transform_contact_resolution.py:0: error: Function is missing a type annotation [no-untyped-def] -apps/workflow/tests/test_xero_transform_contact_resolution.py:0: error: Call to untyped function "resolve_client_from_xero_contact" in typed context [no-untyped-call] -apps/workflow/tests/test_xero_transform_contact_resolution.py:0: error: "Client" has no attribute "id" [attr-defined] +apps/workflow/tests/test_xero_transform_contact_resolution.py:0: error: Call to untyped function "resolve_company_from_xero_contact" in typed context [no-untyped-call] apps/workflow/tests/test_xero_transform_contact_resolution.py:0: error: Function is missing a type annotation [no-untyped-def] -apps/workflow/tests/test_xero_transform_contact_resolution.py:0: error: Call to untyped function "resolve_client_from_xero_contact" in typed context [no-untyped-call] -apps/workflow/tests/test_xero_transform_contact_resolution.py:0: error: "Client" has no attribute "id" [attr-defined] +apps/workflow/tests/test_xero_transform_contact_resolution.py:0: error: Call to untyped function "resolve_company_from_xero_contact" in typed context [no-untyped-call] apps/workflow/tests/test_xero_transform_contact_resolution.py:0: error: Function is missing a type annotation [no-untyped-def] apps/workflow/tests/test_xero_transform_contact_resolution.py:0: error: Call to untyped function "_extract_required_fields_xero" in typed context [no-untyped-call] -apps/workflow/tests/test_xero_transform_contact_resolution.py:0: error: "Client" has no attribute "id" [attr-defined] apps/workflow/tests/test_xero_transform_contact_resolution.py:0: error: Function is missing a type annotation [no-untyped-def] apps/workflow/tests/test_xero_transform_contact_resolution.py:0: error: Call to untyped function "transform_quote" in typed context [no-untyped-call] apps/workflow/tests/test_xero_transform_contact_resolution.py:0: error: Function is missing a type annotation [no-untyped-def] @@ -3977,18 +3781,6 @@ apps/workflow/tests/test_xero_tenant_id.py:0: error: Function is missing a retur apps/workflow/tests/test_xero_tenant_id.py:0: note: Use "-> None" if function does not return a value apps/workflow/tests/test_xero_tenant_id.py:0: error: Function is missing a return type annotation [no-untyped-def] apps/workflow/tests/test_xero_tenant_id.py:0: note: Use "-> None" if function does not return a value -apps/workflow/tests/test_xero_payroll_pay_run_flow.py:0: error: Function is missing a type annotation [no-untyped-def] -apps/workflow/tests/test_xero_payroll_pay_run_flow.py:0: error: Function is missing a type annotation [no-untyped-def] -apps/workflow/tests/test_xero_payroll_pay_run_flow.py:0: error: Call to untyped function "_draft" in typed context [no-untyped-call] -apps/workflow/tests/test_xero_payroll_pay_run_flow.py:0: error: Function is missing a type annotation [no-untyped-def] -apps/workflow/tests/test_xero_payroll_pay_run_flow.py:0: error: Function is missing a type annotation [no-untyped-def] -apps/workflow/tests/test_xero_payroll_pay_run_flow.py:0: error: Call to untyped function "_draft" in typed context [no-untyped-call] -apps/workflow/tests/test_xero_payroll_pay_run_flow.py:0: error: Function is missing a type annotation [no-untyped-def] -apps/workflow/tests/test_xero_payroll_pay_run_flow.py:0: error: Function is missing a type annotation [no-untyped-def] -apps/workflow/tests/test_xero_payroll_leave.py:0: error: Function is missing a type annotation [no-untyped-def] -apps/workflow/tests/test_xero_payroll_leave.py:0: error: Function is missing a type annotation [no-untyped-def] -apps/workflow/tests/test_xero_payroll_leave.py:0: error: Function is missing a type annotation [no-untyped-def] -apps/workflow/tests/test_xero_payroll_leave.py:0: error: Function is missing a type annotation [no-untyped-def] apps/workflow/tests/test_sync_clients.py:0: error: Function is missing a type annotation [no-untyped-def] apps/workflow/tests/test_sync_clients.py:0: error: Function is missing a type annotation [no-untyped-def] apps/workflow/tests/test_sync_clients.py:0: error: Function is missing a return type annotation [no-untyped-def] @@ -3997,29 +3789,29 @@ apps/workflow/tests/test_sync_clients.py:0: error: Function is missing a type an apps/workflow/tests/test_sync_clients.py:0: error: Call to untyped function "_make_raw_json" in typed context [no-untyped-call] apps/workflow/tests/test_sync_clients.py:0: error: Function is missing a type annotation [no-untyped-def] apps/workflow/tests/test_sync_clients.py:0: error: Call to untyped function "_make_xero_contact" in typed context [no-untyped-call] -apps/workflow/tests/test_sync_clients.py:0: error: Module "apps.workflow.api.xero.sync" does not explicitly export attribute "sync_clients" [attr-defined] -apps/workflow/tests/test_sync_clients.py:0: error: Call to untyped function "sync_clients" in typed context [no-untyped-call] +apps/workflow/tests/test_sync_clients.py:0: error: Module "apps.workflow.api.xero.sync" does not explicitly export attribute "sync_companies" [attr-defined] +apps/workflow/tests/test_sync_clients.py:0: error: Call to untyped function "sync_companies" in typed context [no-untyped-call] apps/workflow/tests/test_sync_clients.py:0: error: Function is missing a type annotation [no-untyped-def] apps/workflow/tests/test_sync_clients.py:0: error: Call to untyped function "_make_xero_contact" in typed context [no-untyped-call] -apps/workflow/tests/test_sync_clients.py:0: error: Module "apps.workflow.api.xero.sync" does not explicitly export attribute "sync_clients" [attr-defined] -apps/workflow/tests/test_sync_clients.py:0: error: Call to untyped function "sync_clients" in typed context [no-untyped-call] +apps/workflow/tests/test_sync_clients.py:0: error: Module "apps.workflow.api.xero.sync" does not explicitly export attribute "sync_companies" [attr-defined] +apps/workflow/tests/test_sync_clients.py:0: error: Call to untyped function "sync_companies" in typed context [no-untyped-call] apps/workflow/tests/test_sync_clients.py:0: error: Function is missing a type annotation [no-untyped-def] apps/workflow/tests/test_sync_clients.py:0: error: Call to untyped function "_make_xero_contact" in typed context [no-untyped-call] -apps/workflow/tests/test_sync_clients.py:0: error: Module "apps.workflow.api.xero.sync" does not explicitly export attribute "sync_clients" [attr-defined] -apps/workflow/tests/test_sync_clients.py:0: error: Call to untyped function "sync_clients" in typed context [no-untyped-call] +apps/workflow/tests/test_sync_clients.py:0: error: Module "apps.workflow.api.xero.sync" does not explicitly export attribute "sync_companies" [attr-defined] +apps/workflow/tests/test_sync_clients.py:0: error: Call to untyped function "sync_companies" in typed context [no-untyped-call] apps/workflow/tests/test_push_contacts.py:0: error: Function is missing a type annotation [no-untyped-def] apps/workflow/tests/test_push_contacts.py:0: error: Function is missing a type annotation [no-untyped-def] apps/workflow/tests/test_push_contacts.py:0: error: Function is missing a type annotation [no-untyped-def] apps/workflow/tests/test_push_contacts.py:0: error: Call to untyped function "_make_client" in typed context [no-untyped-call] apps/workflow/tests/test_push_contacts.py:0: error: Call to untyped function "_create_response" in typed context [no-untyped-call] -apps/workflow/tests/test_push_contacts.py:0: error: Call to untyped function "sync_client_to_xero" in typed context [no-untyped-call] +apps/workflow/tests/test_push_contacts.py:0: error: Call to untyped function "sync_company_to_xero" in typed context [no-untyped-call] apps/workflow/tests/test_push_contacts.py:0: error: Function is missing a type annotation [no-untyped-def] apps/workflow/tests/test_push_contacts.py:0: error: Call to untyped function "_make_client" in typed context [no-untyped-call] apps/workflow/tests/test_push_contacts.py:0: error: Call to untyped function "_create_response" in typed context [no-untyped-call] -apps/workflow/tests/test_push_contacts.py:0: error: Call to untyped function "sync_client_to_xero" in typed context [no-untyped-call] +apps/workflow/tests/test_push_contacts.py:0: error: Call to untyped function "sync_company_to_xero" in typed context [no-untyped-call] apps/workflow/tests/test_push_contacts.py:0: error: Function is missing a type annotation [no-untyped-def] apps/workflow/tests/test_push_contacts.py:0: error: Call to untyped function "_make_client" in typed context [no-untyped-call] -apps/workflow/tests/test_push_contacts.py:0: error: Call to untyped function "create_client_contact_in_xero" in typed context [no-untyped-call] +apps/workflow/tests/test_push_contacts.py:0: error: Call to untyped function "create_company_contact_in_xero" in typed context [no-untyped-call] apps/workflow/tests/test_push_contacts.py:0: error: Function is missing a type annotation [no-untyped-def] apps/workflow/tests/test_push_contacts.py:0: error: Call to untyped function "_make_client" in typed context [no-untyped-call] apps/workflow/tests/test_push_contacts.py:0: error: Call to untyped function "bulk_create_contacts_in_xero" in typed context [no-untyped-call] @@ -4059,8 +3851,8 @@ apps/workflow/accounting/xero/provider.py:0: error: Too many arguments for "get_ apps/workflow/accounting/xero/provider.py:0: error: Missing type arguments for generic type "dict" [type-arg] apps/workflow/accounting/xero/provider.py:0: error: Missing type arguments for generic type "dict" [type-arg] apps/workflow/accounting/xero/provider.py:0: error: Missing type arguments for generic type "dict" [type-arg] -apps/workflow/accounting/xero/provider.py:0: error: Call to untyped function "create_client_contact_in_xero" in typed context [no-untyped-call] -apps/workflow/accounting/xero/provider.py:0: error: Call to untyped function "sync_client_to_xero" in typed context [no-untyped-call] +apps/workflow/accounting/xero/provider.py:0: error: Call to untyped function "create_company_contact_in_xero" in typed context [no-untyped-call] +apps/workflow/accounting/xero/provider.py:0: error: Call to untyped function "sync_company_to_xero" in typed context [no-untyped-call] apps/workflow/accounting/xero/provider.py:0: error: Call to untyped function "_get_api" of "XeroAccountingProvider" in typed context [no-untyped-call] apps/workflow/accounting/xero/provider.py:0: error: Call to untyped function "_get_api" of "XeroAccountingProvider" in typed context [no-untyped-call] apps/workflow/accounting/xero/provider.py:0: error: Call to untyped function "_build_line_items" of "XeroAccountingProvider" in typed context [no-untyped-call] @@ -4124,6 +3916,13 @@ apps/timesheet/views/api.py:0: error: Function is missing a type annotation [no apps/timesheet/views/api.py:0: error: Function is missing a type annotation [no-untyped-def] apps/timesheet/views/api.py:0: error: Function is missing a return type annotation [no-untyped-def] apps/timesheet/views/api.py:0: error: Call to untyped function "generate_payroll_events" in typed context [no-untyped-call] +apps/timesheet/services/payroll_employee_sync.py:0: error: Incompatible return value type (got "str | None", expected "str") [return-value] +apps/timesheet/services/payroll_employee_sync.py:0: error: Function is missing a type annotation [no-untyped-def] +apps/timesheet/services/payroll_employee_sync.py:0: error: Call to untyped function "_hours_between" in typed context [no-untyped-call] +apps/timesheet/services/payroll_employee_sync.py:0: error: Call to untyped function "_hours_between" in typed context [no-untyped-call] +apps/timesheet/services/payroll_employee_sync.py:0: error: Call to untyped function "_hours_between" in typed context [no-untyped-call] +apps/timesheet/services/payroll_employee_sync.py:0: error: Call to untyped function "_hours_between" in typed context [no-untyped-call] +apps/timesheet/services/payroll_employee_sync.py:0: error: Call to untyped function "_hours_between" in typed context [no-untyped-call] apps/purchasing/services/purchasing_rest_service.py:0: error: Function is missing a type annotation for one or more parameters [no-untyped-def] apps/purchasing/services/purchasing_rest_service.py:0: error: Call to untyped function (unknown) in typed context [no-untyped-call] apps/purchasing/services/purchasing_rest_service.py:0: error: Argument 2 to "_create_line" of "PurchasingRestService" has incompatible type "Any | None"; expected "str" [arg-type] @@ -4139,14 +3938,74 @@ apps/purchasing/services/purchasing_rest_service.py:0: error: Call to untyped fu apps/purchasing/services/purchasing_rest_service.py:0: error: Incompatible return value type (got "QuerySet[Stock, Stock]", expected "list[dict[str, Any]]") [return-value] apps/purchasing/services/purchasing_rest_service.py:0: error: Missing type arguments for generic type "dict" [type-arg] apps/purchasing/services/purchasing_rest_service.py:0: error: Call to untyped function "get_stock_holding_job" of "Stock" in typed context [no-untyped-call] -apps/quoting/services/providers/mistral_provider.py:0: error: Function is missing a type annotation [no-untyped-def] -apps/quoting/services/providers/mistral_provider.py:0: error: Argument 1 to "_parse_price_from_string" of "MistralPriceExtractionProvider" has incompatible type "str | None"; expected "str" [arg-type] -apps/quoting/services/providers/mistral_provider.py:0: error: Function is missing a type annotation for one or more parameters [no-untyped-def] -apps/quoting/services/providers/mistral_provider.py:0: error: Call to untyped function "encode_pdf" in typed context [no-untyped-call] -apps/quoting/services/providers/mistral_provider.py:0: error: Function is missing a type annotation [no-untyped-def] -apps/quoting/services/providers/mistral_provider.py:0: error: Call to untyped function "ocr_response_to_dict" in typed context [no-untyped-call] -apps/quoting/services/providers/mistral_provider.py:0: error: Call to untyped function "ocr_response_to_dict" in typed context [no-untyped-call] -apps/quoting/services/providers/mistral_provider.py:0: error: Call to untyped function "ocr_response_to_dict" in typed context [no-untyped-call] +apps/job/views/job_rest_views.py:0: error: Module "drf_spectacular.utils" does not explicitly export attribute "OpenApiTypes" [attr-defined] +apps/job/views/job_rest_views.py:0: error: Function is missing a return type annotation [no-untyped-def] +apps/job/views/job_rest_views.py:0: error: Function is missing a type annotation [no-untyped-def] +apps/job/views/job_rest_views.py:0: error: Function is missing a type annotation for one or more parameters [no-untyped-def] +apps/job/views/job_rest_views.py:0: error: Returning Any from function declared to return "dict[str, Any]" [no-any-return] +apps/job/views/job_rest_views.py:0: error: Function is missing a type annotation for one or more parameters [no-untyped-def] +apps/job/views/job_rest_views.py:0: error: Function is missing a type annotation for one or more parameters [no-untyped-def] +apps/job/views/job_rest_views.py:0: error: Function is missing a type annotation for one or more parameters [no-untyped-def] +apps/job/views/job_rest_views.py:0: error: Function is missing a return type annotation [no-untyped-def] +apps/job/views/job_rest_views.py:0: error: Function is missing a type annotation [no-untyped-def] +apps/job/views/job_rest_views.py:0: error: Call to untyped function "get_serializer_class" in typed context [no-untyped-call] +apps/job/views/job_rest_views.py:0: error: Function is missing a type annotation [no-untyped-def] +apps/job/views/job_rest_views.py:0: error: Function is missing a return type annotation [no-untyped-def] +apps/job/views/job_rest_views.py:0: error: Function is missing a type annotation [no-untyped-def] +apps/job/views/job_rest_views.py:0: error: Call to untyped function "get_serializer_class" in typed context [no-untyped-call] +apps/job/views/job_rest_views.py:0: error: Function is missing a type annotation [no-untyped-def] +apps/job/views/job_rest_views.py:0: error: Function is missing a type annotation [no-untyped-def] +apps/job/views/job_rest_views.py:0: error: Function is missing a type annotation [no-untyped-def] +apps/job/views/job_rest_views.py:0: error: Function is missing a type annotation [no-untyped-def] +apps/job/views/job_rest_views.py:0: error: Function is missing a type annotation [no-untyped-def] +apps/job/views/job_rest_views.py:0: error: Function is missing a return type annotation [no-untyped-def] +apps/job/views/job_rest_views.py:0: error: Function is missing a type annotation [no-untyped-def] +apps/job/views/job_rest_views.py:0: error: Function is missing a type annotation [no-untyped-def] +apps/job/views/job_rest_views.py:0: error: Function is missing a type annotation [no-untyped-def] +apps/job/views/job_rest_views.py:0: error: Argument 1 to "get_weekly_metrics" of "JobRestService" has incompatible type "date | None"; expected "date" [arg-type] +apps/job/views/job_rest_views.py:0: error: Function is missing a type annotation [no-untyped-def] +apps/job/views/job_rest_views.py:0: error: Function is missing a type annotation [no-untyped-def] +apps/job/views/job_rest_views.py:0: error: Function is missing a type annotation [no-untyped-def] +apps/job/views/job_rest_views.py:0: error: Function is missing a type annotation [no-untyped-def] +apps/job/views/job_rest_views.py:0: error: Function is missing a type annotation [no-untyped-def] +apps/job/views/job_rest_views.py:0: error: Call to untyped function "calculate_profit_margin" in typed context [no-untyped-call] +apps/job/views/job_rest_views.py:0: error: Call to untyped function "calculate_profit_margin" in typed context [no-untyped-call] +apps/job/views/job_rest_views.py:0: error: Call to untyped function "calculate_profit_margin" in typed context [no-untyped-call] +apps/job/views/job_rest_views.py:0: error: Function is missing a type annotation [no-untyped-def] +apps/job/views/job_rest_views.py:0: error: "type[Job]" has no attribute "STATUS_CHOICES" [attr-defined] +apps/job/views/job_rest_views.py:0: error: Function is missing a type annotation [no-untyped-def] +apps/job/views/job_rest_views.py:0: error: Function is missing a return type annotation [no-untyped-def] +apps/job/views/job_rest_views.py:0: error: Function is missing a type annotation for one or more parameters [no-untyped-def] +apps/job/views/job_rest_views.py:0: error: Function is missing a type annotation [no-untyped-def] +apps/job/views/job_rest_views.py:0: error: Function is missing a type annotation [no-untyped-def] +apps/job/views/job_rest_views.py:0: error: Function is missing a type annotation [no-untyped-def] +apps/job/views/job_rest_views.py:0: error: Function is missing a type annotation [no-untyped-def] +apps/job/views/job_rest_views.py:0: error: Function is missing a type annotation [no-untyped-def] +apps/job/tests/test_job_rest_service.py:0: error: Function is missing a return type annotation [no-untyped-def] +apps/job/tests/test_job_rest_service.py:0: note: Use "-> None" if function does not return a value +apps/job/tests/test_job_rest_service.py:0: error: Function is missing a return type annotation [no-untyped-def] +apps/job/tests/test_job_rest_service.py:0: note: Use "-> None" if function does not return a value +apps/job/tests/test_job_delta_rejection_grouping.py:0: error: Function is missing a type annotation [no-untyped-def] +apps/job/tests/test_job_delta_rejection_grouping.py:0: error: Function is missing a type annotation [no-untyped-def] +apps/job/tests/test_job_delta_rejection_grouping.py:0: error: Function is missing a type annotation [no-untyped-def] +apps/job/tests/test_job_delta_rejection_grouping.py:0: error: Function is missing a type annotation [no-untyped-def] +apps/job/tests/test_job_delta_rejection_grouping.py:0: error: Function is missing a type annotation [no-untyped-def] +apps/job/tests/test_event_deduplication.py:0: error: Function is missing a return type annotation [no-untyped-def] +apps/job/tests/test_event_deduplication.py:0: note: Use "-> None" if function does not return a value +apps/job/tests/test_event_deduplication.py:0: error: Function is missing a return type annotation [no-untyped-def] +apps/job/tests/test_event_deduplication.py:0: note: Use "-> None" if function does not return a value +apps/job/tests/test_event_deduplication.py:0: error: Function is missing a return type annotation [no-untyped-def] +apps/job/tests/test_event_deduplication.py:0: note: Use "-> None" if function does not return a value +apps/job/tests/test_event_deduplication.py:0: error: Call to untyped function "create_safe" of "JobEvent" in typed context [no-untyped-call] +apps/job/tests/test_event_deduplication.py:0: error: Call to untyped function "create_safe" of "JobEvent" in typed context [no-untyped-call] +apps/job/tests/test_event_deduplication.py:0: error: Function is missing a return type annotation [no-untyped-def] +apps/job/tests/test_event_deduplication.py:0: note: Use "-> None" if function does not return a value +apps/job/tests/test_event_deduplication.py:0: error: Function is missing a return type annotation [no-untyped-def] +apps/job/tests/test_event_deduplication.py:0: note: Use "-> None" if function does not return a value +apps/job/tests/test_event_deduplication.py:0: error: Function is missing a return type annotation [no-untyped-def] +apps/job/tests/test_event_deduplication.py:0: note: Use "-> None" if function does not return a value +apps/job/tests/test_event_deduplication.py:0: error: Function is missing a return type annotation [no-untyped-def] +apps/job/tests/test_event_deduplication.py:0: note: Use "-> None" if function does not return a value apps/workflow/tasks.py:0: error: Module "apps.workflow.api.xero.sync" does not explicitly export attribute "sync_single_contact" [attr-defined] apps/workflow/tasks.py:0: error: Module "apps.workflow.api.xero.sync" does not explicitly export attribute "sync_single_invoice" [attr-defined] apps/workflow/tasks.py:0: error: Call to untyped function "sync_single_contact" in typed context [no-untyped-call] @@ -4167,7 +4026,7 @@ apps/workflow/views/xero/xero_view.py:0: error: Call to untyped function "get_me apps/workflow/views/xero/xero_view.py:0: error: Incompatible return value type (got "JsonResponse", expected "StreamingHttpResponse") [return-value] apps/workflow/views/xero/xero_view.py:0: error: Call to untyped function "generate_xero_sync_events" in typed context [no-untyped-call] apps/workflow/views/xero/xero_view.py:0: error: Missing return statement [return] -apps/workflow/views/xero/xero_view.py:0: error: Argument "client" to "XeroInvoiceManager" has incompatible type "Client | None"; expected "Client" [arg-type] +apps/workflow/views/xero/xero_view.py:0: error: Argument "company" to "XeroInvoiceManager" has incompatible type "Company | None"; expected "Company" [arg-type] apps/workflow/views/xero/xero_view.py:0: error: Argument "staff" to "XeroInvoiceManager" has incompatible type "Staff | AnonymousUser"; expected "Staff" [arg-type] apps/workflow/views/xero/xero_view.py:0: error: Incompatible types in assignment (expression has type "XeroDocumentErrorResponseSerializer", variable has type "XeroDocumentSuccessResponseSerializer") [assignment] apps/workflow/views/xero/xero_view.py:0: error: Argument 2 to "error" has incompatible type "object"; expected "str | _StrPromise" [arg-type] @@ -4179,7 +4038,7 @@ apps/workflow/views/xero/xero_view.py:0: error: Missing return statement [retur apps/workflow/views/xero/xero_view.py:0: error: Call to untyped function "XeroQuoteManager" in typed context [no-untyped-call] apps/workflow/views/xero/xero_view.py:0: error: Incompatible types in assignment (expression has type "XeroDocumentErrorResponseSerializer", variable has type "XeroDocumentSuccessResponseSerializer") [assignment] apps/workflow/views/xero/xero_view.py:0: error: Missing return statement [return] -apps/workflow/views/xero/xero_view.py:0: error: Argument "client" to "XeroInvoiceManager" has incompatible type "Client | None"; expected "Client" [arg-type] +apps/workflow/views/xero/xero_view.py:0: error: Argument "company" to "XeroInvoiceManager" has incompatible type "Company | None"; expected "Company" [arg-type] apps/workflow/views/xero/xero_view.py:0: error: Argument "staff" to "XeroInvoiceManager" has incompatible type "Staff | AnonymousUser"; expected "Staff" [arg-type] apps/workflow/views/xero/xero_view.py:0: error: Argument "xero_invoice_id" to "XeroInvoiceManager" has incompatible type "UUID"; expected "str | None" [arg-type] apps/workflow/views/xero/xero_view.py:0: error: Missing type arguments for generic type "dict" [type-arg] @@ -4207,13 +4066,116 @@ apps/workflow/views/xero/xero_view.py:0: error: Function is missing a type annot apps/workflow/views/xero/xero_view.py:0: error: Function is missing a type annotation [no-untyped-def] apps/workflow/views/xero/xero_view.py:0: error: Missing type arguments for generic type "ListAPIView" [type-arg] apps/workflow/views/xero/xero_view.py:0: error: Missing type arguments for generic type "RetrieveAPIView" [type-arg] -apps/timesheet/services/payroll_employee_sync.py:0: error: Incompatible return value type (got "str | None", expected "str") [return-value] -apps/timesheet/services/payroll_employee_sync.py:0: error: Function is missing a type annotation [no-untyped-def] -apps/timesheet/services/payroll_employee_sync.py:0: error: Call to untyped function "_hours_between" in typed context [no-untyped-call] -apps/timesheet/services/payroll_employee_sync.py:0: error: Call to untyped function "_hours_between" in typed context [no-untyped-call] -apps/timesheet/services/payroll_employee_sync.py:0: error: Call to untyped function "_hours_between" in typed context [no-untyped-call] -apps/timesheet/services/payroll_employee_sync.py:0: error: Call to untyped function "_hours_between" in typed context [no-untyped-call] -apps/timesheet/services/payroll_employee_sync.py:0: error: Call to untyped function "_hours_between" in typed context [no-untyped-call] +apps/workflow/management/commands/export_dev_demo_dump.py:0: error: Function is missing a type annotation [no-untyped-def] +apps/workflow/management/commands/export_dev_demo_dump.py:0: error: Function is missing a type annotation [no-untyped-def] +apps/workflow/management/commands/export_dev_demo_dump.py:0: error: Incompatible types in assignment (expression has type "str | None", target has type "str") [assignment] +apps/workflow/management/commands/export_dev_demo_dump.py:0: error: Call to untyped function "_reset_scrub_schema" in typed context [no-untyped-call] +apps/workflow/management/commands/export_dev_demo_dump.py:0: error: Call to untyped function "_run_pipe" in typed context [no-untyped-call] +apps/workflow/management/commands/export_dev_demo_dump.py:0: error: Call to untyped function "_run" in typed context [no-untyped-call] +apps/workflow/management/commands/export_dev_demo_dump.py:0: error: Call to untyped function "_reset_scrub_schema" in typed context [no-untyped-call] +apps/workflow/management/commands/export_dev_demo_dump.py:0: error: Function is missing a type annotation [no-untyped-def] +apps/workflow/management/commands/export_dev_demo_dump.py:0: error: Call to untyped function "_run" in typed context [no-untyped-call] +apps/workflow/management/commands/export_dev_demo_dump.py:0: error: Function is missing a type annotation [no-untyped-def] +apps/workflow/management/commands/export_dev_demo_dump.py:0: error: Function is missing a type annotation [no-untyped-def] +apps/workflow/management/commands/seed_xero_from_database.py:0: error: Function is missing a type annotation [no-untyped-def] +apps/workflow/management/commands/seed_xero_from_database.py:0: error: Function is missing a type annotation [no-untyped-def] +apps/workflow/management/commands/seed_xero_from_database.py:0: error: Call to untyped function "clear_production_xero_ids" in typed context [no-untyped-call] +apps/workflow/management/commands/seed_xero_from_database.py:0: error: Call to untyped function "process_accounts" in typed context [no-untyped-call] +apps/workflow/management/commands/seed_xero_from_database.py:0: error: Call to untyped function "process_contacts" in typed context [no-untyped-call] +apps/workflow/management/commands/seed_xero_from_database.py:0: error: Call to untyped function "process_projects" in typed context [no-untyped-call] +apps/workflow/management/commands/seed_xero_from_database.py:0: error: Call to untyped function "process_employees" in typed context [no-untyped-call] +apps/workflow/management/commands/seed_xero_from_database.py:0: error: Call to untyped function "process_invoices" in typed context [no-untyped-call] +apps/workflow/management/commands/seed_xero_from_database.py:0: error: Call to untyped function "process_quotes" in typed context [no-untyped-call] +apps/workflow/management/commands/seed_xero_from_database.py:0: error: Call to untyped function "process_stock_items" in typed context [no-untyped-call] +apps/workflow/management/commands/seed_xero_from_database.py:0: error: Call to untyped function "save" in typed context [no-untyped-call] +apps/workflow/management/commands/seed_xero_from_database.py:0: error: Function is missing a type annotation [no-untyped-def] +apps/workflow/management/commands/seed_xero_from_database.py:0: error: Call to untyped function "process_xero_data" in typed context [no-untyped-call] +apps/workflow/management/commands/seed_xero_from_database.py:0: error: Function is missing a type annotation [no-untyped-def] +apps/workflow/management/commands/seed_xero_from_database.py:0: error: Call to untyped function "seed_companies_to_xero" in typed context [no-untyped-call] +apps/workflow/management/commands/seed_xero_from_database.py:0: error: Function is missing a type annotation [no-untyped-def] +apps/workflow/management/commands/seed_xero_from_database.py:0: error: Call to untyped function "seed_jobs_to_xero" in typed context [no-untyped-call] +apps/workflow/management/commands/seed_xero_from_database.py:0: error: Function is missing a type annotation [no-untyped-def] +apps/workflow/management/commands/seed_xero_from_database.py:0: error: Call to untyped function "fetch_xero_entity_lookup" in typed context [no-untyped-call] +apps/workflow/management/commands/seed_xero_from_database.py:0: error: Call to untyped function "_batch_create_invoices" in typed context [no-untyped-call] +apps/workflow/management/commands/seed_xero_from_database.py:0: error: Function is missing a type annotation [no-untyped-def] +apps/workflow/management/commands/seed_xero_from_database.py:0: error: Call to untyped function "_get_invoice_line_unit_amount" of "Command" in typed context [no-untyped-call] +apps/workflow/management/commands/seed_xero_from_database.py:0: error: Call to untyped function "convert_to_pascal_case" in typed context [no-untyped-call] +apps/workflow/management/commands/seed_xero_from_database.py:0: error: Call to untyped function "clean_payload" in typed context [no-untyped-call] +apps/workflow/management/commands/seed_xero_from_database.py:0: error: Function is missing a type annotation [no-untyped-def] +apps/workflow/management/commands/seed_xero_from_database.py:0: error: Call to untyped function "_build_invoice_payload" in typed context [no-untyped-call] +apps/workflow/management/commands/seed_xero_from_database.py:0: error: Function is missing a type annotation [no-untyped-def] +apps/workflow/management/commands/seed_xero_from_database.py:0: error: Function is missing a type annotation [no-untyped-def] +apps/workflow/management/commands/seed_xero_from_database.py:0: error: Call to untyped function "fetch_xero_entity_lookup" in typed context [no-untyped-call] +apps/workflow/management/commands/seed_xero_from_database.py:0: error: Call to untyped function "_batch_create_quotes" in typed context [no-untyped-call] +apps/workflow/management/commands/seed_xero_from_database.py:0: error: Function is missing a type annotation [no-untyped-def] +apps/workflow/management/commands/seed_xero_from_database.py:0: error: Call to untyped function "convert_to_pascal_case" in typed context [no-untyped-call] +apps/workflow/management/commands/seed_xero_from_database.py:0: error: Call to untyped function "clean_payload" in typed context [no-untyped-call] +apps/workflow/management/commands/seed_xero_from_database.py:0: error: Function is missing a type annotation [no-untyped-def] +apps/workflow/management/commands/seed_xero_from_database.py:0: error: Call to untyped function "_build_quote_payload" in typed context [no-untyped-call] +apps/workflow/management/commands/seed_xero_from_database.py:0: error: Function is missing a type annotation [no-untyped-def] +apps/workflow/management/commands/seed_xero_from_database.py:0: error: Function is missing a type annotation [no-untyped-def] +apps/workflow/management/commands/seed_xero_from_database.py:0: error: Function is missing a type annotation [no-untyped-def] +apps/workflow/management/commands/seed_xero_from_database.py:0: error: Item "None" of "str | None" has no attribute "endswith" [union-attr] +apps/workflow/management/commands/seed_xero_from_database.py:0: error: Call to untyped function "_table_exists" in typed context [no-untyped-call] +apps/workflow/management/commands/seed_xero_from_database.py:0: error: Call to untyped function "_table_exists" in typed context [no-untyped-call] +apps/workflow/management/commands/seed_xero_from_database.py:0: error: Call to untyped function "_column_exists" in typed context [no-untyped-call] +apps/workflow/management/commands/seed_xero_from_database.py:0: error: Call to untyped function "_table_exists" in typed context [no-untyped-call] +apps/workflow/management/commands/seed_xero_from_database.py:0: error: Call to untyped function "_column_exists" in typed context [no-untyped-call] +apps/workflow/management/commands/seed_xero_from_database.py:0: error: Call to untyped function "_table_exists" in typed context [no-untyped-call] +apps/workflow/management/commands/seed_xero_from_database.py:0: error: Call to untyped function "_column_exists" in typed context [no-untyped-call] +apps/workflow/management/commands/seed_xero_from_database.py:0: error: Call to untyped function "_table_exists" in typed context [no-untyped-call] +apps/workflow/management/commands/seed_xero_from_database.py:0: error: Function is missing a type annotation [no-untyped-def] +apps/workflow/management/commands/seed_xero_from_database.py:0: error: Function is missing a type annotation [no-untyped-def] +apps/workflow/management/commands/xero.py:0: error: Function is missing a return type annotation [no-untyped-def] +apps/workflow/management/commands/xero.py:0: error: Function is missing a type annotation [no-untyped-def] +apps/workflow/management/commands/xero.py:0: error: Function is missing a type annotation [no-untyped-def] +apps/workflow/management/commands/xero.py:0: error: Call to untyped function "run_setup" in typed context [no-untyped-call] +apps/workflow/management/commands/xero.py:0: error: Call to untyped function "get_users" in typed context [no-untyped-call] +apps/workflow/management/commands/xero.py:0: error: Call to untyped function "get_payroll_employees" in typed context [no-untyped-call] +apps/workflow/management/commands/xero.py:0: error: Call to untyped function "get_payroll_rates" in typed context [no-untyped-call] +apps/workflow/management/commands/xero.py:0: error: Call to untyped function "get_payroll_calendars" in typed context [no-untyped-call] +apps/workflow/management/commands/xero.py:0: error: Call to untyped function "get_payroll_leave_types" in typed context [no-untyped-call] +apps/workflow/management/commands/xero.py:0: error: Call to untyped function "get_payroll_pay_runs" in typed context [no-untyped-call] +apps/workflow/management/commands/xero.py:0: error: Call to untyped function "configure_payroll" in typed context [no-untyped-call] +apps/workflow/management/commands/xero.py:0: error: Call to untyped function "import_staff" in typed context [no-untyped-call] +apps/workflow/management/commands/xero.py:0: error: Call to untyped function "link_staff" in typed context [no-untyped-call] +apps/workflow/management/commands/xero.py:0: error: Call to untyped function "create_staff" in typed context [no-untyped-call] +apps/workflow/management/commands/xero.py:0: error: Call to untyped function "get_tenants" in typed context [no-untyped-call] +apps/workflow/management/commands/xero.py:0: error: Call to untyped function "get_tenants" in typed context [no-untyped-call] +apps/workflow/management/commands/xero.py:0: error: Function is missing a type annotation [no-untyped-def] +apps/workflow/management/commands/xero.py:0: error: Call to untyped function "save" in typed context [no-untyped-call] +apps/workflow/management/commands/xero.py:0: error: Function is missing a return type annotation [no-untyped-def] +apps/workflow/management/commands/xero.py:0: note: Use "-> None" if function does not return a value +apps/workflow/management/commands/xero.py:0: error: Call to untyped function "save" in typed context [no-untyped-call] +apps/workflow/management/commands/xero.py:0: error: Call to untyped function "save" in typed context [no-untyped-call] +apps/workflow/management/commands/xero.py:0: error: Function is missing a return type annotation [no-untyped-def] +apps/workflow/management/commands/xero.py:0: note: Use "-> None" if function does not return a value +apps/workflow/management/commands/xero.py:0: error: Function is missing a type annotation [no-untyped-def] +apps/workflow/management/commands/xero.py:0: error: Call to untyped function "get_employees_simple_dev" in typed context [no-untyped-call] +apps/workflow/management/commands/xero.py:0: error: Function is missing a return type annotation [no-untyped-def] +apps/workflow/management/commands/xero.py:0: note: Use "-> None" if function does not return a value +apps/workflow/management/commands/xero.py:0: error: Function is missing a return type annotation [no-untyped-def] +apps/workflow/management/commands/xero.py:0: note: Use "-> None" if function does not return a value +apps/workflow/management/commands/xero.py:0: error: Function is missing a return type annotation [no-untyped-def] +apps/workflow/management/commands/xero.py:0: note: Use "-> None" if function does not return a value +apps/workflow/management/commands/xero.py:0: error: Function is missing a return type annotation [no-untyped-def] +apps/workflow/management/commands/xero.py:0: note: Use "-> None" if function does not return a value +apps/workflow/management/commands/xero.py:0: error: Function is missing a return type annotation [no-untyped-def] +apps/workflow/management/commands/xero.py:0: note: Use "-> None" if function does not return a value +apps/workflow/management/commands/xero.py:0: error: Function is missing a type annotation [no-untyped-def] +apps/workflow/management/commands/xero.py:0: error: Function is missing a type annotation [no-untyped-def] +apps/workflow/management/commands/xero.py:0: error: Call to untyped function "_print_link_staff_summary" in typed context [no-untyped-call] +apps/workflow/management/commands/xero.py:0: error: Function is missing a type annotation [no-untyped-def] +apps/workflow/management/commands/xero.py:0: error: Call to untyped function "_print_create_staff_summary" in typed context [no-untyped-call] +apps/workflow/management/commands/xero.py:0: error: Function is missing a type annotation [no-untyped-def] +apps/workflow/management/commands/xero.py:0: error: Function is missing a type annotation [no-untyped-def] +apps/workflow/management/commands/xero.py:0: error: Function is missing a type annotation [no-untyped-def] +apps/workflow/management/commands/xero.py:0: error: Function is missing a type annotation [no-untyped-def] +apps/workflow/management/commands/xero.py:0: error: Function is missing a type annotation [no-untyped-def] +apps/timesheet/api/daily_timesheet_views.py:0: error: Function is missing a return type annotation [no-untyped-def] +apps/timesheet/api/daily_timesheet_views.py:0: error: Function is missing a type annotation for one or more parameters [no-untyped-def] +apps/timesheet/api/daily_timesheet_views.py:0: error: Function is missing a return type annotation [no-untyped-def] +apps/timesheet/api/daily_timesheet_views.py:0: error: Function is missing a type annotation for one or more parameters [no-untyped-def] apps/workflow/tests/test_localdate_regression.py:0: error: Function is missing a type annotation [no-untyped-def] apps/workflow/tests/test_localdate_regression.py:0: error: Function is missing a type annotation [no-untyped-def] apps/workflow/tests/test_localdate_regression.py:0: error: Function is missing a return type annotation [no-untyped-def] @@ -4321,21 +4283,156 @@ apps/purchasing/views/purchasing_rest_views.py:0: error: Function is missing a t apps/purchasing/views/purchasing_rest_views.py:0: error: Function is missing a return type annotation [no-untyped-def] apps/purchasing/views/purchasing_rest_views.py:0: error: Function is missing a type annotation [no-untyped-def] apps/purchasing/views/purchasing_rest_views.py:0: error: Function is missing a type annotation [no-untyped-def] -apps/quoting/tests/test_ocr_fixtures.py:0: error: Function is missing a return type annotation [no-untyped-def] -apps/quoting/tests/test_ocr_fixtures.py:0: error: Function is missing a type annotation [no-untyped-def] -apps/quoting/tests/test_ocr_fixtures.py:0: error: Call to untyped function "_ocr_response" in typed context [no-untyped-call] -apps/quoting/services/ai_price_extraction.py:0: error: Incompatible default for parameter "model_name" (default has type "None", parameter has type "str") [assignment] -apps/quoting/services/ai_price_extraction.py:0: note: PEP 484 prohibits implicit Optional. Accordingly, mypy has changed its default to no_implicit_optional=True -apps/quoting/services/ai_price_extraction.py:0: note: Use https://github.com/hauntsaninja/no_implicit_optional to automatically upgrade your codebase -apps/quoting/services/ai_price_extraction.py:0: error: Incompatible return value type (got "MistralPriceExtractionProvider", expected "PriceExtractionProvider") [return-value] -apps/quoting/services/ai_price_extraction.py:0: error: Incompatible return value type (got "GeminiPriceExtractionProvider", expected "PriceExtractionProvider") [return-value] -apps/quoting/services/ai_price_extraction.py:0: error: Function is missing a return type annotation [no-untyped-def] -apps/quoting/services/ai_price_extraction.py:0: error: No overload variant of "get" of "dict" matches argument types "str", "int" [call-overload] -apps/quoting/services/ai_price_extraction.py:0: note: Possible overload variants: -apps/quoting/services/ai_price_extraction.py:0: note: def get(self, AIProviderTypes, None = ..., /) -> int | None -apps/quoting/services/ai_price_extraction.py:0: note: def get(self, AIProviderTypes, int, /) -> int -apps/quoting/services/ai_price_extraction.py:0: note: def [_T] get(self, AIProviderTypes, _T, /) -> int | _T -apps/quoting/services/ai_price_extraction.py:0: error: Call to untyped function "get_prioritized_active_providers" in typed context [no-untyped-call] +apps/job/tests/test_job_event_tracking.py:0: error: Function is missing a return type annotation [no-untyped-def] +apps/job/tests/test_job_event_tracking.py:0: note: Use "-> None" if function does not return a value +apps/job/tests/test_job_event_tracking.py:0: error: Function is missing a return type annotation [no-untyped-def] +apps/job/tests/test_job_event_tracking.py:0: note: Use "-> None" if function does not return a value +apps/job/tests/test_job_event_tracking.py:0: error: Item "None" of "JobEvent | None" has no attribute "staff" [union-attr] +apps/job/tests/test_job_event_tracking.py:0: error: Item "None" of "JobEvent | None" has no attribute "delta_before" [union-attr] +apps/job/tests/test_job_event_tracking.py:0: error: Value of type "Any | None" is not indexable [index] +apps/job/tests/test_job_event_tracking.py:0: error: Item "None" of "JobEvent | None" has no attribute "delta_after" [union-attr] +apps/job/tests/test_job_event_tracking.py:0: error: Value of type "Any | None" is not indexable [index] +apps/job/tests/test_job_event_tracking.py:0: error: Function is missing a return type annotation [no-untyped-def] +apps/job/tests/test_job_event_tracking.py:0: note: Use "-> None" if function does not return a value +apps/job/tests/test_job_event_tracking.py:0: error: Function is missing a return type annotation [no-untyped-def] +apps/job/tests/test_job_event_tracking.py:0: note: Use "-> None" if function does not return a value +apps/job/tests/test_job_event_tracking.py:0: error: Function is missing a return type annotation [no-untyped-def] +apps/job/tests/test_job_event_tracking.py:0: note: Use "-> None" if function does not return a value +apps/job/tests/test_job_event_tracking.py:0: error: Item "None" of "JobEvent | None" has no attribute "delta_before" [union-attr] +apps/job/tests/test_job_event_tracking.py:0: error: Argument 2 to "assertIn" of "TestCase" has incompatible type "Any | None"; expected "Iterable[Any] | Container[Any]" [arg-type] +apps/job/tests/test_job_event_tracking.py:0: error: Item "None" of "JobEvent | None" has no attribute "delta_after" [union-attr] +apps/job/tests/test_job_event_tracking.py:0: error: Value of type "Any | None" is not indexable [index] +apps/job/tests/test_job_event_tracking.py:0: error: Function is missing a return type annotation [no-untyped-def] +apps/job/tests/test_job_event_tracking.py:0: note: Use "-> None" if function does not return a value +apps/job/tests/test_job_event_tracking.py:0: error: Function is missing a return type annotation [no-untyped-def] +apps/job/tests/test_job_event_tracking.py:0: note: Use "-> None" if function does not return a value +apps/job/tests/test_job_event_tracking.py:0: error: Item "None" of "JobEvent | None" has no attribute "delta_before" [union-attr] +apps/job/tests/test_job_event_tracking.py:0: error: Argument 2 to "assertIn" of "TestCase" has incompatible type "Any | None"; expected "Iterable[Any] | Container[Any]" [arg-type] +apps/job/tests/test_job_event_tracking.py:0: error: Item "None" of "JobEvent | None" has no attribute "delta_before" [union-attr] +apps/job/tests/test_job_event_tracking.py:0: error: Argument 2 to "assertIn" of "TestCase" has incompatible type "Any | None"; expected "Iterable[Any] | Container[Any]" [arg-type] +apps/job/tests/test_job_event_tracking.py:0: error: Function is missing a return type annotation [no-untyped-def] +apps/job/tests/test_job_event_tracking.py:0: note: Use "-> None" if function does not return a value +apps/job/tests/test_job_event_tracking.py:0: error: Function is missing a return type annotation [no-untyped-def] +apps/job/tests/test_job_event_tracking.py:0: note: Use "-> None" if function does not return a value +apps/job/tests/test_job_event_tracking.py:0: error: Item "None" of "JobEvent | None" has no attribute "schema_version" [union-attr] +apps/job/tests/test_job_event_tracking.py:0: error: Item "None" of "JobEvent | None" has no attribute "change_id" [union-attr] +apps/job/tests/test_job_event_tracking.py:0: error: Item "None" of "JobEvent | None" has no attribute "delta_meta" [union-attr] +apps/job/tests/test_job_event_tracking.py:0: error: Item "None" of "JobEvent | None" has no attribute "delta_checksum" [union-attr] +apps/job/tests/test_job_event_tracking.py:0: error: Function is missing a return type annotation [no-untyped-def] +apps/job/tests/test_job_event_tracking.py:0: note: Use "-> None" if function does not return a value +apps/job/tests/test_job_event_tracking.py:0: error: Item "None" of "JobEvent | None" has no attribute "event_type" [union-attr] +apps/job/tests/test_job_event_tracking.py:0: error: Function is missing a return type annotation [no-untyped-def] +apps/job/tests/test_job_event_tracking.py:0: note: Use "-> None" if function does not return a value +apps/job/tests/test_job_event_tracking.py:0: error: Function is missing a return type annotation [no-untyped-def] +apps/job/tests/test_job_event_tracking.py:0: note: Use "-> None" if function does not return a value +apps/job/tests/test_job_event_tracking.py:0: error: Function is missing a return type annotation [no-untyped-def] +apps/job/tests/test_job_event_tracking.py:0: note: Use "-> None" if function does not return a value +apps/job/tests/test_job_event_tracking.py:0: error: Function is missing a return type annotation [no-untyped-def] +apps/job/tests/test_job_event_tracking.py:0: note: Use "-> None" if function does not return a value +apps/job/tests/test_job_event_tracking.py:0: error: Function is missing a return type annotation [no-untyped-def] +apps/job/tests/test_job_event_tracking.py:0: note: Use "-> None" if function does not return a value +apps/job/tests/test_job_event_tracking.py:0: error: Function is missing a return type annotation [no-untyped-def] +apps/job/tests/test_job_event_tracking.py:0: note: Use "-> None" if function does not return a value +apps/job/tests/test_job_event_tracking.py:0: error: Function is missing a return type annotation [no-untyped-def] +apps/job/tests/test_job_event_tracking.py:0: note: Use "-> None" if function does not return a value +apps/job/tests/test_job_event_tracking.py:0: error: Function is missing a return type annotation [no-untyped-def] +apps/job/tests/test_job_event_tracking.py:0: note: Use "-> None" if function does not return a value +apps/job/tests/test_job_event_tracking.py:0: error: Function is missing a type annotation [no-untyped-def] +apps/job/tests/test_job_event_tracking.py:0: error: Function is missing a return type annotation [no-untyped-def] +apps/job/tests/test_job_event_tracking.py:0: note: Use "-> None" if function does not return a value +apps/job/tests/test_job_event_tracking.py:0: error: Call to untyped function "_event" in typed context [no-untyped-call] +apps/job/tests/test_job_event_tracking.py:0: error: Function is missing a return type annotation [no-untyped-def] +apps/job/tests/test_job_event_tracking.py:0: note: Use "-> None" if function does not return a value +apps/job/tests/test_job_event_tracking.py:0: error: Call to untyped function "_event" in typed context [no-untyped-call] +apps/job/tests/test_job_event_tracking.py:0: error: Function is missing a return type annotation [no-untyped-def] +apps/job/tests/test_job_event_tracking.py:0: note: Use "-> None" if function does not return a value +apps/job/tests/test_job_event_tracking.py:0: error: Call to untyped function "_event" in typed context [no-untyped-call] +apps/job/tests/test_job_event_tracking.py:0: error: Function is missing a return type annotation [no-untyped-def] +apps/job/tests/test_job_event_tracking.py:0: note: Use "-> None" if function does not return a value +apps/job/tests/test_job_event_tracking.py:0: error: Call to untyped function "_event" in typed context [no-untyped-call] +apps/job/tests/test_job_event_tracking.py:0: error: Function is missing a return type annotation [no-untyped-def] +apps/job/tests/test_job_event_tracking.py:0: note: Use "-> None" if function does not return a value +apps/job/tests/test_job_event_tracking.py:0: error: Call to untyped function "_event" in typed context [no-untyped-call] +apps/job/tests/test_job_event_tracking.py:0: error: Function is missing a return type annotation [no-untyped-def] +apps/job/tests/test_job_event_tracking.py:0: note: Use "-> None" if function does not return a value +apps/job/tests/test_job_event_tracking.py:0: error: Call to untyped function "_event" in typed context [no-untyped-call] +apps/job/tests/test_job_event_tracking.py:0: error: Function is missing a return type annotation [no-untyped-def] +apps/job/tests/test_job_event_tracking.py:0: note: Use "-> None" if function does not return a value +apps/job/tests/test_job_event_tracking.py:0: error: Call to untyped function "_event" in typed context [no-untyped-call] +apps/job/tests/test_job_event_tracking.py:0: error: Function is missing a return type annotation [no-untyped-def] +apps/job/tests/test_job_event_tracking.py:0: note: Use "-> None" if function does not return a value +apps/job/tests/test_job_event_tracking.py:0: error: Call to untyped function "_event" in typed context [no-untyped-call] +apps/job/tests/test_job_event_tracking.py:0: error: Function is missing a return type annotation [no-untyped-def] +apps/job/tests/test_job_event_tracking.py:0: note: Use "-> None" if function does not return a value +apps/job/tests/test_job_event_tracking.py:0: error: Call to untyped function "_event" in typed context [no-untyped-call] +apps/job/tests/test_job_event_tracking.py:0: error: Function is missing a return type annotation [no-untyped-def] +apps/job/tests/test_job_event_tracking.py:0: note: Use "-> None" if function does not return a value +apps/job/tests/test_job_event_tracking.py:0: error: Call to untyped function "_event" in typed context [no-untyped-call] +apps/job/tests/test_job_event_tracking.py:0: error: Function is missing a return type annotation [no-untyped-def] +apps/job/tests/test_job_event_tracking.py:0: note: Use "-> None" if function does not return a value +apps/job/tests/test_job_event_tracking.py:0: error: Call to untyped function "_event" in typed context [no-untyped-call] +apps/job/tests/test_job_event_tracking.py:0: error: Function is missing a return type annotation [no-untyped-def] +apps/job/tests/test_job_event_tracking.py:0: note: Use "-> None" if function does not return a value +apps/job/tests/test_job_event_tracking.py:0: error: Call to untyped function "_event" in typed context [no-untyped-call] +apps/job/tests/test_job_event_tracking.py:0: error: Function is missing a return type annotation [no-untyped-def] +apps/job/tests/test_job_event_tracking.py:0: note: Use "-> None" if function does not return a value +apps/job/tests/test_job_event_tracking.py:0: error: Call to untyped function "_event" in typed context [no-untyped-call] +apps/job/tests/test_job_event_tracking.py:0: error: Function is missing a return type annotation [no-untyped-def] +apps/job/tests/test_job_event_tracking.py:0: note: Use "-> None" if function does not return a value +apps/job/tests/test_job_event_tracking.py:0: error: Call to untyped function "_event" in typed context [no-untyped-call] +apps/job/tests/test_job_event_tracking.py:0: error: Function is missing a return type annotation [no-untyped-def] +apps/job/tests/test_job_event_tracking.py:0: note: Use "-> None" if function does not return a value +apps/job/tests/test_job_event_tracking.py:0: error: Call to untyped function "_event" in typed context [no-untyped-call] +apps/job/tests/test_job_event_tracking.py:0: error: Function is missing a return type annotation [no-untyped-def] +apps/job/tests/test_job_event_tracking.py:0: note: Use "-> None" if function does not return a value +apps/job/tests/test_job_event_tracking.py:0: error: Call to untyped function "_event" in typed context [no-untyped-call] +apps/job/tests/test_job_event_tracking.py:0: error: Function is missing a return type annotation [no-untyped-def] +apps/job/tests/test_job_event_tracking.py:0: note: Use "-> None" if function does not return a value +apps/job/tests/test_job_event_tracking.py:0: error: Call to untyped function "_event" in typed context [no-untyped-call] +apps/job/tests/test_job_event_tracking.py:0: error: Function is missing a return type annotation [no-untyped-def] +apps/job/tests/test_job_event_tracking.py:0: note: Use "-> None" if function does not return a value +apps/job/tests/test_job_event_tracking.py:0: error: Function is missing a return type annotation [no-untyped-def] +apps/job/tests/test_job_event_tracking.py:0: note: Use "-> None" if function does not return a value +apps/job/tests/test_job_event_tracking.py:0: error: Function is missing a return type annotation [no-untyped-def] +apps/job/tests/test_job_event_tracking.py:0: note: Use "-> None" if function does not return a value +apps/job/tests/test_job_event_tracking.py:0: error: Function is missing a return type annotation [no-untyped-def] +apps/job/tests/test_job_event_tracking.py:0: note: Use "-> None" if function does not return a value +apps/job/tests/test_job_event_tracking.py:0: error: Function is missing a return type annotation [no-untyped-def] +apps/job/tests/test_job_event_tracking.py:0: note: Use "-> None" if function does not return a value +apps/job/tests/test_job_event_tracking.py:0: error: Function is missing a type annotation [no-untyped-def] +apps/job/tests/test_job_event_tracking.py:0: error: Function is missing a return type annotation [no-untyped-def] +apps/job/tests/test_job_event_tracking.py:0: note: Use "-> None" if function does not return a value +apps/job/tests/test_job_event_tracking.py:0: error: Call to untyped function "_make_job" in typed context [no-untyped-call] +apps/job/tests/test_job_event_tracking.py:0: error: Call to untyped function "_make_job" in typed context [no-untyped-call] +apps/job/tests/test_job_event_tracking.py:0: error: Call to untyped function "_make_job" in typed context [no-untyped-call] +apps/job/tests/test_job_event_tracking.py:0: error: Item "None" of "JobEvent | None" has no attribute "detail" [union-attr] +apps/job/tests/test_job_event_tracking.py:0: error: Function is missing a return type annotation [no-untyped-def] +apps/job/tests/test_job_event_tracking.py:0: note: Use "-> None" if function does not return a value +apps/job/tests/test_job_event_tracking.py:0: error: Call to untyped function "_make_job" in typed context [no-untyped-call] +apps/job/tests/test_job_event_tracking.py:0: error: Function is missing a return type annotation [no-untyped-def] +apps/job/tests/test_job_event_tracking.py:0: note: Use "-> None" if function does not return a value +apps/job/tests/test_job_event_tracking.py:0: error: Call to untyped function "_make_job" in typed context [no-untyped-call] +apps/job/tests/test_job_event_tracking.py:0: error: Call to untyped function "_make_job" in typed context [no-untyped-call] +apps/job/tests/test_job_event_tracking.py:0: error: Call to untyped function "_make_job" in typed context [no-untyped-call] +apps/job/tests/test_job_event_tracking.py:0: error: Call to untyped function "_make_job" in typed context [no-untyped-call] +apps/job/tests/test_job_event_tracking.py:0: error: Call to untyped function "_make_job" in typed context [no-untyped-call] +apps/job/tests/test_job_event_tracking.py:0: error: Call to untyped function "_make_job" in typed context [no-untyped-call] +apps/job/tests/test_job_event_tracking.py:0: error: Call to untyped function "_make_job" in typed context [no-untyped-call] +apps/job/tests/test_job_event_tracking.py:0: error: Item "None" of "JobEvent | None" has no attribute "event_type" [union-attr] +apps/job/tests/test_job_event_tracking.py:0: error: Item "None" of "JobEvent | None" has no attribute "detail" [union-attr] +apps/job/tests/test_job_event_tracking.py:0: error: Item "None" of "JobEvent | None" has no attribute "description" [union-attr] +apps/job/tests/test_job_event_tracking.py:0: error: Call to untyped function "update" in typed context [no-untyped-call] +apps/job/tests/test_job_event_tracking.py:0: error: Call to untyped function "update" in typed context [no-untyped-call] +apps/job/tests/test_job_event_tracking.py:0: error: Call to untyped function "untracked_update" in typed context [no-untyped-call] +apps/job/tests/test_job_event_tracking.py:0: error: Call to untyped function "update" in typed context [no-untyped-call] +apps/job/views/quote_sync_views.py:0: error: Function is missing a type annotation [no-untyped-def] +apps/job/views/quote_sync_views.py:0: error: Call to untyped function "draft_line_to_dict" in typed context [no-untyped-call] +apps/job/views/quote_sync_views.py:0: error: Call to untyped function "draft_line_to_dict" in typed context [no-untyped-call] +apps/job/views/quote_sync_views.py:0: error: Call to untyped function "draft_line_to_dict" in typed context [no-untyped-call] +apps/job/views/quote_sync_views.py:0: error: Dict entry 0 has incompatible type "str": "bool"; expected "str": "str" [dict-item] +apps/job/views/quote_sync_views.py:0: error: Incompatible types in assignment (expression has type "ApplyQuoteErrorResponseSerializer", variable has type "QuoteSyncErrorResponseSerializer") [assignment] +apps/job/views/quote_sync_views.py:0: error: Dict entry 0 has incompatible type "str": "bool"; expected "str": "str" [dict-item] +apps/job/views/quote_sync_views.py:0: error: Incompatible types in assignment (expression has type "ApplyQuoteErrorResponseSerializer", variable has type "QuoteSyncErrorResponseSerializer") [assignment] apps/workflow/xero_webhooks.py:0: error: "Callable[[str, dict[str, Any]], None]" has no attribute "delay" [attr-defined] apps/workflow/tests/test_xero_quota_floor.py:0: error: Function is missing a type annotation [no-untyped-def] apps/workflow/tests/test_xero_quota_floor.py:0: error: Function is missing a type annotation [no-untyped-def] @@ -4454,17 +4551,6 @@ apps/workflow/tests/test_xero_po_manager.py:0: error: "type[XeroPurchaseOrderMan apps/workflow/tests/test_xero_po_manager.py:0: error: Function is missing a type annotation [no-untyped-def] apps/workflow/tests/test_xero_po_manager.py:0: error: "XeroPurchaseOrderManagerConstructionTests" has no attribute "purchase_order" [attr-defined] apps/workflow/tests/test_xero_app_active.py:0: error: Value of type "dict[str, Any] | None" is not indexable [index] -apps/workflow/management/commands/export_dev_demo_dump.py:0: error: Function is missing a type annotation [no-untyped-def] -apps/workflow/management/commands/export_dev_demo_dump.py:0: error: Function is missing a type annotation [no-untyped-def] -apps/workflow/management/commands/export_dev_demo_dump.py:0: error: Incompatible types in assignment (expression has type "str | None", target has type "str") [assignment] -apps/workflow/management/commands/export_dev_demo_dump.py:0: error: Call to untyped function "_reset_scrub_schema" in typed context [no-untyped-call] -apps/workflow/management/commands/export_dev_demo_dump.py:0: error: Call to untyped function "_run_pipe" in typed context [no-untyped-call] -apps/workflow/management/commands/export_dev_demo_dump.py:0: error: Call to untyped function "_run" in typed context [no-untyped-call] -apps/workflow/management/commands/export_dev_demo_dump.py:0: error: Call to untyped function "_reset_scrub_schema" in typed context [no-untyped-call] -apps/workflow/management/commands/export_dev_demo_dump.py:0: error: Function is missing a type annotation [no-untyped-def] -apps/workflow/management/commands/export_dev_demo_dump.py:0: error: Call to untyped function "_run" in typed context [no-untyped-call] -apps/workflow/management/commands/export_dev_demo_dump.py:0: error: Function is missing a type annotation [no-untyped-def] -apps/workflow/management/commands/export_dev_demo_dump.py:0: error: Function is missing a type annotation [no-untyped-def] apps/workflow/management/commands/backport_data_backup.py:0: error: Function is missing a type annotation [no-untyped-def] apps/workflow/management/commands/backport_data_backup.py:0: error: Function is missing a type annotation [no-untyped-def] apps/workflow/management/commands/backport_data_backup.py:0: error: Call to untyped function "analyze_fields" in typed context [no-untyped-call] @@ -4486,124 +4572,10 @@ apps/workflow/management/commands/backport_data_backup.py:0: error: Call to unty apps/workflow/management/commands/backport_data_backup.py:0: error: Function is missing a type annotation [no-untyped-def] apps/workflow/management/commands/backport_data_backup.py:0: error: Call to untyped function "collect_field_samples" in typed context [no-untyped-call] apps/workflow/management/commands/backport_data_backup.py:0: error: Call to untyped function "collect_field_samples" in typed context [no-untyped-call] -apps/workflow/management/commands/seed_xero_from_database.py:0: error: Function is missing a type annotation [no-untyped-def] -apps/workflow/management/commands/seed_xero_from_database.py:0: error: Function is missing a type annotation [no-untyped-def] -apps/workflow/management/commands/seed_xero_from_database.py:0: error: Call to untyped function "clear_production_xero_ids" in typed context [no-untyped-call] -apps/workflow/management/commands/seed_xero_from_database.py:0: error: Call to untyped function "process_accounts" in typed context [no-untyped-call] -apps/workflow/management/commands/seed_xero_from_database.py:0: error: Call to untyped function "process_contacts" in typed context [no-untyped-call] -apps/workflow/management/commands/seed_xero_from_database.py:0: error: Call to untyped function "process_projects" in typed context [no-untyped-call] -apps/workflow/management/commands/seed_xero_from_database.py:0: error: Call to untyped function "process_employees" in typed context [no-untyped-call] -apps/workflow/management/commands/seed_xero_from_database.py:0: error: Call to untyped function "process_invoices" in typed context [no-untyped-call] -apps/workflow/management/commands/seed_xero_from_database.py:0: error: Call to untyped function "process_quotes" in typed context [no-untyped-call] -apps/workflow/management/commands/seed_xero_from_database.py:0: error: Call to untyped function "process_stock_items" in typed context [no-untyped-call] -apps/workflow/management/commands/seed_xero_from_database.py:0: error: Call to untyped function "save" in typed context [no-untyped-call] -apps/workflow/management/commands/seed_xero_from_database.py:0: error: Function is missing a type annotation [no-untyped-def] -apps/workflow/management/commands/seed_xero_from_database.py:0: error: Call to untyped function "process_xero_data" in typed context [no-untyped-call] -apps/workflow/management/commands/seed_xero_from_database.py:0: error: Function is missing a type annotation [no-untyped-def] -apps/workflow/management/commands/seed_xero_from_database.py:0: error: Call to untyped function "seed_clients_to_xero" in typed context [no-untyped-call] -apps/workflow/management/commands/seed_xero_from_database.py:0: error: Function is missing a type annotation [no-untyped-def] -apps/workflow/management/commands/seed_xero_from_database.py:0: error: Call to untyped function "seed_jobs_to_xero" in typed context [no-untyped-call] -apps/workflow/management/commands/seed_xero_from_database.py:0: error: Function is missing a type annotation [no-untyped-def] -apps/workflow/management/commands/seed_xero_from_database.py:0: error: Call to untyped function "fetch_xero_entity_lookup" in typed context [no-untyped-call] -apps/workflow/management/commands/seed_xero_from_database.py:0: error: Call to untyped function "_batch_create_invoices" in typed context [no-untyped-call] -apps/workflow/management/commands/seed_xero_from_database.py:0: error: Function is missing a type annotation [no-untyped-def] -apps/workflow/management/commands/seed_xero_from_database.py:0: error: Call to untyped function "_get_invoice_line_unit_amount" of "Command" in typed context [no-untyped-call] -apps/workflow/management/commands/seed_xero_from_database.py:0: error: Call to untyped function "convert_to_pascal_case" in typed context [no-untyped-call] -apps/workflow/management/commands/seed_xero_from_database.py:0: error: Call to untyped function "clean_payload" in typed context [no-untyped-call] -apps/workflow/management/commands/seed_xero_from_database.py:0: error: Function is missing a type annotation [no-untyped-def] -apps/workflow/management/commands/seed_xero_from_database.py:0: error: Call to untyped function "_build_invoice_payload" in typed context [no-untyped-call] -apps/workflow/management/commands/seed_xero_from_database.py:0: error: Function is missing a type annotation [no-untyped-def] -apps/workflow/management/commands/seed_xero_from_database.py:0: error: Function is missing a type annotation [no-untyped-def] -apps/workflow/management/commands/seed_xero_from_database.py:0: error: Call to untyped function "fetch_xero_entity_lookup" in typed context [no-untyped-call] -apps/workflow/management/commands/seed_xero_from_database.py:0: error: Call to untyped function "_batch_create_quotes" in typed context [no-untyped-call] -apps/workflow/management/commands/seed_xero_from_database.py:0: error: Function is missing a type annotation [no-untyped-def] -apps/workflow/management/commands/seed_xero_from_database.py:0: error: Call to untyped function "convert_to_pascal_case" in typed context [no-untyped-call] -apps/workflow/management/commands/seed_xero_from_database.py:0: error: Call to untyped function "clean_payload" in typed context [no-untyped-call] -apps/workflow/management/commands/seed_xero_from_database.py:0: error: Function is missing a type annotation [no-untyped-def] -apps/workflow/management/commands/seed_xero_from_database.py:0: error: Call to untyped function "_build_quote_payload" in typed context [no-untyped-call] -apps/workflow/management/commands/seed_xero_from_database.py:0: error: Function is missing a type annotation [no-untyped-def] -apps/workflow/management/commands/seed_xero_from_database.py:0: error: Function is missing a type annotation [no-untyped-def] -apps/workflow/management/commands/seed_xero_from_database.py:0: error: Function is missing a type annotation [no-untyped-def] -apps/workflow/management/commands/seed_xero_from_database.py:0: error: Item "None" of "str | None" has no attribute "endswith" [union-attr] -apps/workflow/management/commands/seed_xero_from_database.py:0: error: Call to untyped function "_table_exists" in typed context [no-untyped-call] -apps/workflow/management/commands/seed_xero_from_database.py:0: error: Call to untyped function "_table_exists" in typed context [no-untyped-call] -apps/workflow/management/commands/seed_xero_from_database.py:0: error: Call to untyped function "_column_exists" in typed context [no-untyped-call] -apps/workflow/management/commands/seed_xero_from_database.py:0: error: Call to untyped function "_table_exists" in typed context [no-untyped-call] -apps/workflow/management/commands/seed_xero_from_database.py:0: error: Call to untyped function "_column_exists" in typed context [no-untyped-call] -apps/workflow/management/commands/seed_xero_from_database.py:0: error: Call to untyped function "_table_exists" in typed context [no-untyped-call] -apps/workflow/management/commands/seed_xero_from_database.py:0: error: Call to untyped function "_column_exists" in typed context [no-untyped-call] -apps/workflow/management/commands/seed_xero_from_database.py:0: error: Call to untyped function "_table_exists" in typed context [no-untyped-call] -apps/workflow/management/commands/seed_xero_from_database.py:0: error: Function is missing a type annotation [no-untyped-def] -apps/workflow/management/commands/seed_xero_from_database.py:0: error: Function is missing a type annotation [no-untyped-def] -apps/quoting/views.py:0: error: Function is missing a type annotation [no-untyped-def] -apps/quoting/views.py:0: error: Call to untyped function "PDFDataValidationService" in typed context [no-untyped-call] -apps/quoting/views.py:0: error: Call to untyped function "PDFImportService" in typed context [no-untyped-call] -apps/workflow/apps.py:0: error: Function is missing a type annotation for one or more parameters [no-untyped-def] -apps/workflow/apps.py:0: error: Function is missing a type annotation [no-untyped-def] apps/workflow/tests/test_seed_xero_from_database.py:0: error: Function is missing a return type annotation [no-untyped-def] apps/workflow/tests/test_seed_xero_from_database.py:0: note: Use "-> None" if function does not return a value apps/workflow/tests/test_seed_xero_from_database.py:0: error: Incompatible types in assignment (expression has type "StringIO", variable has type "OutputWrapper") [assignment] apps/workflow/tests/test_seed_xero_from_database.py:0: error: Call to untyped function "clear_production_xero_ids" in typed context [no-untyped-call] -apps/workflow/management/commands/xero.py:0: error: Function is missing a return type annotation [no-untyped-def] -apps/workflow/management/commands/xero.py:0: error: Function is missing a type annotation [no-untyped-def] -apps/workflow/management/commands/xero.py:0: error: Function is missing a type annotation [no-untyped-def] -apps/workflow/management/commands/xero.py:0: error: Call to untyped function "run_setup" in typed context [no-untyped-call] -apps/workflow/management/commands/xero.py:0: error: Call to untyped function "get_users" in typed context [no-untyped-call] -apps/workflow/management/commands/xero.py:0: error: Call to untyped function "get_payroll_employees" in typed context [no-untyped-call] -apps/workflow/management/commands/xero.py:0: error: Call to untyped function "get_payroll_rates" in typed context [no-untyped-call] -apps/workflow/management/commands/xero.py:0: error: Call to untyped function "get_payroll_calendars" in typed context [no-untyped-call] -apps/workflow/management/commands/xero.py:0: error: Call to untyped function "get_payroll_leave_types" in typed context [no-untyped-call] -apps/workflow/management/commands/xero.py:0: error: Call to untyped function "get_payroll_pay_runs" in typed context [no-untyped-call] -apps/workflow/management/commands/xero.py:0: error: Call to untyped function "configure_payroll" in typed context [no-untyped-call] -apps/workflow/management/commands/xero.py:0: error: Call to untyped function "import_staff" in typed context [no-untyped-call] -apps/workflow/management/commands/xero.py:0: error: Call to untyped function "link_staff" in typed context [no-untyped-call] -apps/workflow/management/commands/xero.py:0: error: Call to untyped function "create_staff" in typed context [no-untyped-call] -apps/workflow/management/commands/xero.py:0: error: Call to untyped function "get_tenants" in typed context [no-untyped-call] -apps/workflow/management/commands/xero.py:0: error: Call to untyped function "get_tenants" in typed context [no-untyped-call] -apps/workflow/management/commands/xero.py:0: error: Function is missing a type annotation [no-untyped-def] -apps/workflow/management/commands/xero.py:0: error: Call to untyped function "save" in typed context [no-untyped-call] -apps/workflow/management/commands/xero.py:0: error: Function is missing a return type annotation [no-untyped-def] -apps/workflow/management/commands/xero.py:0: note: Use "-> None" if function does not return a value -apps/workflow/management/commands/xero.py:0: error: Call to untyped function "save" in typed context [no-untyped-call] -apps/workflow/management/commands/xero.py:0: error: Call to untyped function "save" in typed context [no-untyped-call] -apps/workflow/management/commands/xero.py:0: error: Function is missing a return type annotation [no-untyped-def] -apps/workflow/management/commands/xero.py:0: note: Use "-> None" if function does not return a value -apps/workflow/management/commands/xero.py:0: error: Function is missing a type annotation [no-untyped-def] -apps/workflow/management/commands/xero.py:0: error: Call to untyped function "get_employees_simple_dev" in typed context [no-untyped-call] -apps/workflow/management/commands/xero.py:0: error: Function is missing a return type annotation [no-untyped-def] -apps/workflow/management/commands/xero.py:0: note: Use "-> None" if function does not return a value -apps/workflow/management/commands/xero.py:0: error: Function is missing a return type annotation [no-untyped-def] -apps/workflow/management/commands/xero.py:0: note: Use "-> None" if function does not return a value -apps/workflow/management/commands/xero.py:0: error: Function is missing a return type annotation [no-untyped-def] -apps/workflow/management/commands/xero.py:0: note: Use "-> None" if function does not return a value -apps/workflow/management/commands/xero.py:0: error: Function is missing a return type annotation [no-untyped-def] -apps/workflow/management/commands/xero.py:0: note: Use "-> None" if function does not return a value -apps/workflow/management/commands/xero.py:0: error: Function is missing a return type annotation [no-untyped-def] -apps/workflow/management/commands/xero.py:0: note: Use "-> None" if function does not return a value -apps/workflow/management/commands/xero.py:0: error: Function is missing a type annotation [no-untyped-def] -apps/workflow/management/commands/xero.py:0: error: Function is missing a type annotation [no-untyped-def] -apps/workflow/management/commands/xero.py:0: error: Call to untyped function "_print_link_staff_summary" in typed context [no-untyped-call] -apps/workflow/management/commands/xero.py:0: error: Function is missing a type annotation [no-untyped-def] -apps/workflow/management/commands/xero.py:0: error: Call to untyped function "_print_create_staff_summary" in typed context [no-untyped-call] -apps/workflow/management/commands/xero.py:0: error: Function is missing a type annotation [no-untyped-def] -apps/workflow/management/commands/xero.py:0: error: Function is missing a type annotation [no-untyped-def] -apps/workflow/management/commands/xero.py:0: error: Function is missing a type annotation [no-untyped-def] -apps/workflow/management/commands/xero.py:0: error: Function is missing a type annotation [no-untyped-def] -apps/workflow/management/commands/xero.py:0: error: Function is missing a type annotation [no-untyped-def] -apps/timesheet/api/daily_timesheet_views.py:0: error: Function is missing a return type annotation [no-untyped-def] -apps/timesheet/api/daily_timesheet_views.py:0: error: Function is missing a type annotation for one or more parameters [no-untyped-def] -apps/timesheet/api/daily_timesheet_views.py:0: error: Function is missing a return type annotation [no-untyped-def] -apps/timesheet/api/daily_timesheet_views.py:0: error: Function is missing a type annotation for one or more parameters [no-untyped-def] -apps/accounts/tests/test_auth_observability.py:0: error: Function is missing a return type annotation [no-untyped-def] -apps/accounts/tests/test_auth_observability.py:0: note: Use "-> None" if function does not return a value -apps/accounts/tests/test_auth_observability.py:0: error: Call to untyped function "authenticate" in typed context [no-untyped-call] -apps/accounts/tests/test_auth_observability.py:0: error: Function is missing a return type annotation [no-untyped-def] -apps/accounts/tests/test_auth_observability.py:0: note: Use "-> None" if function does not return a value -apps/accounts/tests/test_auth_observability.py:0: error: Call to untyped function "authenticate" in typed context [no-untyped-call] -apps/accounts/tests/test_auth_observability.py:0: error: Function is missing a return type annotation [no-untyped-def] -apps/accounts/tests/test_auth_observability.py:0: note: Use "-> None" if function does not return a value -apps/accounts/tests/test_auth_observability.py:0: error: Function is missing a return type annotation [no-untyped-def] -apps/accounts/tests/test_auth_observability.py:0: note: Use "-> None" if function does not return a value apps/workflow/tests/test_xero_setup_command.py:0: error: Function is missing a return type annotation [no-untyped-def] apps/workflow/tests/test_xero_setup_command.py:0: error: Function is missing a return type annotation [no-untyped-def] apps/workflow/tests/test_xero_setup_command.py:0: error: Function is missing a type annotation [no-untyped-def] @@ -4625,3 +4597,15 @@ apps/workflow/tests/test_xero_setup_command.py:0: error: Cannot assign to a meth apps/workflow/tests/test_xero_setup_command.py:0: error: Call to untyped function "run_setup" in typed context [no-untyped-call] apps/workflow/tests/test_xero_setup_command.py:0: error: Function is missing a return type annotation [no-untyped-def] apps/workflow/tests/test_xero_setup_command.py:0: note: Use "-> None" if function does not return a value +apps/workflow/apps.py:0: error: Function is missing a type annotation for one or more parameters [no-untyped-def] +apps/workflow/apps.py:0: error: Function is missing a type annotation [no-untyped-def] +apps/accounts/tests/test_auth_observability.py:0: error: Function is missing a return type annotation [no-untyped-def] +apps/accounts/tests/test_auth_observability.py:0: note: Use "-> None" if function does not return a value +apps/accounts/tests/test_auth_observability.py:0: error: Call to untyped function "authenticate" in typed context [no-untyped-call] +apps/accounts/tests/test_auth_observability.py:0: error: Function is missing a return type annotation [no-untyped-def] +apps/accounts/tests/test_auth_observability.py:0: note: Use "-> None" if function does not return a value +apps/accounts/tests/test_auth_observability.py:0: error: Call to untyped function "authenticate" in typed context [no-untyped-call] +apps/accounts/tests/test_auth_observability.py:0: error: Function is missing a return type annotation [no-untyped-def] +apps/accounts/tests/test_auth_observability.py:0: note: Use "-> None" if function does not return a value +apps/accounts/tests/test_auth_observability.py:0: error: Function is missing a return type annotation [no-untyped-def] +apps/accounts/tests/test_auth_observability.py:0: note: Use "-> None" if function does not return a value diff --git a/scripts/analyze_client_contacts.py b/scripts/analyze_client_contacts.py index bd3b68285..ec48db783 100644 --- a/scripts/analyze_client_contacts.py +++ b/scripts/analyze_client_contacts.py @@ -19,7 +19,7 @@ from django.db.models import Count -from apps.client.models import Client, ClientContact +from apps.company.models import ClientContact, Company from apps.job.models import Job @@ -38,7 +38,7 @@ def analyze_empty_names(verbose: bool) -> None: logging.info("Sample records (first 10):") for contact in empty_name_contacts[:10]: logging.info(" - ID: %s", contact.id) - logging.info(" Client: %s", contact.client.name) + logging.info(" Company: %s", contact.company.name) logging.info(" Name: '%s' (length: %d)", contact.name, len(contact.name)) logging.info(" Email: %s", contact.email or "None") @@ -48,11 +48,11 @@ def analyze_empty_names(verbose: bool) -> None: def analyze_duplicates(verbose: bool) -> None: - """Analyze duplicate ClientContact records (same client + name).""" + """Analyze duplicate ClientContact records (same company + name).""" logging.info("2. DUPLICATE CONTACT ANALYSIS") duplicates = ( - ClientContact.objects.values("client", "name") + ClientContact.objects.values("company", "name") .annotate(count=Count("id")) .filter(count__gt=1) .order_by("-count") @@ -60,7 +60,7 @@ def analyze_duplicates(verbose: bool) -> None: total_duplicate_groups = duplicates.count() logging.info( - "Total duplicate (client, name) combinations: %d", total_duplicate_groups + "Total duplicate (company, name) combinations: %d", total_duplicate_groups ) if total_duplicate_groups == 0: @@ -73,17 +73,17 @@ def analyze_duplicates(verbose: bool) -> None: if verbose: logging.info("Top 10 duplicate groups:") for i, dup in enumerate(duplicates[:10], 1): - client = Client.objects.get(id=dup["client"]) + company = Company.objects.get(id=dup["company"]) logging.info( - "%d. Client: %s — Contact: '%s' — %d copies", + "%d. Company: %s — Contact: '%s' — %d copies", i, - client.name, + company.name, dup["name"], dup["count"], ) contacts = ClientContact.objects.filter( - client_id=dup["client"], name=dup["name"] + company_id=dup["company"], name=dup["name"] ).order_by("-created_at") for contact in contacts[:5]: @@ -105,7 +105,7 @@ def analyze_duplicates(verbose: bool) -> None: logging.info("--- SUMMARY ---") logging.info("Empty/whitespace name contacts: %d", empty_count) - logging.info("Duplicate (client, name) combinations: %d", total_duplicate_groups) + logging.info("Duplicate (company, name) combinations: %d", total_duplicate_groups) logging.info("Total duplicate records to be removed: %d", total_duplicate_records) logging.info( "Total records after cleanup: %d", diff --git a/scripts/fix_test_client.py b/scripts/fix_test_client.py deleted file mode 100644 index 059b59e70..000000000 --- a/scripts/fix_test_client.py +++ /dev/null @@ -1,34 +0,0 @@ -#!/usr/bin/env python -"""Create the test client (CompanyDefaults.test_client_name) when missing. - -Idempotent restore-time mutation. Pairs with -scripts/restore_checks/check_test_client.py, which is now read-only. -""" - -import os - -os.environ.setdefault("DJANGO_SETTINGS_MODULE", "docketworks.settings") - -import django - -django.setup() - -from django.utils import timezone - -from apps.client.models import Client -from apps.workflow.models import CompanyDefaults - -cd = CompanyDefaults.get_solo() -client = Client.objects.filter(name=cd.test_client_name).first() - -if client: - print(f"Test client already exists: {client.name} (ID: {client.id})") -else: - client = Client( - name=cd.test_client_name, - is_account_customer=False, - xero_last_modified=timezone.now(), - xero_last_synced=timezone.now(), - ) - client.save() - print(f"Created test client: {client.name} (ID: {client.id})") diff --git a/scripts/fix_test_company.py b/scripts/fix_test_company.py new file mode 100644 index 000000000..4beb1847f --- /dev/null +++ b/scripts/fix_test_company.py @@ -0,0 +1,34 @@ +#!/usr/bin/env python +"""Create the test company (CompanyDefaults.test_company_name) when missing. + +Idempotent restore-time mutation. Pairs with +scripts/restore_checks/check_test_company.py, which is now read-only. +""" + +import os + +os.environ.setdefault("DJANGO_SETTINGS_MODULE", "docketworks.settings") + +import django + +django.setup() + +from django.utils import timezone + +from apps.company.models import Company +from apps.workflow.models import CompanyDefaults + +cd = CompanyDefaults.get_solo() +company = Company.objects.filter(name=cd.test_company_name).first() + +if company: + print(f"Test company already exists: {company.name} (ID: {company.id})") +else: + company = Company( + name=cd.test_company_name, + is_account_customer=False, + xero_last_modified=timezone.now(), + xero_last_synced=timezone.now(), + ) + company.save() + print(f"Created test company: {company.name} (ID: {company.id})") diff --git a/scripts/geocode_addresses.py b/scripts/geocode_addresses.py index 8897fddd1..ae16f4e5e 100644 --- a/scripts/geocode_addresses.py +++ b/scripts/geocode_addresses.py @@ -21,8 +21,8 @@ django.setup() -from apps.client.models import SupplierPickupAddress -from apps.client.services.geocoding_service import ( +from apps.company.models import SupplierPickupAddress +from apps.company.services.geocoding_service import ( GeocodingError, GeocodingNotConfiguredError, geocode_address, @@ -99,7 +99,7 @@ def main() -> None: for i, address in enumerate(addresses, 1): freetext = build_freetext_address(address) - logging.info("[%d/%d] %s", i, total, address.client.name) + logging.info("[%d/%d] %s", i, total, address.company.name) logging.info(" Input: %s", freetext) if args.dry_run: diff --git a/scripts/integration/verify_xero_batch_order.py b/scripts/integration/verify_xero_batch_order.py index 2010edab1..d78a27db3 100644 --- a/scripts/integration/verify_xero_batch_order.py +++ b/scripts/integration/verify_xero_batch_order.py @@ -2,7 +2,7 @@ """Verify that Xero's create_contacts API preserves submission order. The bulk_create_contacts_in_xero path in apps/workflow/api/xero/push.py -maps response contacts back to local Client rows by zip-by-index. That +maps response contacts back to local Company rows by zip-by-index. That is correct iff Xero echoes contacts back in the same order they were sent. The Xero SDK does not document this guarantee, so we validate it on demand against a real dev tenant. diff --git a/scripts/integration/verify_xero_client_quote_contract.py b/scripts/integration/verify_xero_client_quote_contract.py index 7332de9d4..c051d4abd 100644 --- a/scripts/integration/verify_xero_client_quote_contract.py +++ b/scripts/integration/verify_xero_client_quote_contract.py @@ -1,5 +1,5 @@ #!/usr/bin/env python -"""Verify the public app API and Xero agree on client/quote contact data. +"""Verify the public app API and Xero agree on company/quote contact data. App writes go through the configured public app URL. Xero verification uses official Xero SDK read APIs. This creates real local records and real Xero @@ -153,19 +153,19 @@ def assert_contact_contract(contact, expected: dict[str, str]) -> None: "is_account_customer": True, "allow_jobs": True, } -log.info("client request: %s", json.dumps(client_payload, sort_keys=True)) -client = app_request("POST", "/api/clients/create/", json=client_payload)["client"] -assert client["email"] == client_payload["email"], client -assert client["phone"] == client_payload["phone"], client -assert client["address"] == client_payload["address"], client -assert client["xero_contact_id"], client - -created_contact = xero_contact(client["xero_contact_id"]) +log.info("company request: %s", json.dumps(client_payload, sort_keys=True)) +company = app_request("POST", "/api/companies/create/", json=client_payload)["company"] +assert company["email"] == client_payload["email"], company +assert company["phone"] == client_payload["phone"], company +assert company["address"] == client_payload["address"], company +assert company["xero_contact_id"], company + +created_contact = xero_contact(company["xero_contact_id"]) assert_contact_contract(created_contact, client_payload) job_payload = { "name": f"Xero Contract Job {run_id}", - "client_id": client["id"], + "client_id": company["id"], "description": "Xero contract smoke test", "pricing_methodology": "fixed_price", "estimated_materials": "1000.00", @@ -176,23 +176,23 @@ def assert_contact_contract(contact, expected: dict[str, str]) -> None: job_id = job["job_id"] job_readback = app_request("GET", f"/api/job/jobs/{job_id}/")["data"]["job"] -assert job_readback["client_id"] == client["id"], job_readback +assert job_readback["client_id"] == company["id"], job_readback quote = app_request( "POST", f"/api/xero/create_quote/{job_id}", json={"breakdown": False} ) quote_readback = xero_quote(quote["xero_id"]) -assert str(quote_readback.contact.contact_id) == client["xero_contact_id"], ( +assert str(quote_readback.contact.contact_id) == company["xero_contact_id"], ( quote_readback.contact.contact_id, - client["xero_contact_id"], + company["xero_contact_id"], ) -contact_after_quote = xero_contact(client["xero_contact_id"]) +contact_after_quote = xero_contact(company["xero_contact_id"]) assert_contact_contract(contact_after_quote, client_payload) log.info("PASS") -log.info("client_id=%s", client["id"]) -log.info("xero_contact_id=%s", client["xero_contact_id"]) +log.info("client_id=%s", company["id"]) +log.info("xero_contact_id=%s", company["xero_contact_id"]) log.info("job_id=%s", job_id) log.info("xero_quote_id=%s", quote["xero_id"]) log.info("email=%s", client_payload["email"]) diff --git a/scripts/push_clients_to_xero.py b/scripts/push_companies_to_xero.py similarity index 56% rename from scripts/push_clients_to_xero.py rename to scripts/push_companies_to_xero.py index 059aeccc3..bb6104817 100644 --- a/scripts/push_clients_to_xero.py +++ b/scripts/push_companies_to_xero.py @@ -1,13 +1,13 @@ #!/usr/bin/env python """ -Push database clients to Xero as contacts. +Push database companies to Xero as contacts. This script is designed to run after production restore and Xero sync. -It finds clients that are referenced by jobs or purchase orders but don't +It finds companies that are referenced by jobs or purchase orders but don't have xero_contact_id values, and creates them as contacts in Xero. Usage: - python scripts/push_clients_to_xero.py [--dry-run] [--force] + python scripts/push_companies_to_xero.py [--dry-run] [--force] Options: --dry-run Show what would be created without making changes @@ -28,8 +28,8 @@ os.environ.setdefault("DJANGO_SETTINGS_MODULE", "docketworks.settings") django.setup() -from apps.client.models import Client -from apps.workflow.api.xero.sync import sync_client_to_xero +from apps.company.models import Company +from apps.workflow.api.xero.sync import sync_company_to_xero from apps.workflow.services.error_persistence import persist_app_error @@ -72,67 +72,71 @@ def confirm_operation(force=False, dry_run=False): print() -def find_clients_to_push(): - """Find clients that need to be pushed to Xero.""" - # Clients referenced by jobs - clients_with_jobs = Client.objects.filter( +def find_companies_to_push(): + """Find companies that need to be pushed to Xero.""" + # Companies referenced by jobs + companies_with_jobs = Company.objects.filter( jobs__isnull=False, xero_contact_id__isnull=True ).distinct() - # Clients referenced by purchase orders - clients_with_pos = Client.objects.filter( + # Companies referenced by purchase orders + companies_with_pos = Company.objects.filter( purchase_orders__isnull=False, xero_contact_id__isnull=True ).distinct() # Combine and deduplicate - all_clients = (clients_with_jobs | clients_with_pos).distinct().order_by("name") + all_companies = ( + (companies_with_jobs | companies_with_pos).distinct().order_by("name") + ) - return all_clients + return all_companies -def push_clients_to_xero(clients, dry_run=False): - """Push clients to Xero and track results.""" +def push_companies_to_xero(companies, dry_run=False): + """Push companies to Xero and track results.""" results = {"success": [], "failed": [], "skipped": []} - for i, client in enumerate(clients, 1): - print(f"📤 ({i}/{len(clients)}) Processing: {client.name}") + for i, company in enumerate(companies, 1): + print(f"📤 ({i}/{len(companies)}) Processing: {company.name}") - # Validate client has required data + # Validate company has required data try: - if not client.validate_for_xero(): + if not company.validate_for_xero(): print(" ⚠️ Skipping - missing required data for Xero") - results["skipped"].append((client, "Missing required data")) + results["skipped"].append((company, "Missing required data")) continue except Exception as e: print(f" ❌ Validation error: {e}") - results["failed"].append((client, f"Validation error: {e}")) + results["failed"].append((company, f"Validation error: {e}")) continue if dry_run: - print(f" ✅ Would create contact: {client.name}") - results["success"].append((client, "Would create (dry run)")) + print(f" ✅ Would create contact: {company.name}") + results["success"].append((company, "Would create (dry run)")) continue # Attempt to push to Xero try: - success = sync_client_to_xero(client) + success = sync_company_to_xero(company) if success: - print(f" ✅ Created in Xero with ID: {client.xero_contact_id}") - results["success"].append((client, client.xero_contact_id)) + print(f" ✅ Created in Xero with ID: {company.xero_contact_id}") + results["success"].append((company, company.xero_contact_id)) else: print(" ❌ Failed to create in Xero") - results["failed"].append((client, "sync_client_to_xero returned False")) + results["failed"].append( + (company, "sync_company_to_xero returned False") + ) except Exception as e: print(f" ❌ Error creating in Xero: {e}") persist_app_error( e, additional_context={ - "operation": "push_clients_to_xero", - "client_id": str(client.id), - "client_name": client.name, + "operation": "push_companies_to_xero", + "company_id": str(company.id), + "company_name": company.name, }, ) - results["failed"].append((client, str(e))) + results["failed"].append((company, str(e))) return results @@ -144,41 +148,41 @@ def main(): force = "--force" in sys.argv mode_text = "DRY RUN - " if dry_run else "" - print(f"🚀 {mode_text}Push Database Clients to Xero") + print(f"🚀 {mode_text}Push Database Companies to Xero") print("=" * 50) # Safety confirmation confirm_operation(force=force, dry_run=dry_run) - # Find clients that need to be pushed - print("🔍 Finding clients that need to be pushed to Xero...") - clients_to_push = find_clients_to_push() + # Find companies that need to be pushed + print("🔍 Finding companies that need to be pushed to Xero...") + companies_to_push = find_companies_to_push() - if not clients_to_push: - print("✅ No clients need to be pushed to Xero.") + if not companies_to_push: + print("✅ No companies need to be pushed to Xero.") print( - "All clients with jobs or purchase orders already have xero_contact_id values." + "All companies with jobs or purchase orders already have xero_contact_id values." ) return - print(f"📋 Found {len(clients_to_push)} clients to push:") - for client in clients_to_push[:10]: # Show first 10 - job_count = client.jobs.count() - po_count = client.purchase_orders.count() - print(f" • {client.name} (Jobs: {job_count}, POs: {po_count})") + print(f"📋 Found {len(companies_to_push)} companies to push:") + for company in companies_to_push[:10]: # Show first 10 + job_count = company.jobs.count() + po_count = company.purchase_orders.count() + print(f" • {company.name} (Jobs: {job_count}, POs: {po_count})") - if len(clients_to_push) > 10: - print(f" ... and {len(clients_to_push) - 10} more") + if len(companies_to_push) > 10: + print(f" ... and {len(companies_to_push) - 10} more") print() - # Push clients to Xero + # Push companies to Xero action_text = "Simulating push of" if dry_run else "Pushing" - print(f"🚀 {action_text} {len(clients_to_push)} clients to Xero...") + print(f"🚀 {action_text} {len(companies_to_push)} companies to Xero...") print() with transaction.atomic(): - results = push_clients_to_xero(clients_to_push, dry_run=dry_run) + results = push_companies_to_xero(companies_to_push, dry_run=dry_run) # Report results print() @@ -190,15 +194,15 @@ def main(): if results["failed"]: print() - print("❌ Failed clients:") - for client, reason in results["failed"]: - print(f" • {client.name}: {reason}") + print("❌ Failed companies:") + for company, reason in results["failed"]: + print(f" • {company.name}: {reason}") if results["skipped"]: print() - print("⚠️ Skipped clients:") - for client, reason in results["skipped"]: - print(f" • {client.name}: {reason}") + print("⚠️ Skipped companies:") + for company, reason in results["skipped"]: + print(f" • {company.name}: {reason}") if not dry_run and results["success"]: print() @@ -211,7 +215,7 @@ def main(): print("🔍 Dry run complete - no changes made") print("Run without --dry-run to actually create contacts in Xero") else: - print("🎉 Client push to Xero complete!") + print("🎉 Company push to Xero complete!") if __name__ == "__main__": diff --git a/scripts/restore_checks/check_django_orm.py b/scripts/restore_checks/check_django_orm.py index 2658f2ebd..77aeb4089 100644 --- a/scripts/restore_checks/check_django_orm.py +++ b/scripts/restore_checks/check_django_orm.py @@ -10,12 +10,12 @@ django.setup() from apps.accounts.models import Staff -from apps.client.models import Client +from apps.company.models import Company from apps.job.models import Job print(f"Jobs: {Job.objects.count()}") print(f"Staff: {Staff.objects.count()}") -print(f"Clients: {Client.objects.count()}") +print(f"Companies: {Company.objects.count()}") job = Job.objects.first() if job: diff --git a/scripts/restore_checks/check_shop_client.py b/scripts/restore_checks/check_shop_client.py deleted file mode 100644 index c93bbc050..000000000 --- a/scripts/restore_checks/check_shop_client.py +++ /dev/null @@ -1,22 +0,0 @@ -#!/usr/bin/env python -"""Verify shop client has correct name.""" - -import os -import sys - -os.environ.setdefault("DJANGO_SETTINGS_MODULE", "docketworks.settings") - -import django - -django.setup() - -from apps.client.models import Client - -SHOP_CLIENT_ID = "00000000-0000-0000-0000-000000000001" - -try: - shop = Client.objects.get(id=SHOP_CLIENT_ID) - print(f"Shop client: {shop.name}") -except Client.DoesNotExist: - print(f"ERROR: Shop client with ID {SHOP_CLIENT_ID} not found") - sys.exit(1) diff --git a/scripts/restore_checks/check_shop_company.py b/scripts/restore_checks/check_shop_company.py new file mode 100644 index 000000000..ba1022a31 --- /dev/null +++ b/scripts/restore_checks/check_shop_company.py @@ -0,0 +1,22 @@ +#!/usr/bin/env python +"""Verify shop company has correct name.""" + +import os +import sys + +os.environ.setdefault("DJANGO_SETTINGS_MODULE", "docketworks.settings") + +import django + +django.setup() + +from apps.company.models import Company + +SHOP_COMPANY_ID = "00000000-0000-0000-0000-000000000001" + +try: + shop = Company.objects.get(id=SHOP_COMPANY_ID) + print(f"Shop company: {shop.name}") +except Company.DoesNotExist: + print(f"ERROR: Shop company with ID {SHOP_COMPANY_ID} not found") + sys.exit(1) diff --git a/scripts/restore_checks/check_test_client.py b/scripts/restore_checks/check_test_client.py deleted file mode 100644 index 09904c853..000000000 --- a/scripts/restore_checks/check_test_client.py +++ /dev/null @@ -1,26 +0,0 @@ -#!/usr/bin/env python -"""Verify the test client (CompanyDefaults.test_client_name) exists. Read-only.""" - -import os -import sys - -os.environ.setdefault("DJANGO_SETTINGS_MODULE", "docketworks.settings") - -import django - -django.setup() - -from apps.client.models import Client -from apps.workflow.models import CompanyDefaults - -cd = CompanyDefaults.get_solo() -client = Client.objects.filter(name=cd.test_client_name).first() - -if not client: - print( - f"ERROR: Test client {cd.test_client_name!r} not found — " - "run scripts/fix_test_client.py first" - ) - sys.exit(1) - -print(f"Test client: {client.name} (ID: {client.id})") diff --git a/scripts/restore_checks/check_test_company.py b/scripts/restore_checks/check_test_company.py new file mode 100644 index 000000000..832b9a6c3 --- /dev/null +++ b/scripts/restore_checks/check_test_company.py @@ -0,0 +1,26 @@ +#!/usr/bin/env python +"""Verify the test company (CompanyDefaults.test_company_name) exists. Read-only.""" + +import os +import sys + +os.environ.setdefault("DJANGO_SETTINGS_MODULE", "docketworks.settings") + +import django + +django.setup() + +from apps.company.models import Company +from apps.workflow.models import CompanyDefaults + +cd = CompanyDefaults.get_solo() +company = Company.objects.filter(name=cd.test_company_name).first() + +if not company: + print( + f"ERROR: Test company {cd.test_company_name!r} not found — " + "run scripts/fix_test_company.py first" + ) + sys.exit(1) + +print(f"Test company: {company.name} (ID: {company.id})") diff --git a/scripts/restore_checks/check_xero_seed.py b/scripts/restore_checks/check_xero_seed.py index 74c4802ef..6d1755257 100644 --- a/scripts/restore_checks/check_xero_seed.py +++ b/scripts/restore_checks/check_xero_seed.py @@ -10,18 +10,18 @@ django.setup() from apps.accounts.models import Staff -from apps.client.models import Client +from apps.company.models import Company from apps.job.models import Job from apps.purchasing.models import Stock -clients_with_xero = Client.objects.filter(xero_contact_id__isnull=False).count() +companies_with_xero = Company.objects.filter(xero_contact_id__isnull=False).count() jobs_with_xero = Job.objects.filter(xero_project_id__isnull=False).count() stock_with_xero = Stock.objects.filter(xero_id__isnull=False, is_active=True).count() staff_with_xero = Staff.objects.filter( xero_user_id__isnull=False, date_left__isnull=True ).count() -print(f"Clients linked to Xero: {clients_with_xero}") +print(f"Companies linked to Xero: {companies_with_xero}") print(f"Jobs linked to Xero: {jobs_with_xero}") print(f"Stock items synced to Xero: {stock_with_xero}") print(f"Staff linked to Xero Payroll: {staff_with_xero}") diff --git a/scripts/restore_checks/fix_shop_client.py b/scripts/restore_checks/fix_shop_client.py deleted file mode 100644 index a4aa23138..000000000 --- a/scripts/restore_checks/fix_shop_client.py +++ /dev/null @@ -1,31 +0,0 @@ -#!/usr/bin/env python -"""Fix shop client name after production restore (anonymized during backup).""" - -import os -import sys - -os.environ.setdefault("DJANGO_SETTINGS_MODULE", "docketworks.settings") - -import django - -django.setup() - -from apps.client.models import Client - -SHOP_CLIENT_ID = "00000000-0000-0000-0000-000000000001" -NEW_NAME = "Demo Company Shop" - -try: - shop_client = Client.objects.get(id=SHOP_CLIENT_ID) - old_name = shop_client.name - shop_client.name = NEW_NAME - shop_client.save() - - print("Updated shop client:") - print(f" Old name: {old_name}") - print(f" New name: {shop_client.name}") - print(f" ID: {shop_client.id}") - print(f" Job count: {shop_client.jobs.count()}") -except Client.DoesNotExist: - print(f"ERROR: Shop client with ID {SHOP_CLIENT_ID} not found") - sys.exit(1) diff --git a/scripts/restore_checks/fix_shop_company.py b/scripts/restore_checks/fix_shop_company.py new file mode 100644 index 000000000..ddd61d29b --- /dev/null +++ b/scripts/restore_checks/fix_shop_company.py @@ -0,0 +1,31 @@ +#!/usr/bin/env python +"""Fix shop company name after production restore (anonymized during backup).""" + +import os +import sys + +os.environ.setdefault("DJANGO_SETTINGS_MODULE", "docketworks.settings") + +import django + +django.setup() + +from apps.company.models import Company + +SHOP_COMPANY_ID = "00000000-0000-0000-0000-000000000001" +NEW_NAME = "Demo Company Shop" + +try: + shop_company = Company.objects.get(id=SHOP_COMPANY_ID) + old_name = shop_company.name + shop_company.name = NEW_NAME + shop_company.save() + + print("Updated shop company:") + print(f" Old name: {old_name}") + print(f" New name: {shop_company.name}") + print(f" ID: {shop_company.id}") + print(f" Job count: {shop_company.jobs.count()}") +except Company.DoesNotExist: + print(f"ERROR: Shop company with ID {SHOP_COMPANY_ID} not found") + sys.exit(1) diff --git a/scripts/restore_checks/test_serializers.py b/scripts/restore_checks/test_serializers.py index cc673457f..025ec5f10 100644 --- a/scripts/restore_checks/test_serializers.py +++ b/scripts/restore_checks/test_serializers.py @@ -39,7 +39,7 @@ from django.test import RequestFactory from apps.accounts.models import Staff -from apps.client.models import Client +from apps.company.models import Company from apps.job.models import CostLine, CostSet, Job from apps.purchasing.models import PurchaseOrder, PurchaseOrderLine @@ -162,7 +162,7 @@ def test_job_serializer(self) -> Dict[str, Any]: .select_related( "contact", "created_by", - "client", + "company", "latest_estimate", "latest_quote", "latest_actual", @@ -185,7 +185,7 @@ def test_kanban_serializer(self) -> Dict[str, Any]: Job.objects.filter( status__in=["quoting", "in_progress", "ready_for_delivery"] ) - .select_related("contact", "client", "created_by") + .select_related("contact", "company", "created_by") .prefetch_related("people") ) context = KanbanService.build_serialization_context(jobs) @@ -213,10 +213,10 @@ def test_costing_serializer(self) -> Dict[str, Any]: def test_client_serializer(self) -> Dict[str, Any]: """Test ClientSerializer with sample of clients""" - from apps.client.serializers import ClientSerializer + from apps.company.serializers import ClientSerializer # Test sample of clients to avoid overwhelming output - queryset = Client.objects.all()[:500] + queryset = Company.objects.all()[:500] return self._test_serializer_batch( ClientSerializer, queryset, "ClientSerializer (Sample)" @@ -240,7 +240,7 @@ def test_purchase_order_serializer(self) -> Dict[str, Any]: .prefetch_related( Prefetch( "po_lines", - queryset=PurchaseOrderLine.objects.select_related("job__client"), + queryset=PurchaseOrderLine.objects.select_related("job__company"), to_attr="detail_lines", ) ) @@ -289,7 +289,7 @@ def run_all_tests(self, specific_serializer: str = None) -> Dict[str, Any]: "job": self.test_job_serializer, "kanban": self.test_kanban_serializer, "costing": self.test_costing_serializer, - "client": self.test_client_serializer, + "company": self.test_client_serializer, "staff": self.test_staff_serializer, "purchase_order": self.test_purchase_order_serializer, "timesheet": self.test_modern_timesheet_serializer, @@ -374,7 +374,7 @@ def main(): "--serializer", "-s", type=str, - help="Test specific serializer (job, kanban, costing, client, staff, purchase_order, timesheet)", + help="Test specific serializer (job, kanban, costing, company, staff, purchase_order, timesheet)", ) args = parser.parse_args() diff --git a/scripts/server/deploy.sh b/scripts/server/deploy.sh index 91c1b5c00..492224778 100755 --- a/scripts/server/deploy.sh +++ b/scripts/server/deploy.sh @@ -335,6 +335,13 @@ for instance in "${TARGETS[@]}"; do switch_instance_release "$instance" "$TARGET_SHA" chown -h "$inst_user:$inst_user" "$instance_dir/app" + log " Running relabel_client_app (KAN-278 one-time DB surgery)..." + if ! "$SCRIPT_DIR/dw-run.sh" "$instance" python manage.py relabel_client_app; then + log " ERROR: relabel_client_app failed for $instance — services remain stopped" + FAILED_INSTANCES+=("$instance") + continue + fi + log " Running migrate..." if "$SCRIPT_DIR/dw-run.sh" "$instance" python manage.py migrate --no-input; then log " Migration complete for $instance" diff --git a/scripts/test_full_quote_conversation.py b/scripts/test_full_quote_conversation.py index bd5e511d2..0509367aa 100644 --- a/scripts/test_full_quote_conversation.py +++ b/scripts/test_full_quote_conversation.py @@ -99,7 +99,7 @@ def main(): return 1 print(f"\nTesting with job: {job.job_number} - {job.name}") - print(f"Client: {job.client.name}") + print(f"Company: {job.company.name}") # Clear existing chat deleted = JobQuoteChat.objects.filter(job=job).delete() diff --git a/scripts/test_quote_import.py b/scripts/test_quote_import.py index b17c8d0c6..65731ac6b 100644 --- a/scripts/test_quote_import.py +++ b/scripts/test_quote_import.py @@ -22,7 +22,7 @@ from django.conf import settings -from apps.client.models import Client +from apps.company.models import Company from apps.job.models import Job from apps.job.services.import_quote_service import ( QuoteImportError, @@ -37,14 +37,14 @@ def get_or_create_test_job() -> Job: if test_job: return test_job - client = Client.objects.first() - if not client: - logging.info("Creating test client...") - client = Client.objects.create(name="Test Client", account_ref="TEST001") + company = Company.objects.first() + if not company: + logging.info("Creating test company...") + company = Company.objects.create(name="Test Company", account_ref="TEST001") test_job = Job.objects.create( name="Quote Import Test Job", - client=client, + company=company, status="quoting", description="Test job for quote import service testing", ) diff --git a/scripts/validate_restore_progress.py b/scripts/validate_restore_progress.py index fbb60f07c..758f5d69b 100644 --- a/scripts/validate_restore_progress.py +++ b/scripts/validate_restore_progress.py @@ -15,7 +15,7 @@ django.setup() from apps.accounts.models import Staff -from apps.client.models import Client +from apps.company.models import Company from apps.job.models import Job from apps.workflow.models import CompanyDefaults, XeroToken @@ -27,13 +27,13 @@ def check_basic_restore(): # Check database has data job_count = Job.objects.count() staff_count = Staff.objects.count() - client_count = Client.objects.count() + company_count = Company.objects.count() checks.append( ( "Database populated", - job_count > 0 and staff_count > 0 and client_count > 0, - f"Jobs: {job_count}, Staff: {staff_count}, Clients: {client_count}", + job_count > 0 and staff_count > 0 and company_count > 0, + f"Jobs: {job_count}, Staff: {staff_count}, Companies: {company_count}", ) ) @@ -62,15 +62,15 @@ def check_basic_restore(): ) ) - # Check shop client - shop_exists = Client.objects.filter( + # Check shop company + shop_exists = Company.objects.filter( id="00000000-0000-0000-0000-000000000001" ).exists() checks.append( ( - "Shop client exists", + "Shop company exists", shop_exists, - "Shop client found" if shop_exists else "Not found", + "Shop company found" if shop_exists else "Not found", ) ) @@ -105,14 +105,14 @@ def check_xero_config(): checks.append(("Xero tenant ID set", False, "Company defaults not found")) # Check if Xero IDs are cleared (they should be null or have new IDs) - clients_with_xero = Client.objects.filter(xero_contact_id__isnull=False).count() + companies_with_xero = Company.objects.filter(xero_contact_id__isnull=False).count() jobs_with_xero = Job.objects.filter(xero_project_id__isnull=False).count() checks.append( ( "Xero sync status", True, - f"Clients with Xero ID: {clients_with_xero}, Jobs with Xero ID: {jobs_with_xero}", + f"Companies with Xero ID: {companies_with_xero}, Jobs with Xero ID: {jobs_with_xero}", ) ) From 6b4992c8f5a978f854f5d3ba1a9cd9d4eb6c5288 Mon Sep 17 00:00:00 2001 From: Corrin Lakeland Date: Mon, 6 Jul 2026 22:54:49 +1200 Subject: [PATCH 03/73] KAN-278 refactor: rename client to company across frontend Hand-written frontend sweep matching the regenerated API client: - api.clients_* -> api.companies_*, schemas.Client* -> schemas.Company*, client_id/client_name -> company_id/company_name - File renames: CompanyLookup.vue, CreateCompanyModal.vue, CompanyDropdown.vue, InlineEditCompany.vue, useCompanyLookup.ts, companyService.ts, companyStore.ts, company-wrapper.ts - Route /crm/clients -> /crm/companies (typed-router regenerated) - data-automation-id values + Playwright specs updated in lockstep - Kept: src/api/client.ts (HTTP client), schemas.ClientContact* (backend class names unchanged until Stage B), Xero OAuth client_id, and the Step-3 persisted values (domain: 'client', source: 'client_lookup', client_match, cross_client/effective_client_id) Co-Authored-By: Claude Fable 5 --- .../src/components/AdvancedSearchDialog.vue | 16 +- frontend/src/components/AppNavbar.vue | 8 +- .../components/CompanyDefaultsFormModal.vue | 16 +- ...ClientDropdown.vue => CompanyDropdown.vue} | 54 ++-- .../{ClientLookup.vue => CompanyLookup.vue} | 154 +++++----- .../src/components/ContactSelectionModal.vue | 12 +- frontend/src/components/ContactSelector.vue | 50 ++-- ...ClientModal.vue => CreateCompanyModal.vue} | 190 ++++++------ frontend/src/components/JobCard.vue | 4 +- frontend/src/components/KanbanColumn.vue | 2 +- frontend/src/components/SectionForm.vue | 32 +- ...ntLookup.test.ts => CompanyLookup.test.ts} | 38 +-- .../components/__tests__/SectionForm.test.ts | 32 +- .../src/components/board/ActiveJobCard.vue | 2 +- .../src/components/board/WorkshopModeView.vue | 8 +- .../src/components/crm/PhoneCallTable.vue | 38 +-- .../src/components/crm/PhoneNumberManager.vue | 110 +++---- .../PhoneCallTable.recording.test.ts | 6 +- .../src/components/job/JobAttachmentsTab.vue | 10 +- .../src/components/job/JobQuotingChatTab.vue | 2 +- .../src/components/job/JobSettingsTab.vue | 280 +++++++++--------- .../JobSettingsTab.labourRate.test.ts | 10 +- .../__tests__/JobSettingsTab.urgent.test.ts | 10 +- .../src/components/kpi/KPIDayDetailsModal.vue | 2 +- .../components/kpi/KPILabourDetailsModal.vue | 2 +- .../purchasing/AddressAutocompleteInput.vue | 2 +- .../src/components/purchasing/JobSelect.vue | 6 +- .../components/purchasing/PoLinesTable.vue | 4 +- .../components/purchasing/PoSummaryCard.vue | 12 +- ...neEditClient.vue => InlineEditCompany.vue} | 72 ++--- .../timesheet/SmartTimesheetTable.vue | 12 +- .../components/timesheet/StaffDetailModal.vue | 6 +- .../components/timesheet/SummaryDrawer.vue | 2 +- .../timesheet/TimesheetJobPicker.vue | 8 +- .../timesheet/WeeklyMetricsModal.vue | 6 +- .../SmartTimesheetTable.billReset.test.ts | 4 +- .../SmartTimesheetTable.urgent.test.ts | 6 +- .../TimesheetJobPicker.urgent.test.ts | 2 +- .../components/workshop/WorkshopJobHeader.vue | 4 +- .../workshop/WorkshopJobPickerDrawer.vue | 6 +- .../workshop/WorkshopTimesheetLegacyTable.vue | 2 +- .../composables/__tests__/useErrorApi.test.ts | 2 +- .../__tests__/useOptimizedKanban.test.ts | 16 +- ...useClientLookup.ts => useCompanyLookup.ts} | 134 ++++----- .../src/composables/useContactManagement.ts | 72 ++--- frontend/src/composables/useJobAutosave.ts | 8 +- .../src/composables/useJobHeaderAutosave.ts | 40 +-- .../src/composables/useJobNotifications.ts | 2 +- .../src/composables/useOptimizedKanban.ts | 4 +- .../composables/usePickupAddressManagement.ts | 14 +- .../composables/useWorkshopTimesheetJobs.ts | 4 +- frontend/src/constants/advanced-filters.ts | 4 +- .../{client-wrapper.ts => company-wrapper.ts} | 6 +- frontend/src/pages/crm/calls.vue | 68 ++--- .../crm/{clients => companies}/(index).vue | 142 ++++----- .../pages/crm/{clients => companies}/[id].vue | 158 +++++----- frontend/src/pages/jobs/[id]/(index).vue | 46 +-- frontend/src/pages/jobs/create.vue | 104 +++---- frontend/src/pages/kanban.vue | 2 +- frontend/src/pages/purchasing/po/(index).vue | 2 +- frontend/src/pages/purchasing/po/[id].vue | 4 +- frontend/src/pages/quoting/chat.vue | 6 +- .../reports/data-quality/archived-jobs.vue | 18 +- .../reports/data-quality/duplicate-phones.vue | 2 +- frontend/src/pages/reports/job-aging.vue | 8 +- frontend/src/pages/reports/job-movement.vue | 4 +- .../src/pages/reports/job-profitability.vue | 18 +- frontend/src/pages/reports/rdti-spend.vue | 16 +- frontend/src/pages/reports/sales-forecast.vue | 12 +- frontend/src/pages/reports/wip.vue | 10 +- frontend/src/pages/schedule.vue | 10 +- frontend/src/pages/timesheets/entry.vue | 4 +- .../admin-company-defaults-service.ts | 2 +- frontend/src/services/clientService.ts | 65 ---- frontend/src/services/companyService.ts | 65 ++++ .../src/services/job-aging-report.service.ts | 6 +- frontend/src/services/job.service.ts | 12 +- frontend/src/services/kpi.service.ts | 2 +- .../services/sales-pipeline-report.service.ts | 2 +- frontend/src/services/wip-report.service.ts | 6 +- frontend/src/stores/__tests__/jobs.test.ts | 2 +- frontend/src/stores/clientStore.ts | 230 -------------- frontend/src/stores/companyStore.ts | 230 ++++++++++++++ frontend/src/stores/jobs.ts | 6 +- frontend/src/typed-router.d.ts | 20 +- frontend/src/types/job-aging.types.ts | 2 +- frontend/src/types/job-movement.types.ts | 2 +- frontend/src/types/sales-forecast.types.ts | 2 +- .../src/utils/__tests__/timesheetCalc.test.ts | 2 +- frontend/src/utils/iconRegistry.ts | 2 +- frontend/src/views/AdminArchiveJobsView.vue | 4 +- frontend/src/views/WorkshopKanbanView.vue | 2 +- frontend/src/views/WorkshopView.vue | 4 +- .../__tests__/AdminErrorView.resolved.test.ts | 2 +- .../views/__tests__/QuotingChatView.test.ts | 4 +- frontend/src/views/jobColumns.ts | 10 +- frontend/tests/company-defaults.spec.ts | 6 +- .../tests/crm/phone-call-job-link.spec.ts | 4 +- frontend/tests/fixtures/auth.ts | 18 +- frontend/tests/fixtures/helpers.ts | 56 ++-- ...ts => create-job-with-new-company.spec.ts} | 100 +++---- frontend/tests/job/create-job.spec.ts | 42 +-- frontend/tests/job/edit-job-settings.spec.ts | 84 +++--- .../tests/purchasing/pickup-address.spec.ts | 16 +- .../purchasing/supplier-alias-search.spec.ts | 42 +-- .../{clients.spec.ts => companies.spec.ts} | 78 ++--- frontend/tests/scripts/db-backup-utils.ts | 16 +- frontend/tests/scripts/e2e-reset.ts | 2 +- frontend/tests/scripts/global-teardown.ts | 2 +- .../timesheet/urgent-job-defaults.spec.ts | 26 +- 110 files changed, 1683 insertions(+), 1675 deletions(-) rename frontend/src/components/{ClientDropdown.vue => CompanyDropdown.vue} (66%) rename frontend/src/components/{ClientLookup.vue => CompanyLookup.vue} (60%) rename frontend/src/components/{CreateClientModal.vue => CreateCompanyModal.vue} (66%) rename frontend/src/components/__tests__/{ClientLookup.test.ts => CompanyLookup.test.ts} (54%) rename frontend/src/components/shared/{InlineEditClient.vue => InlineEditCompany.vue} (56%) rename frontend/src/composables/{useClientLookup.ts => useCompanyLookup.ts} (54%) rename frontend/src/constants/{client-wrapper.ts => company-wrapper.ts} (70%) rename frontend/src/pages/crm/{clients => companies}/(index).vue (58%) rename frontend/src/pages/crm/{clients => companies}/[id].vue (80%) delete mode 100644 frontend/src/services/clientService.ts create mode 100644 frontend/src/services/companyService.ts delete mode 100644 frontend/src/stores/clientStore.ts create mode 100644 frontend/src/stores/companyStore.ts rename frontend/tests/job/{create-job-with-new-client.spec.ts => create-job-with-new-company.spec.ts} (57%) rename frontend/tests/reports/{clients.spec.ts => companies.spec.ts} (55%) diff --git a/frontend/src/components/AdvancedSearchDialog.vue b/frontend/src/components/AdvancedSearchDialog.vue index c65375ba4..71d1e10ec 100644 --- a/frontend/src/components/AdvancedSearchDialog.vue +++ b/frontend/src/components/AdvancedSearchDialog.vue @@ -14,10 +14,10 @@ v-model="localFilters.q" type="text" class="w-full px-3 py-2 border border-blue-300 rounded-md focus:ring-2 focus:ring-blue-500 focus:border-transparent text-sm" - placeholder="Search job number, name, description, or client..." + placeholder="Search job number, name, description, or company..." />

- Searches across job number, name, description, and client name + Searches across job number, name, description, and company name

@@ -48,11 +48,11 @@
-
@@ -172,7 +172,7 @@ diff --git a/frontend/src/components/ClientLookup.vue b/frontend/src/components/CompanyLookup.vue similarity index 60% rename from frontend/src/components/ClientLookup.vue rename to frontend/src/components/CompanyLookup.vue index 3a270e37d..0bf5d3e5b 100644 --- a/frontend/src/components/ClientLookup.vue +++ b/frontend/src/components/CompanyLookup.vue @@ -14,7 +14,7 @@ type="text" :placeholder="placeholder" :required="required" - data-automation-id="ClientLookup-input" + data-automation-id="CompanyLookup-input" class="w-full px-3 py-2 border border-gray-300 rounded-md focus:ring-2 focus:ring-blue-500 focus:border-transparent" @input="handleInput" @focus="handleFocus" @@ -24,7 +24,7 @@ />
@@ -32,30 +32,30 @@
-
{{ client.name }}
+
{{ company.name }}
- Add new {{ supplierLookup.value ? 'supplier' : 'client' }} "{{ searchQuery }}" + Add new {{ supplierLookup.value ? 'supplier' : 'company' }} "{{ searchQuery }}"
or press Ctrl+Enter
@@ -65,7 +65,7 @@ v-if="suggestions.length === 0 && searchQuery.length >= 3 && !isLoading" class="px-4 py-2 text-gray-500 text-center" > - No clients found + No companies found
@@ -78,8 +78,10 @@ ? 'bg-green-100 text-green-800 border border-green-200' : 'bg-red-100 text-red-800 border border-red-200', ]" - :title="hasValidXeroId ? 'Client has Xero ID' : 'Client missing Xero ID'" - :data-automation-id="xeroValid ? 'ClientLookup-xero-valid' : 'ClientLookup-xero-invalid'" + :title="hasValidXeroId ? 'Company has Xero ID' : 'Company missing Xero ID'" + :data-automation-id=" + xeroValid ? 'CompanyLookup-xero-valid' : 'CompanyLookup-xero-invalid' + " v-if="!searchMode" > @@ -88,18 +90,18 @@ -
-
{{ selectedClient.name }}
-
- {{ selectedClient.email }} +
+
{{ selectedCompany.name }}
+
+ {{ selectedCompany.email }}
-
@@ -107,9 +109,9 @@ diff --git a/frontend/src/components/ContactSelectionModal.vue b/frontend/src/components/ContactSelectionModal.vue index 6bf166d80..322220a7c 100644 --- a/frontend/src/components/ContactSelectionModal.vue +++ b/frontend/src/components/ContactSelectionModal.vue @@ -7,8 +7,10 @@ Select Contact - Client: - {{ clientName || 'No client selected' }} + Company: + {{ + companyName || 'No company selected' + }} @@ -38,7 +40,7 @@ v-if="contactToDelete?.is_primary" class="text-sm text-amber-600 font-medium mb-4" > - This is the primary contact for this client. + This is the primary contact for this company.

@@ -42,13 +42,13 @@
-

Please select a client first

+

Please select a company first

(), @@ -132,20 +132,20 @@ let loadToken = 0 const handleOpenModal = async () => { debugLog('ContactSelector - handleOpenModal called:', { - clientId: props.clientId, - clientName: props.clientName, + companyId: props.companyId, + companyName: props.companyName, initialContactId: props.initialContactId, contacts: contacts.value, selectedContact: selectedContact.value, newContactForm: newContactForm.value, }) - if (!props.clientId) { - debugLog('Cannot open contact modal without client') + if (!props.companyId) { + debugLog('Cannot open contact modal without company') return } - await openModal(props.clientId, props.clientName) + await openModal(props.companyId, props.companyName) debugLog('ContactSelector - after openModal:', { isModalOpen: isModalOpen.value, contacts: contacts.value, @@ -237,22 +237,22 @@ const clearSelection = () => { const selectPrimaryContact = async () => { debugLog('ContactSelector - selectPrimaryContact called', { - clientId: props.clientId, + companyId: props.companyId, contactsLength: contacts.value.length, }) - if (!props.clientId) { - debugLog('Cannot select primary contact without client', { - clientId: props.clientId, - propsClientId: props.clientId, + if (!props.companyId) { + debugLog('Cannot select primary contact without company', { + companyId: props.companyId, + propsCompanyId: props.companyId, }) return } // Load contacts if not already loaded if (contacts.value.length === 0) { - debugLog('Loading contacts for client:', props.clientId) - await loadContactsOnly(props.clientId) + debugLog('Loading contacts for company:', props.companyId) + await loadContactsOnly(props.companyId) } // Find and select the primary contact (without closing modal) @@ -307,9 +307,9 @@ watch( ) watch( - () => [props.clientId, props.initialContactId], - async ([clientId, initialId]) => { - debugLog('ContactSelector - unified watch:', { clientId, initialId }) + () => [props.companyId, props.initialContactId], + async ([companyId, initialId]) => { + debugLog('ContactSelector - unified watch:', { companyId, initialId }) // Quando mudar o cliente: limpe estado local (sem emitir) suppressEmit.value = true @@ -318,8 +318,8 @@ watch( contacts.value = [] suppressEmit.value = false - if (!clientId) { - debugLog('Client ID vazio; nada a carregar.') + if (!companyId) { + debugLog('Company ID vazio; nada a carregar.') return } @@ -327,14 +327,14 @@ watch( const token = ++loadToken // Carrega contatos do cliente atual - await loadContactsOnly(clientId) + await loadContactsOnly(companyId) if (token !== loadToken) return // request antigo, ignora // Selection priority: // 1) If initialId provided → select that contact // 2) Else if primary contact exists → select primary // 3) Else → clear selection - // NOTE: suppressEmit=true prevents API calls during initialization/client change + // NOTE: suppressEmit=true prevents API calls during initialization/company change // (auto-selection should not trigger saves - only user actions should) const decideAndSelect = () => { const choose = diff --git a/frontend/src/components/CreateClientModal.vue b/frontend/src/components/CreateCompanyModal.vue similarity index 66% rename from frontend/src/components/CreateClientModal.vue rename to frontend/src/components/CreateCompanyModal.vue index 49791553c..c528e288b 100644 --- a/frontend/src/components/CreateClientModal.vue +++ b/frontend/src/components/CreateCompanyModal.vue @@ -2,12 +2,12 @@ - {{ editMode ? 'Edit Client' : 'Add New Client' }} + {{ editMode ? 'Edit Company' : 'Add New Company' }} {{ editMode - ? 'Update client information. All fields except name are optional.' - : 'Create a new client. All fields except name are optional.' + ? 'Update company information. All fields except name are optional.' + : 'Create a new company. All fields except name are optional.' }} @@ -18,12 +18,12 @@
-

Error creating client

+

Error creating company

{{ errorMessage }}

-
- Existing client in Xero: {{ duplicateClientInfo.name }} +
+ Existing company in Xero: {{ duplicateCompanyInfo.name }} Xero @@ -33,22 +33,22 @@
-
-
-