diff --git a/.env.example b/.env.example index 427a16e54..eb8105ec1 100644 --- a/.env.example +++ b/.env.example @@ -43,9 +43,9 @@ DJANGO_SITE_DOMAIN=your-domain.ngrok-free.app # Xero Integration XERO_DEFAULT_USER_ID=YOUR_XERO_USER_ID_FOR_TIME_ENTRIES -XERO_SYNC_PROJECTS=False # Suppress all Xero writes for this process (E2E/test backends only; reads stay live) XERO_READONLY=False +XERO_SYNC_PROJECTS=False # JWT Configuration ENABLE_JWT_AUTH=True diff --git a/.env.precommit b/.env.precommit index 2620d4071..2dd98f941 100644 --- a/.env.precommit +++ b/.env.precommit @@ -16,8 +16,8 @@ REDIS_HOST=localhost REDIS_PORT=6379 XERO_CLIENT_ID=dummy XERO_CLIENT_SECRET=dummy -XERO_REDIRECT_URI=http://localhost XERO_DEFAULT_USER_ID=dummy +XERO_REDIRECT_URI=http://localhost EMAIL_HOST=localhost EMAIL_PORT=587 EMAIL_USE_TLS=false @@ -33,8 +33,8 @@ SKIP_VERSION_CHECK=True ALLOWED_HOSTS=localhost,127.0.0.1 DJANGO_SITE_DOMAIN=localhost APP_DOMAIN=localhost -XERO_SYNC_PROJECTS=False XERO_READONLY=False +XERO_SYNC_PROJECTS=False XERO_WEBHOOK_KEY=dummy-webhook-key CORS_ALLOWED_ORIGINS=http://localhost:3000 ENABLE_JWT_AUTH=False diff --git a/.vscode/tasks.json b/.vscode/tasks.json index a6ab6f612..b90360605 100644 --- a/.vscode/tasks.json +++ b/.vscode/tasks.json @@ -120,7 +120,7 @@ } }, { - "label": "Start Hotfix Environment", + "label": "Start Dev Environment", "dependsOn": [ "Frontend Dev Server", "Frontend Manual Dev Server", diff --git a/CLAUDE.md b/CLAUDE.md index 7b96a3ff9..fdd93400e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -25,6 +25,10 @@ Examples: - If asked whether a bug is fixed, verify the behavior that matters to the user, not only that a unit test passed. - If asked to review a proposal, assess whether it solves the problem and what risks remain, not only whether the text is internally consistent. +## No feature removal + +Replacing or rewriting any component, page, model, or endpoint requires a **Feature Parity Inventory** first: enumerate every capability the old version exposed (buttons, actions, fields, shortcuts, edge cases — sourced from its template/emits, its tests, E2E specs, ADRs) with a keep/drop/defer decision for each. Default is keep; dropping a feature needs explicit user sign-off. A silently dropped capability is a release-blocking regression. Plans for such work must carry the inventory as a section (see the template in `docs/plans/`). + ## Tokens are precious Every single line in CLAUDE.md will make agents worse at unrelated tasks. Every single word must have significant lasting benefit or it must not be added. Do not repeat yourself, do not add lines even if you screw up, if it is unlikely a similar screw up will happen again. Always give the most general fix, to increase the likelihood the guidence is future-directed. @@ -51,7 +55,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 +69,11 @@ 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) +Person → Job (1:many) +Person → PhoneCallRecord (1:many) +Company → CompanyPersonLink → Person +Company/Person → ContactMethod (1:many, exactly one owner) ``` **Key Design Patterns:** @@ -83,7 +91,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 2789339bc..08ecf0a4d 100644 --- a/apps/accounting/migrations/0001_baseline.py +++ b/apps/accounting/migrations/0001_baseline.py @@ -12,18 +12,8 @@ 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"), + ("company", "0001_baseline"), ] operations = [ @@ -424,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 0c37215ac..14b78fbc6 100644 --- a/apps/accounting/migrations/0002_baseline.py +++ b/apps/accounting/migrations/0002_baseline.py @@ -8,13 +8,9 @@ class Migration(migrations.Migration): initial = True - replaces = [ - ("accounting", "0008_normalize_xero_raw_json_strings"), - ] - dependencies = [ ("accounting", "0001_baseline"), - ("client", "0001_baseline"), + ("company", "0001_baseline"), ("job", "0001_baseline"), ("workflow", "0001_baseline"), ] @@ -43,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( @@ -69,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( @@ -106,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_payroll_reconciliation_service.py b/apps/accounting/tests/test_payroll_reconciliation_service.py index d8e653993..ccdccd766 100644 --- a/apps/accounting/tests/test_payroll_reconciliation_service.py +++ b/apps/accounting/tests/test_payroll_reconciliation_service.py @@ -18,35 +18,36 @@ class GetAlignedDateRangeTests(BaseTestCase): def _set_payroll_start(self, payroll_start: date | None) -> None: defaults = CompanyDefaults.get_solo() - defaults.xero_payroll_start_date = payroll_start - defaults.save() + CompanyDefaults.objects.filter(pk=defaults.pk).update( + xero_payroll_start_date=payroll_start + ) def _aligned(self, start: date, end: date) -> tuple[date, date]: result = PayrollReconciliationService.get_aligned_date_range(start, end) return result["aligned_start"], result["aligned_end"] - def test_midweek_dates_snap_to_monday_and_sunday(self): + def test_midweek_dates_snap_to_monday_and_sunday(self) -> None: self._set_payroll_start(None) - # Tuesday 2025-04-01 → Monday 2025-03-31; Tuesday 2026-03-31 → Sunday 2026-04-05 + # Tuesday 2025-04-01 -> Monday 2025-03-31; Tuesday 2026-03-31 -> Sunday 2026-04-05 aligned_start, aligned_end = self._aligned(date(2025, 4, 1), date(2026, 3, 31)) self.assertEqual(aligned_start, date(2025, 3, 31)) self.assertEqual(aligned_end, date(2026, 4, 5)) - def test_already_aligned_dates_are_unchanged(self): + def test_already_aligned_dates_are_unchanged(self) -> None: self._set_payroll_start(None) # Monday 2025-03-31 and Sunday 2026-04-05 are already week boundaries aligned_start, aligned_end = self._aligned(date(2025, 3, 31), date(2026, 4, 5)) self.assertEqual(aligned_start, date(2025, 3, 31)) self.assertEqual(aligned_end, date(2026, 4, 5)) - def test_single_day_expands_to_its_full_week(self): + def test_single_day_expands_to_its_full_week(self) -> None: self._set_payroll_start(None) - # Thursday 2025-04-03 → the whole Mon–Sun week containing it + # Thursday 2025-04-03 -> the whole Mon-Sun week containing it aligned_start, aligned_end = self._aligned(date(2025, 4, 3), date(2025, 4, 3)) self.assertEqual(aligned_start, date(2025, 3, 31)) self.assertEqual(aligned_end, date(2025, 4, 6)) - def test_start_clamps_to_payroll_start_before_snapping(self): + def test_start_clamps_to_payroll_start_before_snapping(self) -> None: # Friday 2025-08-01: requests starting earlier clamp to it, then # snap to the Monday of its week. self._set_payroll_start(date(2025, 8, 1)) @@ -54,18 +55,18 @@ def test_start_clamps_to_payroll_start_before_snapping(self): self.assertEqual(aligned_start, date(2025, 7, 28)) self.assertEqual(aligned_end, date(2026, 4, 5)) - def test_start_after_payroll_start_is_not_clamped(self): + def test_start_after_payroll_start_is_not_clamped(self) -> None: self._set_payroll_start(date(2025, 8, 1)) - # Tuesday 2025-09-02 is after the payroll start → normal Monday snap + # Tuesday 2025-09-02 is after the payroll start -> normal Monday snap aligned_start, _ = self._aligned(date(2025, 9, 2), date(2026, 3, 31)) self.assertEqual(aligned_start, date(2025, 9, 1)) - def test_payroll_start_on_a_monday_clamps_exactly_to_it(self): + def test_payroll_start_on_a_monday_clamps_exactly_to_it(self) -> None: self._set_payroll_start(date(2025, 8, 4)) # a Monday aligned_start, _ = self._aligned(date(2025, 4, 1), date(2026, 3, 31)) self.assertEqual(aligned_start, date(2025, 8, 4)) - def test_end_date_is_never_clamped_by_payroll_start(self): + def test_end_date_is_never_clamped_by_payroll_start(self) -> None: # The clamp applies to the start only; an end before payroll start # still snaps to its own week's Sunday (yielding an empty range, # which the report handles, rather than a silently rewritten end). diff --git a/apps/accounting/tests/test_sales_pipeline_service.py b/apps/accounting/tests/test_sales_pipeline_service.py index df20ef5e9..96b62b3b9 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,8 +61,8 @@ 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", - "contact_name": None, + "company_name": company.name if company else "Shop Job", + "person_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/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/__init__.py b/apps/client/__init__.py deleted file mode 100644 index 39f2499fe..000000000 --- a/apps/client/__init__.py +++ /dev/null @@ -1,86 +0,0 @@ -# This file is autogenerated by update_init.py script - -from .apps import ClientConfig -from .utils import date_to_datetime - -# Conditional imports (only when Django is ready) -try: - from django.apps import apps - - if apps.ready: - from .models import ( - Client, - ClientContact, - ClientContactMethod, - ClientQuerySet, - PhoneAssignmentConflictError, - Supplier, - SupplierPickupAddress, - SupplierSearchAlias, - ) - from .serializers import ( - ClientContactMethodSerializer, - ClientContactSerializer, - ClientCreateResponseSerializer, - ClientCreateSerializer, - ClientDetailResponseSerializer, - ClientDuplicateErrorResponseSerializer, - ClientErrorResponseSerializer, - ClientJobHeaderSerializer, - ClientJobsResponseSerializer, - ClientListResponseSerializer, - ClientNameOnlySerializer, - ClientSearchResponseSerializer, - ClientSearchResultSerializer, - ClientSerializer, - ClientUpdateResponseSerializer, - ClientUpdateSerializer, - JobContactBaseSerializer, - JobContactResponseSerializer, - JobContactUpdateSerializer, - StandardErrorSerializer, - SupplierPickupAddressSerializer, - SupplierSearchAliasCreateSerializer, - SupplierSearchAliasSerializer, - set_primary_phone, - ) -except (ImportError, RuntimeError): - # Django not ready or circular import, skip conditional imports - pass - -__all__ = [ - "Client", - "ClientConfig", - "ClientContact", - "ClientContactMethod", - "ClientContactMethodSerializer", - "ClientContactSerializer", - "ClientCreateResponseSerializer", - "ClientCreateSerializer", - "ClientDetailResponseSerializer", - "ClientDuplicateErrorResponseSerializer", - "ClientErrorResponseSerializer", - "ClientJobHeaderSerializer", - "ClientJobsResponseSerializer", - "ClientListResponseSerializer", - "ClientNameOnlySerializer", - "ClientQuerySet", - "ClientSearchResponseSerializer", - "ClientSearchResultSerializer", - "ClientSerializer", - "ClientUpdateResponseSerializer", - "ClientUpdateSerializer", - "JobContactBaseSerializer", - "JobContactResponseSerializer", - "JobContactUpdateSerializer", - "PhoneAssignmentConflictError", - "StandardErrorSerializer", - "Supplier", - "SupplierPickupAddress", - "SupplierPickupAddressSerializer", - "SupplierSearchAlias", - "SupplierSearchAliasCreateSerializer", - "SupplierSearchAliasSerializer", - "date_to_datetime", - "set_primary_phone", -] 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/services/__init__.py b/apps/client/services/__init__.py deleted file mode 100644 index e9e28a705..000000000 --- a/apps/client/services/__init__.py +++ /dev/null @@ -1,42 +0,0 @@ -# This file is autogenerated by update_init.py script - -# Conditional imports (only when Django is ready) -try: - from django.apps import apps - - if apps.ready: - from .client_merge_service import reassign_client_fk_records - from .client_rest_service import ClientRestService, _ClientPhoneAnnotations - from .duplicate_phone_report import ( - DuplicatePhoneIssue, - DuplicatePhoneOwner, - DuplicatePhoneReportService, - DuplicatePhoneSummary, - DuplicatePhonesReport, - ) - from .geocoding_service import ( - GeocodingError, - GeocodingNotConfiguredError, - GeocodingResult, - geocode_address, - get_api_key, - ) -except (ImportError, RuntimeError): - # Django not ready or circular import, skip conditional imports - pass - -__all__ = [ - "ClientRestService", - "DuplicatePhoneIssue", - "DuplicatePhoneOwner", - "DuplicatePhoneReportService", - "DuplicatePhoneSummary", - "DuplicatePhonesReport", - "GeocodingError", - "GeocodingNotConfiguredError", - "GeocodingResult", - "_ClientPhoneAnnotations", - "geocode_address", - "get_api_key", - "reassign_client_fk_records", -] diff --git a/apps/client/services/client_merge_service.py b/apps/client/services/client_merge_service.py deleted file mode 100644 index c98d782ed..000000000 --- a/apps/client/services/client_merge_service.py +++ /dev/null @@ -1,252 +0,0 @@ -""" -Reassign FK records from one Client (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 -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. -""" - -import logging - -from django.db import transaction -from django.utils import timezone - -from apps.client.models import Client, ClientContact, ClientContactMethod -from apps.workflow.services.error_persistence import persist_app_error - -logger = logging.getLogger("xero") - - -def _move_contact_methods( - *, - source_contact: ClientContact | None, - source_client: Client | None, - destination_contact: ClientContact | None, - destination_client: Client | None, -) -> int: - if source_contact is None: - source_queryset = ClientContactMethod.objects.filter( - client=source_client, - contact__isnull=True, - ) - destination_queryset = ClientContactMethod.objects.filter( - client=destination_client, - contact__isnull=True, - ) - else: - source_queryset = ClientContactMethod.objects.filter(contact=source_contact) - destination_queryset = ClientContactMethod.objects.filter( - contact=destination_contact - ) - - affected = 0 - - # Drop source methods the destination already owns (same method_type + - # normalized_value), respecting the per-owner unique constraints. - destination_pairs = set( - destination_queryset.values_list("method_type", "normalized_value") - ) - duplicate_ids = [ - method_id - for method_id, method_type, normalized_value in source_queryset.values_list( - "id", "method_type", "normalized_value" - ) - if (method_type, normalized_value) in destination_pairs - ] - if duplicate_ids: - ClientContactMethod.objects.filter(id__in=duplicate_ids).delete() - affected += len(duplicate_ids) - - # A moving primary method wins over the destination's existing primary of - # the same method_type (mirrors ClientContactMethod.save()'s demotion), - # keeping the partial unique constraints on (owner, method_type, is_primary) - # satisfied. - moving_primary_types = list( - source_queryset.filter(is_primary=True).values_list("method_type", flat=True) - ) - if moving_primary_types: - destination_queryset.filter( - method_type__in=moving_primary_types, - is_primary=True, - ).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 - # 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, - contact=None, - updated_at=timezone.now(), - ) - else: - affected += source_queryset.update( - client=None, - contact=destination_contact, - updated_at=timezone.now(), - ) - return affected - - -def _move_client_contacts_and_methods( - source: Client, destination: Client -) -> dict[str, int]: - """Move CRM contact ownership before the source client is deleted.""" - - from apps.crm.models import PhoneCallRecord - - counts = { - "contacts": 0, - "contact_methods": 0, - "phone_calls": 0, - } - - counts["contact_methods"] += _move_contact_methods( - source_contact=None, - source_client=source, - destination_contact=None, - destination_client=destination, - ) - - destination_contacts_by_name = { - existing.name: existing - for existing in ClientContact.objects.filter(client=destination) - } - for contact in ClientContact.objects.filter(client=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"]) - destination_contact = contact - else: - counts["contact_methods"] += _move_contact_methods( - source_contact=contact, - source_client=None, - destination_contact=destination_contact, - destination_client=None, - ) - counts["phone_calls"] += PhoneCallRecord.objects.filter( - contact=contact - ).update( - client=destination, - contact=destination_contact, - ) - contact.delete() - - counts["contacts"] += 1 - - counts["phone_calls"] += PhoneCallRecord.objects.filter(client=source).update( - client=destination - ) - return counts - - -def reassign_client_fk_records( - source: Client, - destination: Client, - staff, - *, - logger_prefix: str = "", -) -> dict[str, int]: - """ - Move every client-referencing FK record from ``source`` to ``destination``. - - Returns a dict of per-model rowcounts, e.g. ``{"jobs": 3, ...}``. - - Job records are iterated and saved so JobEvents are generated. All other - tables use bulk ``.update()``. - - Args: - staff: Staff to attribute the JobEvents to. Xero-sync callers should - pass ``Staff.get_automation_user()``. - - Raises: - ValueError: if ``destination == source`` (the service refuses this - no-op because it almost certainly indicates a caller bug, e.g. - a chain-walk that terminated at a cycle). - """ - 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" - ) - - # Late imports to avoid circular-import at Django app-loading time. - from apps.accounting.models import Bill, CreditNote, Invoice, Quote - from apps.job.models import Job - from apps.purchasing.models import PurchaseOrder - from apps.quoting.models import ScrapeJob, SupplierPriceList, SupplierProduct - - 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"]) - jobs_moved += 1 - - crm_counts = _move_client_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 - ), - "bills": Bill.objects.filter(client=source).update(client=destination), - "credit_notes": CreditNote.objects.filter(client=source).update( - client=destination - ), - "quotes": Quote.objects.filter(client=source).update( - client=destination - ), - "purchase_orders": PurchaseOrder.objects.filter(supplier=source).update( - supplier=destination - ), - "supplier_products": SupplierProduct.objects.filter( - supplier=source - ).update(supplier=destination), - "supplier_price_lists": SupplierPriceList.objects.filter( - supplier=source - ).update(supplier=destination), - "scrape_jobs": ScrapeJob.objects.filter(supplier=source).update( - supplier=destination - ), - } - - logger.info( - "%sReassigned client %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", - logger_prefix, - source.id, - destination.id, - counts["jobs"], - counts["contacts"], - counts["contact_methods"], - counts["phone_calls"], - counts["invoices"], - counts["bills"], - counts["credit_notes"], - counts["quotes"], - counts["purchase_orders"], - counts["supplier_products"], - counts["supplier_price_lists"], - counts["scrape_jobs"], - ) - return counts - - except Exception as exc: - persist_app_error(exc) - raise diff --git a/apps/client/services/client_rest_service.py b/apps/client/services/client_rest_service.py deleted file mode 100644 index 992dee42a..000000000 --- a/apps/client/services/client_rest_service.py +++ /dev/null @@ -1,1180 +0,0 @@ -""" -Client REST Service Layer - -Following SRP (Single Responsibility Principle) and clean code guidelines. -All business logic for Client REST operations should be implemented here. -""" - -import json -import logging -import re -from typing import TYPE_CHECKING, Any, Dict, List, Optional, TypedDict -from uuid import UUID, uuid4 - -if TYPE_CHECKING: - from django_stubs_ext import WithAnnotations - - from apps.accounts.models import Staff - -from django.core.exceptions import ValidationError as DjangoValidationError -from django.db import transaction -from django.db.models import Case, IntegerField, Q, When -from django.http import HttpRequest -from django.shortcuts import get_object_or_404 -from django.utils import timezone - -from apps.client.models import ( - PRIMARY_PHONE_ORDERING, - Client, - ClientContact, - ClientContactMethod, -) -from apps.client.serializers import ( - ClientCreateSerializer, - ClientUpdateSerializer, - set_primary_phone, -) -from apps.client.utils import date_to_datetime -from apps.crm.tasks import rematch_phone_calls_task -from apps.workflow.accounting.registry import get_provider -from apps.workflow.exceptions import AlreadyLoggedException -from apps.workflow.services.error_persistence import ( - persist_and_raise, - persist_app_error, -) -from apps.workflow.services.search_telemetry import SearchTelemetryService - -CLIENT_SEARCH_TOKEN_RE = re.compile(r"[a-z0-9]+") - - -class _ClientPhoneAnnotations(TypedDict): - """Queryset annotation required by _format_client_summary and - _format_client_detail; not a Client model field.""" - - phone: str - - -if TYPE_CHECKING: - # Evaluated only by the type checker (annotation is quoted at use site), so - # the dev-only django_stubs_ext dependency is never imported at runtime. - _AnnotatedClientWithPhone = WithAnnotations[Client, _ClientPhoneAnnotations] - -logger = logging.getLogger(__name__) -client_search_logger = logging.getLogger("client_search") - - -class ClientRestService: - """ - Service layer for Client REST operations. - Implements all business rules related to Client manipulation via REST API. - """ - - @staticmethod - def get_all_clients() -> List[Dict[str, Any]]: - """ - Retrieves all clients with basic information for dropdowns. - - Returns: - List of client dictionaries with id and name - """ - try: - clients = Client.objects.all().order_by("name") - return [ - { - "id": str(client.id), - "name": client.name, - } - for client in clients - ] - except AlreadyLoggedException: - raise - except Exception as exc: - persist_and_raise(exc) - - @staticmethod - def search_clients(query: str, limit: int = 10) -> List[Dict[str, Any]]: - """ - Searches clients 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 - - Raises: - ValueError: If query is too short - """ - try: - # Guard clause - validate query length - if not query or len(query.strip()) < 3: - return [] - - # Sanitize and limit - query = query.strip() - limit = max(1, min(limit, 50)) - - # Execute optimized search - clients = ClientRestService._execute_client_search(query, limit) - return ClientRestService._format_client_search_results(clients) - - except AlreadyLoggedException: - raise - except Exception as exc: - persist_and_raise(exc, additional_context={"query": query, "limit": limit}) - - @staticmethod - def list_clients( - query: str | None = None, - page: int = 1, - page_size: int = 50, - sort_by: str = "name", - sort_dir: str = "asc", - ) -> Dict[str, Any]: - """ - Lists clients with pagination, sorting, and optional search. - - Args: - query: Optional search query (min 3 chars for filtering) - page: Page number (1-indexed) - page_size: Results per page - sort_by: Field to sort by - sort_dir: Sort direction ('asc' or 'desc') - - Returns: - Dict with results, count, page, page_size, total_pages - """ - try: - # Validate sort field - whitelist allowed fields - allowed_sort_fields = { - "name": "name", - "email": "email", - "is_account_customer": "is_account_customer", - "last_invoice_date": "last_invoice_date", - "total_spend": "total_spend", - } - sort_field = allowed_sort_fields.get(sort_by, "name") - - # Build ordering - if sort_dir.lower() == "desc": - sort_field = f"-{sort_field}" - - # Annotate computed fields for sorting capability - queryset = ( - Client.objects.with_invoice_summary() - .defer("raw_json") - .annotate( - phone=ClientContactMethod.primary_phone_annotation( - owner="client", outer_ref="pk" - ) - ) - ) - - # Apply search filter if query provided - if query: - ranked_ids = ClientRestService._rank_matching_client_ids( - Client.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) - else: - ordering = (sort_field,) - # Get total count before pagination - total_count = queryset.count() - - # Apply sorting and pagination - offset = (page - 1) * page_size - clients = 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), - "count": total_count, - "page": page, - "page_size": page_size, - "total_pages": total_pages, - } - - except AlreadyLoggedException: - raise - except Exception as exc: - persist_and_raise( - exc, - additional_context={ - "query": query, - "page": page, - "page_size": page_size, - }, - ) - - @staticmethod - def get_client_by_id(client_id: UUID) -> Dict[str, Any]: - """ - Retrieves a specific client by ID with full details. - - Args: - client_id: Client UUID - - Returns: - Dict with complete client information - - Raises: - ValueError: If client not found - """ - try: - client = ( - Client.objects.with_invoice_summary() - .annotate( - phone=ClientContactMethod.primary_phone_annotation( - owner="client", outer_ref="pk" - ) - ) - .get(id=client_id) - ) - return ClientRestService._format_client_detail(client) - except Client.DoesNotExist: - raise ValueError(f"Client with id {client_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), - }, - ) - - @staticmethod - def create_client(data: Dict[str, Any]) -> Client: - """ - Creates a new client locally and in the accounting provider. - - Args: - data: Client creation data - - Returns: - Created Client instance - - Raises: - ValueError: If validation fails or accounting provider sync fails - """ - try: - # Validate using DRF serializer - serializer = ClientCreateSerializer(data=data) - if not serializer.is_valid(): - error_messages = [] - for field, errors in serializer.errors.items(): - error_messages.extend([f"{field}: {e}" for e in errors]) - raise ValueError("; ".join(error_messages)) - - # Check accounting provider authentication - provider = get_provider() - token = provider.get_valid_token() - if not token: - raise ValueError("Accounting provider authentication required") - - # Create in Xero first - client = ClientRestService._create_client_in_xero(serializer.validated_data) - return client - - except AlreadyLoggedException: - raise - except Exception as exc: - persist_and_raise( - exc, - additional_context={ - "operation": "create_client", - "payload_keys": list(data.keys()), - }, - ) - - @staticmethod - def update_client(client_id: UUID, data: Dict[str, Any]) -> Client: - """ - Updates an existing client. - If client is synced with Xero, updates Xero first then syncs locally. - - Args: - client_id: Client UUID - data: Updated client data - - Returns: - Updated Client instance - - Raises: - ValueError: If client not found or validation fails - """ - client = Client.objects.filter(id=client_id).first() - if client is None: - raise ValueError(f"Client with id {client_id} not found") - - # Store xero_contact_id before validation - original_xero_contact_id = client.xero_contact_id - - # Validate using DRF serializer - serializer = ClientUpdateSerializer(data=data) - if not serializer.is_valid(): - error_messages = [] - for field, errors in serializer.errors.items(): - error_messages.extend([f"{field}: {e}" for e in errors]) - raise ValueError("; ".join(error_messages)) - - validated_data = serializer.validated_data - # Stored as the client's primary ClientContactMethod, not a Client - # field, so it never reaches the generic setattr loop below. - phone_supplied = "phone" in validated_data - phone = validated_data.pop("phone", None) - - # Guard clause - validate required fields - if not validated_data.get("name") and not client.name: - raise ValueError("Client name is required") - - # DEBUG: Log client state after validation - logger.info( - f"Client data after validation: xero_contact_id={original_xero_contact_id}", - extra={ - "client_id": str(client.id), - "original_xero_contact_id": original_xero_contact_id, - "operation": "update_client_debug_after_validation", - }, - ) - - # Check if client is synced with Xero - if original_xero_contact_id: - # Update in Xero first, then sync locally - updated_client = ClientRestService._update_client_in_xero( - client, - validated_data, - phone_supplied=phone_supplied, - raw_phone=phone, - ) - logger.info( - f"Client {updated_client.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", - }, - ) - else: - # Local-only update for clients 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() - - ClientRestService._apply_client_phone_change( - client, - phone_supplied=phone_supplied, - raw_phone=phone, - ) - - logger.info( - f"Client {client.id} updated locally (no Xero sync)", - extra={ - "client_id": str(client.id), - "client_name": client.name, - "operation": "update_client_local_only", - }, - ) - updated_client = client - - # The response's phone field always reads from a queryset annotation; - # refetch through it, restoring the with_invoice_summary() aggregates - # _format_client_detail needs. - return ( - Client.objects.with_invoice_summary() - .annotate( - phone=ClientContactMethod.primary_phone_annotation( - owner="client", outer_ref="pk" - ) - ) - .get(id=updated_client.id) - ) - - @staticmethod - def get_client_contacts(client_id: UUID) -> List[Dict[str, Any]]: - """ - Retrieves all contacts for a specific client. - - Args: - client_id: Client UUID - - Returns: - List of contact dictionaries - - Raises: - ValueError: If client not found - """ - try: - client = get_object_or_404(Client, id=client_id) - contacts = client.contacts.all().order_by("name") - - return [ - { - "id": str(contact.id), - "name": contact.name, - "email": contact.email, - "position": contact.position, - "is_primary": contact.is_primary, - } - for contact in contacts - ] - - except AlreadyLoggedException: - raise - except Exception as exc: - persist_and_raise( - exc, - additional_context={ - "operation": "get_client_contacts", - "client_id": str(client_id), - }, - ) - - @staticmethod - def get_job_contact(job_id: UUID) -> Dict[str, Any]: - """ - Retrieves contact information for a specific job. - - Args: - job_id: Job UUID - - Returns: - Dict with contact information - - Raises: - ValueError: If job not found or no contact associated - """ - # Import here to avoid circular imports - from apps.job.models import Job - - try: - job = Job.objects.select_related("contact").get(id=job_id) - except Job.DoesNotExist: - raise ValueError(f"Job with id {job_id} not found") - except AlreadyLoggedException: - raise - except Exception as exc: - persist_and_raise( - exc, - additional_context={ - "operation": "get_job_contact", - "job_id": str(job_id), - }, - ) - - if not job.contact: - # Documented business validation failure should not be persisted - raise ValueError(f"No contact associated with job {job_id}") - - contact = job.contact - try: - return { - "id": str(contact.id), - "name": contact.name, - "email": contact.email, - "position": contact.position, - "is_primary": contact.is_primary, - "notes": contact.notes, - } - except AlreadyLoggedException: - raise - except Exception as exc: - persist_and_raise( - exc, - additional_context={ - "operation": "serialize_job_contact", - "job_id": str(job_id), - }, - ) - - @staticmethod - def update_job_contact( - job_id: UUID, contact_data: Dict[str, Any], user: "Staff" - ) -> Dict[str, Any]: - """ - Updates the contact person for a specific job. - - Args: - job_id: Job UUID - contact_data: Contact data to update - user: Staff performing the update - - Returns: - Dict with updated contact information - - Raises: - ValueError: If job not found, contact not found, or validation fails - """ - try: - # Import here to avoid circular imports - from apps.job.models import Job - - try: - job = Job.objects.select_related("client", "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 - contact_id = contact_data.get("id") - if not contact_id: - raise ValueError("Contact ID is required") - - try: - contact = ClientContact.objects.get(id=contact_id) - 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") - - # Update job's contact - job.contact = contact - job.save(staff=user) - - logger.info( - f"Contact {contact_id} assigned to job {job_id}", - extra={ - "job_id": str(job_id), - "contact_id": str(contact_id), - "client_id": str(job.client_id), - "operation": "update_job_contact", - }, - ) - - return { - "id": str(contact.id), - "name": contact.name, - "email": contact.email, - "position": contact.position, - "is_primary": contact.is_primary, - "notes": contact.notes, - } - - except AlreadyLoggedException: - raise - except Exception as exc: - persist_and_raise( - exc, - additional_context={ - "operation": "update_job_contact", - "job_id": str(job_id), - "contact_id": contact_data.get("id"), - }, - ) - - @staticmethod - def _execute_client_search(query: str, limit: int): - """ - Executes client search with appropriate filters and annotations. - """ - ranked_ids = ClientRestService._rank_matching_client_ids( - Client.objects.filter(allow_jobs=True), query - ) - return ClientRestService._hydrate_client_search_results(ranked_ids[:limit]) - - @staticmethod - def _hydrate_client_search_results(client_ids): - if not client_ids: - return [] - - ordering = Case( - *[ - When(id=client_id, then=position) - for position, client_id in enumerate(client_ids) - ], - output_field=IntegerField(), - ) - return list( - Client.objects.with_invoice_summary() - .defer("raw_json") # Not needed for search results - .annotate( - phone=ClientContactMethod.primary_phone_annotation( - owner="client", outer_ref="pk" - ) - ) - .only( - "id", - "name", - "email", - "address", - "is_account_customer", - "is_supplier", - "allow_jobs", - "xero_contact_id", - ) - .filter(id__in=client_ids) - .order_by(ordering) - ) - - @staticmethod - def _rank_matching_client_ids(queryset, query: str): - tokens = ClientRestService._client_search_tokens(query) - if not tokens: - return [] - - candidate_filter = ClientRestService._client_name_candidate_filter(tokens) - candidates = queryset.filter(candidate_filter).values_list("id", "name") - - ranked = [ - ( - ClientRestService._client_name_score(name, query, tokens), - client_id, - ) - for client_id, name in candidates.iterator() - if ClientRestService._client_name_matches(name, tokens) - ] - ranked.sort(key=lambda item: item[0]) - return [client_id for _, client_id in ranked] - - @staticmethod - def _client_search_tokens(query: str) -> list[str]: - return CLIENT_SEARCH_TOKEN_RE.findall(query.lower()) - - @staticmethod - def _normalized_client_search_text(value: str) -> str: - return " ".join(CLIENT_SEARCH_TOKEN_RE.findall(value.lower())) - - @staticmethod - def _client_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) - 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) - - if normalized_name == normalized_query: - tier = 0 - elif normalized_name.startswith(normalized_query): - tier = 1 - elif normalized_query in normalized_name: - tier = 2 - else: - tier = 3 - - token_scores = [ - ClientRestService._client_token_match_score(token, name_tokens) - for token in tokens - ] - positions = [normalized_name.find(token) for token in tokens] - ordered_penalty = 0 if positions == sorted(positions) else 1 - return ( - tier, - max(token_scores), - sum(token_scores), - sum(positions), - ordered_penalty, - len(normalized_name), - normalized_name, - ) - - @staticmethod - def _client_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): - return 1 - 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 - ) - clients = ClientRestService._hydrate_client_search_results(ranked_ids[:limit]) - tokens = ClientRestService._client_search_tokens(query) - return [ - ClientRestService._client_search_log_result( - rank=index + 1, - result=client, - query=query, - tokens=tokens, - ) - for index, client in enumerate(clients) - ] - - @staticmethod - def log_client_search_results( - *, - request: Optional[HttpRequest], - source: str, - query: str, - clients, - total_count: int, - ) -> None: - if len(query.strip()) < 3: - return - - tokens = ClientRestService._client_search_tokens(query) - user = getattr(request, "user", None) if request else None - payload = { - "event": "client_search_results", - "search_id": str(uuid4()), - "source": source, - "query": query, - "path": getattr(request, "path", None), - "query_string": ( - request.META.get("QUERY_STRING", "") if request is not None else "" - ), - "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), - "results": [ - ClientRestService._client_search_log_result( - rank=index + 1, - result=client, - query=query, - tokens=tokens, - ) - for index, client in enumerate(clients) - ], - } - client_search_logger.info(json.dumps(payload, sort_keys=True, default=str)) - SearchTelemetryService.log_search( - request=request, - domain="client", - source=source, - query=query, - result_count=total_count, - returned_result_ids=[ - result["id"] if isinstance(result, dict) else result.id - for result in clients - ], - metadata={"results": payload["results"][:100]}, - ) - - @staticmethod - def log_client_search_click( - *, - request: Optional[HttpRequest], - source: str, - query: str, - client_id, - rank: Optional[int], - ) -> None: - client = Client.objects.only("id", "name").get(id=client_id) - user = getattr(request, "user", None) if request else None - payload = { - "event": "client_search_click", - "search_id": str(uuid4()), - "source": source, - "query": query, - "path": getattr(request, "path", None), - "query_string": ( - request.META.get("QUERY_STRING", "") if request is not None else "" - ), - "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, - "rank": rank, - } - client_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_rank=rank, - metadata={"client_name": client.name}, - ) - - @staticmethod - def _client_search_log_result( - *, - rank: int, - result: Client | Dict[str, Any], - query: str, - tokens: list[str], - ) -> Dict[str, Any]: - if isinstance(result, dict): - client_id = result["id"] - client_name = result["name"] - else: - client_id = str(result.id) - client_name = result.name - - name_tokens = ClientRestService._client_search_tokens(client_name) - return { - "rank": rank, - "client_id": client_id, - "client_name": client_name, - "search_score": ClientRestService._client_name_score( - client_name, query, tokens - ), - "search_reasons": [ - { - "token": token, - "reason": ClientRestService._client_token_match_reason( - token, name_tokens - ), - "score": ClientRestService._client_token_match_score( - token, name_tokens - ), - } - for token in tokens - ], - } - - @staticmethod - def _client_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): - return "token_prefix" - return "no_match" - - @staticmethod - def _format_client_summary(client: "_AnnotatedClientWithPhone") -> Dict[str, Any]: - """ - Formats a single client summary for list/search responses. - - Callers must annotate their queryset with - ClientContactMethod.primary_phone_annotation (see _ClientPhoneAnnotations). - """ - return { - "id": str(client.id), - "name": client.name, - "email": client.email or "", - "phone": client.phone, - "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}", - } - - @staticmethod - def _format_client_search_results(clients) -> List[Dict[str, Any]]: - """ - Formats client search results for API response. - """ - return [ClientRestService._format_client_summary(client) for client in clients] - - @staticmethod - def _format_client_detail(client: "_AnnotatedClientWithPhone") -> Dict[str, Any]: - """ - Formats complete client details for API response. - - Callers must annotate their queryset with - ClientContactMethod.primary_phone_annotation (see _ClientPhoneAnnotations). - """ - return { - "id": str(client.id), - "name": client.name, - "email": client.email or "", - "phone": client.phone, - "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}", - } - - @staticmethod - def _create_client_in_xero(client_data: Dict[str, Any]) -> Client: - """ - Creates client in the accounting provider and locally. - """ - provider = get_provider() - name = client_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" with ID: {existing.external_id}" - ) - - # Create local client first - with transaction.atomic(): - client = Client.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), - xero_last_modified=timezone.now(), - ) - phone = client_data.get("phone") - ClientRestService._apply_client_phone_change( - client, - phone_supplied="phone" in client_data, - raw_phone=phone, - ) - - # Push to accounting provider (persists xero_contact_id on the client object) - result = provider.create_contact(client) - if not result.success: - client_id = client.id - client.delete() - logger.warning( - "Deleted local client after accounting provider create failure", - extra={ - "client_id": str(client_id), - "client_name": name, - "provider": provider.provider_name, - "operation": "create_client_in_xero_cleanup", - }, - ) - raise ValueError( - f"Failed to create client in {provider.provider_name}: {result.error}" - ) - - logger.info( - f"Client {client.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", - }, - ) - return client - - @staticmethod - def _update_client_in_xero( - client: Client, - data: Dict[str, Any], - *, - phone_supplied: bool, - raw_phone: str | None, - ) -> Client: - """ - Updates client locally and in the accounting provider. - """ - provider = get_provider() - - # Check accounting provider authentication - token = provider.get_valid_token() - if not token: - exc = RuntimeError("Accounting provider authentication required") - persist_and_raise( - exc, - additional_context={ - "operation": "_update_client_in_xero", - "client_id": str(client.id), - "provider": provider.provider_name, - }, - ) - - # 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 - ) - if "allow_jobs" in data: - client.allow_jobs = data["allow_jobs"] - client.xero_last_modified = timezone.now() - client.save() - ClientRestService._apply_client_phone_change( - client, - phone_supplied=phone_supplied, - raw_phone=raw_phone, - ) - - # FIXME: `allow_jobs` is a local-only field (not synced to Xero) but - # toggling it still routes through this method, which unconditionally - # bumps `xero_last_modified` and pushes to Xero below. That wastes - # 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 - # `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) - if not result.success: - exc = RuntimeError( - f"Failed to update client in {provider.provider_name}: {result.error}" - ) - persist_and_raise( - exc, - additional_context={ - "operation": "_update_client_in_xero", - "client_id": str(client.id), - "provider": provider.provider_name, - "provider_error": result.error, - }, - ) - - logger.info( - f"Client {client.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", - }, - ) - - return client - - @staticmethod - def _apply_client_phone_change( - client: Client, - *, - phone_supplied: bool, - raw_phone: str | None, - ) -> None: - if not phone_supplied: - logger.debug( - "Client phone omitted; leaving contact methods unchanged", - extra={ - "client_id": str(client.id), - "operation": "client_phone_omitted", - }, - ) - return - - if raw_phone is not None and raw_phone.strip(): - try: - set_primary_phone(client, raw_phone) - except DjangoValidationError as exc: - raise ValueError("; ".join(exc.messages)) from exc - else: - return - - ClientRestService._clear_client_primary_phone(client) - - @staticmethod - def _clear_client_primary_phone(client: Client) -> None: - primary = ( - ClientContactMethod.objects.filter( - client=client, - method_type=ClientContactMethod.MethodType.PHONE, - is_primary=True, - ) - .order_by(*PRIMARY_PHONE_ORDERING) - .first() - ) - if primary is None: - logger.info( - "Client primary phone clear requested but no primary phone exists", - extra={ - "client_id": str(client.id), - "operation": "client_phone_clear_noop", - }, - ) - return - - old_number = primary.normalized_value - primary.delete() - if not old_number: - logger.warning( - "Deleted client primary phone without normalized value", - extra={ - "client_id": str(client.id), - "contact_method_id": str(primary.id), - "operation": "client_phone_clear_missing_normalized_value", - }, - ) - return - - transaction.on_commit(lambda: rematch_phone_calls_task.delay([old_number])) - - @staticmethod - def get_client_jobs(client_id: UUID) -> List[Dict[str, Any]]: - """ - Retrieves all jobs for a specific client. - - Args: - client_id: Client UUID - - Returns: - List of job header dictionaries - - Raises: - ValueError: If client 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") - - # 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 - jobs = ( - Job.objects.filter(client_id=client_id) - # quote joined in because job.quoted reads it per job below - .select_related("client", "quote") - .only(*query_fields, "quote__id") - .order_by("-job_number") - ) - - # Format job data - return [ - { - "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 - else None - ), - "status": job.status, - "pricing_methodology": job.pricing_methodology, - "speed_quality_tradeoff": job.speed_quality_tradeoff, - "fully_invoiced": job.fully_invoiced, - "has_quote_in_xero": job.quoted, - "is_fixed_price": job.pricing_methodology == "fixed_price", - "quote_acceptance_date": job.quote_acceptance_date, - "paid": job.paid, - "rejected_flag": job.rejected_flag, - "min_people": job.min_people, - "max_people": job.max_people, - } - for job in jobs - ] - - except Exception as e: - persist_app_error(e) - raise 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/tests/test_contact_methods.py b/apps/client/tests/test_contact_methods.py deleted file mode 100644 index b54d1eda8..000000000 --- a/apps/client/tests/test_contact_methods.py +++ /dev/null @@ -1,1131 +0,0 @@ -import uuid -from typing import TYPE_CHECKING -from unittest.mock import MagicMock, patch - -from django.core.exceptions import ValidationError -from django.db import connection -from django.test import TestCase -from django.test.utils import CaptureQueriesContext -from django.utils import timezone - -if TYPE_CHECKING: - from apps.job.models import Job - -from apps.client.models import Client, ClientContact, ClientContactMethod -from apps.client.services.client_rest_service import ClientRestService -from apps.crm.models import PhoneCallRecord -from apps.crm.services.phone_call_service import rematch_calls_for_numbers -from apps.testing import BaseAPITestCase, BaseTestCase -from apps.workflow.accounting.types import ContactResult -from apps.workflow.exceptions import AlreadyLoggedException -from apps.workflow.models import AppError - - -class ClientContactMethodTests(TestCase): - def _client(self, name: str = "Acme Ltd") -> Client: - return Client.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.""" - self.assertEqual( - ClientContactMethod.normalize_phone("+64 9 636 5131"), - "+6496365131", - ) - self.assertEqual( - ClientContactMethod.normalize_phone("09 636 5131"), - "+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() - first = ClientContactMethod.objects.create( - client=client, - method_type=ClientContactMethod.MethodType.PHONE, - value="09 111 1111", - is_primary=True, - ) - second = ClientContactMethod.objects.create( - client=client, - method_type=ClientContactMethod.MethodType.PHONE, - value="09 222 2222", - is_primary=True, - ) - - first.refresh_from_db() - second.refresh_from_db() - - 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, - method_type=ClientContactMethod.MethodType.PHONE, - value="021 111 111", - ) - on_contact = ClientContactMethod.objects.create( - contact=contact, - method_type=ClientContactMethod.MethodType.PHONE, - value="021 111 111", - ) - - self.assertEqual(on_client.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") - ClientContactMethod.objects.create( - contact=first_contact, - method_type=ClientContactMethod.MethodType.PHONE, - value="021 111 111", - ) - on_second = ClientContactMethod.objects.create( - contact=second_contact, - method_type=ClientContactMethod.MethodType.PHONE, - value="021 111 111", - ) - - 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") - ClientContactMethod.objects.create( - client=client_a, - method_type=ClientContactMethod.MethodType.PHONE, - value="021 111 111", - ) - with self.assertRaises(ValidationError): - ClientContactMethod.objects.create( - client=client_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") - ClientContactMethod.objects.create( - contact=contact_a, - method_type=ClientContactMethod.MethodType.PHONE, - value="021 111 111", - ) - with self.assertRaises(ValidationError): - ClientContactMethod.objects.create( - contact=contact_b, - method_type=ClientContactMethod.MethodType.PHONE, - 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") - ClientContactMethod.objects.create( - client=client_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, - method_type=ClientContactMethod.MethodType.PHONE, - value="021 111 111", - ) - legacy.normalized_value = ClientContactMethod.normalize_phone("021 111 111") - ClientContactMethod.objects.bulk_create([legacy]) - - legacy.refresh_from_db() - legacy.label = "Mobile" - legacy.save() # association unchanged -> grandfathered, must not raise - - 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") - ClientContactMethod.objects.create( - client=client_a, - method_type=ClientContactMethod.MethodType.PHONE, - value="021 111 111", - ) - moving = ClientContactMethod.objects.create( - client=client_b, - method_type=ClientContactMethod.MethodType.PHONE, - value="021 222 222", - ) - - moving.value = "021 111 111" # now collides with client 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") - first = ClientContactMethod.objects.create( - contact=contact, - method_type=ClientContactMethod.MethodType.PHONE, - value="021 111 111", - is_primary=True, - ) - second = ClientContactMethod.objects.create( - contact=contact, - method_type=ClientContactMethod.MethodType.PHONE, - value="021 222 222", - is_primary=True, - ) - - first.refresh_from_db() - second.refresh_from_db() - - self.assertFalse(first.is_primary) - self.assertTrue(second.is_primary) - - 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") - method = ClientContactMethod.objects.create( - client=client, - method_type=ClientContactMethod.MethodType.PHONE, - value="021 111 111", - ) - - method.value = "021 222 222" - method.save(update_fields=["value"]) - - method.refresh_from_db() - self.assertEqual( - method.normalized_value, - ClientContactMethod.normalize_phone("021 222 222"), - ) - - -class ClientPrimaryPhoneValueTests(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( - name="Acme Ltd", xero_last_modified=timezone.now() - ) - ClientContactMethod.objects.create( - client=client, - method_type=ClientContactMethod.MethodType.PHONE, - value="09 111 1111", - ) - ClientContactMethod.objects.create( - client=client, - method_type=ClientContactMethod.MethodType.PHONE, - value="09 222 2222", - is_primary=True, - ) - - self.assertEqual(client.primary_phone_value(), "09 222 2222") - - def test_returns_empty_string_when_no_phone_methods(self) -> None: - client = Client.objects.create( - name="Phoneless Ltd", xero_last_modified=timezone.now() - ) - ClientContactMethod.objects.create( - client=client, - method_type=ClientContactMethod.MethodType.EMAIL, - value="office@example.com", - ) - - self.assertEqual(client.primary_phone_value(), "") - - -class PrimaryPhoneAnnotationTests(TestCase): - """Guards the shared queryset annotation every phone-bearing payload uses.""" - - def _client(self, name: str = "Acme Ltd") -> Client: - return Client.objects.create(name=name, xero_last_modified=timezone.now()) - - def test_client_annotation_prefers_primary_over_label_order(self) -> None: - client = self._client() - ClientContactMethod.objects.create( - client=client, - method_type=ClientContactMethod.MethodType.PHONE, - value="09 111 1111", - label="AAA sorts first", - ) - ClientContactMethod.objects.create( - client=client, - method_type=ClientContactMethod.MethodType.PHONE, - value="09 222 2222", - label="ZZZ sorts last", - is_primary=True, - ) - - annotated = Client.objects.annotate( - phone=ClientContactMethod.primary_phone_annotation( - owner="client", outer_ref="pk" - ) - ).get(pk=client.pk) - - self.assertEqual(annotated.phone, "09 222 2222") - - def test_client_annotation_is_empty_string_without_phones(self) -> None: - client = self._client("Phoneless Ltd") - ClientContactMethod.objects.create( - client=client, - method_type=ClientContactMethod.MethodType.EMAIL, - value="office@example.com", - ) - - annotated = Client.objects.annotate( - phone=ClientContactMethod.primary_phone_annotation( - owner="client", outer_ref="pk" - ) - ).get(pk=client.pk) - - self.assertEqual(annotated.phone, "") - - def test_contact_annotation_returns_contact_primary_phone(self) -> None: - client = self._client() - contact = ClientContact.objects.create(client=client, name="Jane Smith") - ClientContactMethod.objects.create( - contact=contact, - method_type=ClientContactMethod.MethodType.PHONE, - value="021 111 111", - ) - ClientContactMethod.objects.create( - contact=contact, - method_type=ClientContactMethod.MethodType.PHONE, - value="021 222 222", - is_primary=True, - ) - - annotated = ClientContact.objects.annotate( - phone=ClientContactMethod.primary_phone_annotation( - owner="contact", outer_ref="pk" - ) - ).get(pk=contact.pk) - - self.assertEqual(annotated.phone, "021 222 222") - - -class UpdateJobContactTests(BaseTestCase): - """Guards that reassigning a job's contact persists to the job record.""" - - def _job_with_contact(self) -> "tuple[Job, Client, ClientContact]": - from apps.job.models import Job - from apps.workflow.models import XeroPayItem - - client = Client.objects.create( - name="Acme Ltd", xero_last_modified=timezone.now() - ) - contact = ClientContact.objects.create(client=client, name="Jane Smith") - job: Job = Job.objects.create( - name="Contact Assignment Job", - client=client, - contact=contact, - created_by=self.test_staff, - default_xero_pay_item=XeroPayItem.get_ordinary_time(), - staff=self.test_staff, - ) - return job, client, contact - - def test_update_job_contact_persists_new_contact(self) -> None: - job, client, _ = self._job_with_contact() - new_contact = ClientContact.objects.create(client=client, name="Bob Brown") - - ClientRestService.update_job_contact( - job.id, {"id": str(new_contact.id)}, self.test_staff - ) - - job.refresh_from_db() - self.assertEqual(job.contact_id, new_contact.id) - - -class ClientListPhoneTests(TestCase): - """Guards the Phone column of the clients list (restored after the - ClientContactMethod migration dropped it).""" - - def _client_with_phone(self, name: str, phone: str) -> Client: - client = Client.objects.create(name=name, xero_last_modified=timezone.now()) - ClientContactMethod.objects.create( - client=client, - method_type=ClientContactMethod.MethodType.PHONE, - value=phone, - is_primary=True, - ) - return client - - def _lazy_phone_queries(self, captured: CaptureQueriesContext) -> list[str]: - return [ - q["sql"] - for q in captured.captured_queries - if q["sql"].startswith('SELECT "client_clientcontactmethod"') - ] - - def test_list_clients_rows_include_phone(self) -> None: - self._client_with_phone("Acme Ltd", "09 111 1111") - Client.objects.create(name="Phoneless Ltd", xero_last_modified=timezone.now()) - - with CaptureQueriesContext(connection) as captured: - result = ClientRestService.list_clients(page=1, page_size=10) - - phones = {row["name"]: row["phone"] for row in result["results"]} - self.assertEqual(phones["Acme Ltd"], "09 111 1111") - self.assertEqual(phones["Phoneless Ltd"], "") - self.assertEqual(self._lazy_phone_queries(captured), []) - - def test_searched_clients_include_phone(self) -> None: - self._client_with_phone("Acme Ltd", "09 111 1111") - - result = ClientRestService.list_clients(query="Acme", page=1, page_size=10) - - self.assertEqual(result["results"][0]["phone"], "09 111 1111") - - -class ClientContactApiPhoneTests(BaseAPITestCase): - """Guards the contact phone read/write restored on the contacts endpoint - (client detail Contacts card, ContactSelectionModal, contact picker).""" - - URL = "/api/clients/contacts/" - - def setUp(self) -> None: - super().setUp() - self.client.force_authenticate(user=self.test_staff) - self.job_client = Client.objects.create( - name="Acme Ltd", xero_last_modified=timezone.now() - ) - - def _contact( - self, name: str = "Jane Smith", phone: str | None = None - ) -> ClientContact: - contact = ClientContact.objects.create(client=self.job_client, name=name) - if phone is not None: - ClientContactMethod.objects.create( - contact=contact, - method_type=ClientContactMethod.MethodType.PHONE, - value=phone, - is_primary=True, - ) - return contact - - def test_list_includes_contact_phone_without_lazy_queries(self) -> None: - self._contact("Jane Smith", phone="021 111 111") - self._contact("No Phone") - - with CaptureQueriesContext(connection) as captured: - response = self.client.get(self.URL, {"client_id": str(self.job_client.id)}) - - self.assertEqual(response.status_code, 200) - phones = {row["name"]: row["phone"] for row in response.json()} - self.assertEqual(phones["Jane Smith"], "021 111 111") - self.assertEqual(phones["No Phone"], "") - lazy = [ - q["sql"] - for q in captured.captured_queries - if q["sql"].startswith('SELECT "client_clientcontactmethod"') - ] - self.assertEqual(lazy, []) - - def test_create_contact_with_phone_creates_primary_method(self) -> None: - with patch("apps.crm.tasks.rematch_phone_calls_task.delay") as rematch: - with self.captureOnCommitCallbacks(execute=True): - response = self.client.post( - self.URL, - { - "client": str(self.job_client.id), - "name": "Bob Brown", - "phone": "021 222 222", - }, - format="json", - ) - - self.assertEqual(response.status_code, 201) - self.assertEqual(response.json()["phone"], "021 222 222") - method = ClientContactMethod.objects.get(contact__name="Bob Brown") - self.assertEqual(method.method_type, ClientContactMethod.MethodType.PHONE) - self.assertTrue(method.is_primary) - rematch.assert_called_once_with(["+6421222222"]) - - def test_update_phone_updates_existing_primary_method(self) -> None: - contact = self._contact("Jane Smith", phone="021 111 111") - method = contact.contact_methods.get() - - with patch("apps.crm.tasks.rematch_phone_calls_task.delay") as rematch: - with self.captureOnCommitCallbacks(execute=True): - response = self.client.patch( - f"{self.URL}{contact.id}/", - {"phone": "021 333 333"}, - format="json", - ) - - self.assertEqual(response.status_code, 200) - self.assertEqual(response.json()["phone"], "021 333 333") - method.refresh_from_db() - self.assertEqual(method.value, "021 333 333") - self.assertEqual(contact.contact_methods.count(), 1) - rematch.assert_called_once_with(["+6421111111", "+6421333333"]) - - def test_update_phone_matching_secondary_promotes_it(self) -> None: - contact = self._contact("Jane Smith", phone="021 111 111") - secondary = ClientContactMethod.objects.create( - contact=contact, - method_type=ClientContactMethod.MethodType.PHONE, - value="021 444 444", - ) - - response = self.client.patch( - f"{self.URL}{contact.id}/", - {"phone": "021 444 444"}, - format="json", - ) - - self.assertEqual(response.status_code, 200) - secondary.refresh_from_db() - self.assertTrue(secondary.is_primary) - self.assertEqual(contact.contact_methods.filter(is_primary=True).count(), 1) - - def test_blank_phone_leaves_methods_untouched(self) -> None: - contact = self._contact("Jane Smith", phone="021 111 111") - - response = self.client.patch( - f"{self.URL}{contact.id}/", - {"phone": "", "position": "Manager"}, - format="json", - ) - - self.assertEqual(response.status_code, 200) - self.assertEqual(response.json()["phone"], "021 111 111") - self.assertEqual(contact.contact_methods.count(), 1) - - def test_conflicting_phone_returns_400_and_creates_nothing(self) -> None: - other_client = Client.objects.create( - name="Beta Ltd", xero_last_modified=timezone.now() - ) - ClientContactMethod.objects.create( - client=other_client, - method_type=ClientContactMethod.MethodType.PHONE, - value="021 555 555", - ) - contact = self._contact("Jane Smith") - - response = self.client.patch( - f"{self.URL}{contact.id}/", - {"phone": "021 555 555"}, - format="json", - ) - - self.assertEqual(response.status_code, 400) - self.assertIn("phone", response.json()) - self.assertEqual(contact.contact_methods.count(), 0) - - -class ClientUpdatePhoneTests(BaseAPITestCase): - """Guards the phone edit restored on the Edit Client modal's update flow - (client detail's "phone" read via ClientContactMethod, written through - set_primary_phone).""" - - def setUp(self) -> None: - super().setUp() - self.client.force_authenticate(user=self.test_staff) - - def _client(self, name: str = "Acme Ltd") -> Client: - return Client.objects.create(name=name, xero_last_modified=timezone.now()) - - def _update_url(self, client_id) -> str: - return f"/api/clients/{client_id}/update/" - - def test_update_with_new_phone_creates_primary_method(self) -> None: - client = self._client() - - with patch("apps.crm.tasks.rematch_phone_calls_task.delay") as rematch: - with self.captureOnCommitCallbacks(execute=True): - response = self.client.patch( - self._update_url(client.id), - {"phone": "09 111 1111"}, - format="json", - ) - - self.assertEqual(response.status_code, 200) - self.assertEqual(response.json()["client"]["phone"], "09 111 1111") - method = ClientContactMethod.objects.get(client=client) - self.assertEqual(method.method_type, ClientContactMethod.MethodType.PHONE) - self.assertEqual(method.value, "09 111 1111") - self.assertTrue(method.is_primary) - rematch.assert_called_once_with( - [ClientContactMethod.normalize_phone("09 111 1111")] - ) - - def test_update_with_existing_secondary_number_promotes_it(self) -> None: - client = self._client() - primary = ClientContactMethod.objects.create( - client=client, - method_type=ClientContactMethod.MethodType.PHONE, - value="09 111 1111", - is_primary=True, - ) - secondary = ClientContactMethod.objects.create( - client=client, - method_type=ClientContactMethod.MethodType.PHONE, - value="09 222 2222", - ) - - response = self.client.patch( - self._update_url(client.id), {"phone": "09 222 2222"}, format="json" - ) - - self.assertEqual(response.status_code, 200) - primary.refresh_from_db() - secondary.refresh_from_db() - self.assertFalse(primary.is_primary) - self.assertTrue(secondary.is_primary) - self.assertEqual(client.contact_methods.count(), 2) - - def test_update_renumbers_current_primary_when_number_is_new(self) -> None: - """Matches set_primary_phone's contract: a genuinely new number reuses - (renumbers) the existing primary row instead of creating a second - one.""" - client = self._client() - primary = ClientContactMethod.objects.create( - client=client, - method_type=ClientContactMethod.MethodType.PHONE, - value="09 111 1111", - is_primary=True, - ) - - response = self.client.patch( - self._update_url(client.id), {"phone": "09 333 3333"}, format="json" - ) - - self.assertEqual(response.status_code, 200) - self.assertEqual(client.contact_methods.count(), 1) - primary.refresh_from_db() - self.assertEqual(primary.value, "09 333 3333") - self.assertTrue(primary.is_primary) - - def test_blank_phone_clears_primary_method(self) -> None: - client = self._client() - ClientContactMethod.objects.create( - client=client, - method_type=ClientContactMethod.MethodType.PHONE, - value="09 111 1111", - is_primary=True, - ) - - with patch( - "apps.client.services.client_rest_service.rematch_phone_calls_task.delay" - ) as rematch: - with self.captureOnCommitCallbacks(execute=True): - response = self.client.patch( - self._update_url(client.id), - {"phone": "", "name": "Acme Renamed"}, - format="json", - ) - - self.assertEqual(response.status_code, 200) - self.assertEqual(response.json()["client"]["phone"], "") - self.assertEqual(client.contact_methods.count(), 0) - client.refresh_from_db() - self.assertEqual(client.name, "Acme Renamed") - rematch.assert_called_once_with( - [ClientContactMethod.normalize_phone("09 111 1111")] - ) - - def test_omitted_phone_leaves_methods_untouched(self) -> None: - client = self._client() - ClientContactMethod.objects.create( - client=client, - method_type=ClientContactMethod.MethodType.PHONE, - value="09 111 1111", - is_primary=True, - ) - - with patch( - "apps.client.services.client_rest_service.rematch_phone_calls_task.delay" - ) as rematch: - with self.captureOnCommitCallbacks(execute=True): - response = self.client.patch( - self._update_url(client.id), - {"name": "Acme Renamed"}, - format="json", - ) - - self.assertEqual(response.status_code, 200) - self.assertEqual(response.json()["client"]["phone"], "09 111 1111") - self.assertEqual(client.contact_methods.count(), 1) - client.refresh_from_db() - self.assertEqual(client.name, "Acme Renamed") - rematch.assert_not_called() - - def test_conflicting_phone_returns_400_and_rolls_back_update(self) -> None: - """A conflict must not leave the update half-applied: neither the new - name nor a stray contact method should be persisted.""" - other = self._client("Beta Ltd") - ClientContactMethod.objects.create( - client=other, - method_type=ClientContactMethod.MethodType.PHONE, - value="09 555 5555", - ) - client = self._client("Acme Ltd") - - response = self.client.patch( - self._update_url(client.id), - {"phone": "09 555 5555", "name": "Acme Renamed"}, - format="json", - ) - - self.assertEqual(response.status_code, 400) - self.assertIn("phone", response.json()["error"].lower()) - client.refresh_from_db() - self.assertEqual(client.name, "Acme Ltd") - self.assertEqual(client.contact_methods.count(), 0) - - def test_get_client_detail_returns_primary_phone(self) -> None: - client = self._client() - ClientContactMethod.objects.create( - client=client, - method_type=ClientContactMethod.MethodType.PHONE, - value="09 111 1111", - is_primary=True, - ) - - response = self.client.get(f"/api/clients/{client.id}/") - - self.assertEqual(response.status_code, 200) - self.assertEqual(response.json()["phone"], "09 111 1111") - - def test_get_client_detail_returns_empty_string_without_phone(self) -> None: - client = self._client("Phoneless Ltd") - - response = self.client.get(f"/api/clients/{client.id}/") - - self.assertEqual(response.status_code, 200) - self.assertEqual(response.json()["phone"], "") - - def test_xero_synced_update_applies_phone_before_provider_push(self) -> None: - client = self._client() - client.xero_contact_id = "xero-contact-id" - client.save() - provider = MagicMock() - provider.provider_name = "Xero" - provider.get_valid_token.return_value = {"access_token": "token"} - provider.update_contact.return_value = ContactResult( - success=True, external_id=client.xero_contact_id, name=client.name - ) - - with patch( - "apps.client.services.client_rest_service.get_provider", - return_value=provider, - ): - with patch("apps.crm.tasks.rematch_phone_calls_task.delay") as rematch: - with self.captureOnCommitCallbacks(execute=True): - response = self.client.patch( - self._update_url(client.id), - {"phone": "09 444 4444"}, - format="json", - ) - - self.assertEqual(response.status_code, 200) - self.assertEqual(response.json()["client"]["phone"], "09 444 4444") - pushed_client = provider.update_contact.call_args.args[0] - pushed_contact = pushed_client.get_client_for_xero() - self.assertEqual(pushed_contact.phones[0].phone_number, "09 444 4444") - rematch.assert_called_once_with( - [ClientContactMethod.normalize_phone("09 444 4444")] - ) - - def test_xero_synced_blank_phone_clears_before_provider_push(self) -> None: - client = self._client() - client.xero_contact_id = "xero-contact-id" - client.save() - ClientContactMethod.objects.create( - client=client, - method_type=ClientContactMethod.MethodType.PHONE, - value="09 111 1111", - is_primary=True, - ) - provider = MagicMock() - provider.provider_name = "Xero" - provider.get_valid_token.return_value = {"access_token": "token"} - provider.update_contact.return_value = ContactResult( - success=True, external_id=client.xero_contact_id, name=client.name - ) - - with patch( - "apps.client.services.client_rest_service.get_provider", - return_value=provider, - ): - with patch( - "apps.client.services.client_rest_service." - "rematch_phone_calls_task.delay" - ) as rematch: - with self.captureOnCommitCallbacks(execute=True): - response = self.client.patch( - self._update_url(client.id), - {"phone": ""}, - format="json", - ) - - self.assertEqual(response.status_code, 200) - self.assertEqual(response.json()["client"]["phone"], "") - pushed_client = provider.update_contact.call_args.args[0] - pushed_contact = pushed_client.get_client_for_xero() - self.assertIsNone(pushed_contact.phones[0].phone_number) - rematch.assert_called_once_with( - [ClientContactMethod.normalize_phone("09 111 1111")] - ) - - def test_phone_rematch_waits_until_transaction_commit(self) -> None: - client = self._client() - - with patch("apps.crm.tasks.rematch_phone_calls_task.delay") as rematch: - with self.captureOnCommitCallbacks(execute=False) as callbacks: - response = self.client.patch( - self._update_url(client.id), - {"phone": "09 111 1111"}, - format="json", - ) - rematch.assert_not_called() - - self.assertEqual(response.status_code, 200) - self.assertEqual(len(callbacks), 1) - rematch.assert_not_called() - callbacks[0]() - rematch.assert_called_once_with( - [ClientContactMethod.normalize_phone("09 111 1111")] - ) - - def test_unknown_client_update_returns_404_without_app_error(self) -> None: - before = AppError.objects.count() - - response = self.client.patch( - self._update_url(uuid.uuid4()), - {"name": "Missing Client"}, - format="json", - ) - - self.assertEqual(response.status_code, 404) - self.assertIn("not found", response.json()["error"].lower()) - self.assertEqual(AppError.objects.count(), before) - - def test_validation_error_returns_400_without_app_error(self) -> None: - client = self._client() - before = AppError.objects.count() - - response = self.client.patch( - self._update_url(client.id), - {"email": "not-an-email"}, - format="json", - ) - - self.assertEqual(response.status_code, 400) - self.assertIn("invalid input data", response.json()["error"].lower()) - self.assertEqual(AppError.objects.count(), before) - - def test_xero_update_failure_persists_once_and_returns_500(self) -> None: - client = self._client() - client.xero_contact_id = "xero-contact-id" - client.save() - provider = MagicMock() - provider.provider_name = "Xero" - provider.get_valid_token.return_value = {"access_token": "token"} - provider.update_contact.return_value = ContactResult( - success=False, - error="RemoteDisconnected", - ) - before = AppError.objects.count() - - with patch( - "apps.client.services.client_rest_service.get_provider", - return_value=provider, - ): - response = self.client.patch( - self._update_url(client.id), - {"name": "Acme Renamed"}, - format="json", - ) - - self.assertEqual(response.status_code, 500) - payload = response.json() - self.assertEqual(payload["error"], "Error updating client") - self.assertIn("RemoteDisconnected", payload["details"]) - self.assertEqual(AppError.objects.count(), before + 1) - app_error = AppError.objects.latest("timestamp") - self.assertEqual(payload["error_id"], str(app_error.id)) - - def test_already_logged_update_failure_is_not_persisted_again(self) -> None: - client = self._client() - client.xero_contact_id = "xero-contact-id" - client.save() - before = AppError.objects.count() - app_error = AppError.objects.create( - message="upstream failure", - app="client", - file="client_rest_service.py", - function="_update_client_in_xero", - ) - - with patch( - "apps.client.services.client_rest_service.get_provider", - side_effect=AlreadyLoggedException( - RuntimeError("upstream failure"), - app_error.id, - ), - ): - response = self.client.patch( - self._update_url(client.id), - {"name": "Acme Renamed"}, - format="json", - ) - - self.assertEqual(response.status_code, 500) - self.assertEqual(AppError.objects.count(), before + 1) - self.assertEqual(response.json()["error_id"], str(app_error.id)) - - -class ClientUpdateProviderFailureTests(BaseTestCase): - """Service-level guard for ADR 0019 on update_client: Xero/system - failures must persist to AppError and surface as AlreadyLoggedException - at the service boundary, never ride the user-facing ValueError (400) - path. Complements the HTTP-level 500/error_id tests above.""" - - def _xero_synced_client(self) -> Client: - return Client.objects.create( - name="Acme Ltd", - xero_contact_id="xero-contact-id", - xero_last_modified=timezone.now(), - ) - - def test_failed_provider_push_persists_app_error(self) -> None: - from apps.client.services.client_rest_service import ClientRestService - - client = self._xero_synced_client() - provider = MagicMock() - provider.provider_name = "Xero" - provider.get_valid_token.return_value = {"access_token": "token"} - provider.update_contact.return_value = ContactResult( - success=False, error="rate limited" - ) - - with patch( - "apps.client.services.client_rest_service.get_provider", - return_value=provider, - ): - with self.assertRaises(AlreadyLoggedException) as ctx: - ClientRestService.update_client(client.id, {"name": "Acme Renamed"}) - - self.assertIn("Failed to update client", str(ctx.exception)) - self.assertEqual(AppError.objects.count(), 1) - - def test_missing_provider_token_persists_app_error(self) -> None: - from apps.client.services.client_rest_service import ClientRestService - - client = self._xero_synced_client() - provider = MagicMock() - provider.provider_name = "Xero" - provider.get_valid_token.return_value = None - - with patch( - "apps.client.services.client_rest_service.get_provider", - return_value=provider, - ): - with self.assertRaises(AlreadyLoggedException) as ctx: - ClientRestService.update_client(client.id, {"name": "Acme Renamed"}) - - self.assertIn("authentication required", str(ctx.exception)) - self.assertEqual(AppError.objects.count(), 1) - - -class ClientCreatePhoneTests(BaseTestCase): - """Guards the phone entry restored on the create-client modal.""" - - def _provider(self) -> MagicMock: - provider = MagicMock() - provider.provider_name = "Xero" - provider.get_valid_token.return_value = {"access_token": "token"} - provider.search_contact_by_name.return_value = None - provider.create_contact.return_value = ContactResult( - success=True, external_id="xero-contact-id", name="New Client" - ) - return provider - - def _create(self, provider: MagicMock, **payload: str) -> Client: - data: dict[str, str] = {"name": "New Client", "email": "", "address": ""} - data.update(payload) - with patch( - "apps.client.services.client_rest_service.get_provider", - return_value=provider, - ): - return ClientRestService.create_client(data) - - def test_create_with_phone_creates_primary_client_method(self) -> None: - with patch("apps.crm.tasks.rematch_phone_calls_task.delay"): - with self.captureOnCommitCallbacks(execute=True): - client = self._create(self._provider(), phone="09 777 7777") - - method = ClientContactMethod.objects.get(client=client) - self.assertEqual(method.method_type, ClientContactMethod.MethodType.PHONE) - self.assertEqual(method.value, "09 777 7777") - self.assertTrue(method.is_primary) - - def test_create_without_phone_creates_no_methods(self) -> None: - client = self._create(self._provider()) - - self.assertEqual(ClientContactMethod.objects.filter(client=client).count(), 0) - - def test_create_with_conflicting_phone_rolls_back_client(self) -> None: - owner = Client.objects.create( - name="Owner Ltd", xero_last_modified=timezone.now() - ) - ClientContactMethod.objects.create( - client=owner, - method_type=ClientContactMethod.MethodType.PHONE, - value="09 777 7777", - ) - provider = self._provider() - - with self.assertRaises(AlreadyLoggedException) as ctx: - self._create(provider, phone="09 777 7777") - - self.assertIn("already belongs", str(ctx.exception)) - self.assertFalse(Client.objects.filter(name="New Client").exists()) - provider.create_contact.assert_not_called() - - -class ClientContactMethodApiTests(BaseAPITestCase): - def _client(self, name: str = "Acme Ltd") -> Client: - return Client.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") - for index in range(3): - ClientContactMethod.objects.create( - client=client, - method_type=ClientContactMethod.MethodType.PHONE, - value=f"021 555 10{index}", - ) - - response = self.client.get( - "/api/clients/contact-methods/", - {"method_type": "phone", "page_size": "2"}, - ) - - self.assertEqual(response.status_code, 200) - payload = response.json() - self.assertEqual(payload["count"], 3) - self.assertEqual(payload["page"], 1) - self.assertEqual(payload["page_size"], 2) - self.assertEqual(payload["total_pages"], 2) - self.assertEqual(len(payload["results"]), 2) - - 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") - for index in range(101): - ClientContactMethod.objects.create( - client=client, - method_type=ClientContactMethod.MethodType.PHONE, - value=f"021 555 {index:03d}", - ) - - response = self.client.get( - "/api/clients/contact-methods/", - {"method_type": "phone", "page_size": "250"}, - ) - - self.assertEqual(response.status_code, 200) - payload = response.json() - self.assertEqual(payload["count"], 101) - self.assertEqual(payload["page_size"], 100) - self.assertEqual(len(payload["results"]), 100) - - 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") - method = ClientContactMethod.objects.create( - client=client, - 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) - - with patch( - "apps.client.views.client_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}/", - {"value": "021 555 200"}, - format="json", - ) - - self.assertEqual(response.status_code, 200) - 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) - - 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") - method = ClientContactMethod.objects.create( - client=client, - method_type=ClientContactMethod.MethodType.PHONE, - value="021 555 100", - ) - call = self._call("deleted-number", origin="021 555 100", client=client) - - with patch( - "apps.client.views.client_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}/") - - self.assertEqual(response.status_code, 204) - rematch.assert_called_once_with(["+6421555100"]) - call.refresh_from_db() - self.assertIsNone(call.client) - - def _call( - self, - provider_id: str, - *, - origin: str, - client: Client | None, - ) -> PhoneCallRecord: - call_datetime = timezone.now() - return PhoneCallRecord.objects.create( - provider_call_id=f"account:{provider_id}", - account_code="account", - call_datetime=call_datetime, - call_date=timezone.localdate(), - call_time=call_datetime.time(), - origin=origin, - destination="+6496365131", - client=client, - raw_json={ - "id": provider_id, - "calldate": timezone.localdate().isoformat(), - "calltime": call_datetime.time().isoformat(timespec="seconds"), - }, - ) diff --git a/apps/client/tests/test_duplicate_phone_report.py b/apps/client/tests/test_duplicate_phone_report.py deleted file mode 100644 index 79dc0b4f9..000000000 --- a/apps/client/tests/test_duplicate_phone_report.py +++ /dev/null @@ -1,79 +0,0 @@ -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.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 _phone( - self, - value: str, - client: Client | None = None, - contact: ClientContact | None = None, - ) -> ClientContactMethod: - """Insert a phone method bypassing the save() guard (legacy-style data).""" - method = ClientContactMethod( - client=client, - contact=contact, - method_type=ClientContactMethod.MethodType.PHONE, - value=value, - ) - method.normalized_value = ClientContactMethod.normalize_phone(value) - 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) - - report = DuplicatePhoneReportService().get_report() - - cross = [i for i in report["duplicate_phones"] if i["issue"] == "cross_client"] - self.assertEqual(len(cross), 1) - self.assertEqual( - cross[0]["normalized_value"], - ClientContactMethod.normalize_phone("021 111 111"), - ) - self.assertEqual(len(cross[0]["owners"]), 2) - 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") - 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. - endpoint = PhoneEndpoint( - number="09 636 5131", - label="Main line", - endpoint_type=PhoneEndpoint.EndpointType.MAIN_LINE, - ) - endpoint.normalized_number = ClientContactMethod.normalize_phone("09 636 5131") - PhoneEndpoint.objects.bulk_create([endpoint]) - - report = DuplicatePhoneReportService().get_report() - - internal = [ - i for i in report["duplicate_phones"] if i["issue"] == "internal_line" - ] - self.assertEqual(len(internal), 1) - self.assertEqual(internal[0]["endpoint_label"], "Main line") - self.assertEqual(len(internal[0]["owners"]), 1) - 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) - - report = DuplicatePhoneReportService().get_report() - - self.assertEqual(report["duplicate_phones"], []) - self.assertEqual(report["summary"], {"cross_client": 0, "internal_line": 0}) - self.assertIn("checked_at", report) 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/views/__init__.py b/apps/client/views/__init__.py deleted file mode 100644 index 511ab6415..000000000 --- a/apps/client/views/__init__.py +++ /dev/null @@ -1,44 +0,0 @@ -# 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, - JobContactRestView, -) -from .supplier_search_alias_views import ( - ClientSupplierAliasListCreateView, - SupplierAliasDetailView, -) - -# Conditional imports (only when Django is ready) -try: - from django.apps import apps - - if apps.ready: - from .client_contact_method_viewset import ClientContactMethodViewSet - from .client_contact_viewset import ClientContactViewSet - from .supplier_pickup_address_viewset import SupplierPickupAddressViewSet -except (ImportError, RuntimeError): - # Django not ready or circular import, skip conditional imports - pass - -__all__ = [ - "AddressValidateView", - "ClientContactMethodViewSet", - "ClientContactViewSet", - "ClientCreateRestView", - "ClientJobsRestView", - "ClientListAllRestView", - "ClientRetrieveRestView", - "ClientSearchRestView", - "ClientSupplierAliasListCreateView", - "ClientUpdateRestView", - "JobContactRestView", - "SupplierAliasDetailView", - "SupplierPickupAddressViewSet", -] diff --git a/apps/client/views/client_contact_viewset.py b/apps/client/views/client_contact_viewset.py deleted file mode 100644 index f53ce5342..000000000 --- a/apps/client/views/client_contact_viewset.py +++ /dev/null @@ -1,70 +0,0 @@ -""" -ClientContact ViewSet - -ViewSet for ClientContact CRUD operations using DRF's ModelViewSet. -Provides list, create, retrieve, update, partial_update, and destroy actions. -""" - -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 ClientContact, ClientContactMethod -from apps.client.serializers import ClientContactSerializer - - -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) - - Query Parameters: - - client_id: Filter contacts by client UUID - """ - - queryset = ClientContact.objects.filter(is_active=True) - serializer_class = ClientContactSerializer - permission_classes = [permissions.IsAuthenticated] - - @extend_schema( - parameters=[ - OpenApiParameter( - name="client_id", - type=OpenApiTypes.UUID, - location=OpenApiParameter.QUERY, - description="Filter contacts by client UUID", - required=False, - ), - ] - ) - def list(self, request, *args, **kwargs): - """List all contacts, optionally filtered by client_id.""" - return super().list(request, *args, **kwargs) - - def get_queryset(self): - """ - Filter to only active contacts, optionally filtered by client_id. - """ - queryset = ClientContact.objects.filter(is_active=True).annotate( - phone=ClientContactMethod.primary_phone_annotation( - owner="contact", outer_ref="pk" - ) - ) - client_id = self.request.query_params.get("client_id") - if client_id: - queryset = queryset.filter(client_id=client_id) - return queryset.order_by("-is_primary", "name") - - def perform_destroy(self, instance): - """ - Soft delete - set is_active=False instead of actually deleting. - """ - instance.is_active = False - instance.save(update_fields=["is_active"]) diff --git a/apps/company/__init__.py b/apps/company/__init__.py new file mode 100644 index 000000000..2b2841847 --- /dev/null +++ b/apps/company/__init__.py @@ -0,0 +1,128 @@ +# This file is autogenerated by update_init.py script + +from .apps import CompanyConfig +from .utils import date_to_datetime + +# Conditional imports (only when Django is ready) +try: + from django.apps import apps + + if apps.ready: + from .models import ( + Company, + CompanyPersonLink, + CompanyQuerySet, + ContactMethod, + Person, + PhoneAssignmentConflictError, + Supplier, + SupplierPickupAddress, + SupplierSearchAlias, + ) + from .person_serializers import ( + CompanyLinkWriteSerializer, + CompanyPersonCreateSerializer, + CompanyPersonSerializer, + PersonCompanyLinkSerializer, + PersonCompanySummarySerializer, + PersonContactMethodWriteSerializer, + PersonDetailSerializer, + PersonIdentityUpdateSerializer, + PersonSummarySerializer, + PhoneCompanyOwnerSerializer, + PhoneOwnershipConflictSerializer, + PhoneOwnershipRequestSerializer, + PhoneOwnershipSerializer, + PhonePersonMatchSerializer, + ) + from .serializers import ( + CompanyCreateResponseSerializer, + CompanyCreateSerializer, + CompanyDetailResponseSerializer, + CompanyDuplicateErrorResponseSerializer, + CompanyErrorResponseSerializer, + CompanyJobHeaderSerializer, + CompanyJobsResponseSerializer, + CompanyListResponseSerializer, + CompanyNameOnlySerializer, + CompanyPersonCreateAttrs, + CompanyPersonLinkCreateAttrs, + CompanyPersonLinkModelCreateAttrs, + CompanyPersonLinkSerializer, + CompanyPersonLinkUpdateAttrs, + CompanyPersonUpdateAttrs, + CompanySearchResponseSerializer, + CompanySearchResultSerializer, + CompanySerializer, + CompanyUpdateResponseSerializer, + CompanyUpdateSerializer, + ContactMethodSerializer, + JobPersonBaseSerializer, + JobPersonResponseSerializer, + JobPersonUpdateSerializer, + StandardErrorSerializer, + SupplierPickupAddressSerializer, + SupplierSearchAliasCreateSerializer, + SupplierSearchAliasSerializer, + set_primary_phone, + ) +except (ImportError, RuntimeError): + # Django not ready or circular import, skip conditional imports + pass + +__all__ = [ + "Company", + "CompanyConfig", + "CompanyCreateResponseSerializer", + "CompanyCreateSerializer", + "CompanyDetailResponseSerializer", + "CompanyDuplicateErrorResponseSerializer", + "CompanyErrorResponseSerializer", + "CompanyJobHeaderSerializer", + "CompanyJobsResponseSerializer", + "CompanyLinkWriteSerializer", + "CompanyListResponseSerializer", + "CompanyNameOnlySerializer", + "CompanyPersonCreateAttrs", + "CompanyPersonCreateSerializer", + "CompanyPersonLink", + "CompanyPersonLinkCreateAttrs", + "CompanyPersonLinkModelCreateAttrs", + "CompanyPersonLinkSerializer", + "CompanyPersonLinkUpdateAttrs", + "CompanyPersonSerializer", + "CompanyPersonUpdateAttrs", + "CompanyQuerySet", + "CompanySearchResponseSerializer", + "CompanySearchResultSerializer", + "CompanySerializer", + "CompanyUpdateResponseSerializer", + "CompanyUpdateSerializer", + "ContactMethod", + "ContactMethodSerializer", + "JobPersonBaseSerializer", + "JobPersonResponseSerializer", + "JobPersonUpdateSerializer", + "Person", + "PersonCompanyLinkSerializer", + "PersonCompanySummarySerializer", + "PersonContactMethodWriteSerializer", + "PersonDetailSerializer", + "PersonIdentityUpdateSerializer", + "PersonSummarySerializer", + "PhoneAssignmentConflictError", + "PhoneCompanyOwnerSerializer", + "PhoneOwnershipConflictSerializer", + "PhoneOwnershipRequestSerializer", + "PhoneOwnershipSerializer", + "PhonePersonMatchSerializer", + "StandardErrorSerializer", + "Supplier", + "SupplierPickupAddress", + "SupplierPickupAddressSerializer", + "SupplierSearchAlias", + "SupplierSearchAliasCreateSerializer", + "SupplierSearchAliasSerializer", + "date_to_datetime", + "set_primary_phone", +] 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 92% rename from apps/client/migrations/0001_baseline.py rename to apps/company/migrations/0001_baseline.py index c6edd8ddf..4746dd232 100644 --- a/apps/client/migrations/0001_baseline.py +++ b/apps/company/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 = [ @@ -125,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", ), ), ], @@ -142,7 +116,7 @@ class Migration(migrations.Migration): "indexes": [], "constraints": [], }, - bases=("client.client",), + bases=("company.client",), ), migrations.CreateModel( name="ClientContact", @@ -210,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", ), ), ], @@ -259,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", ), ), ( @@ -269,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", ), ), ], @@ -403,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", ), ), ], @@ -434,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/company/migrations/0004_person_link_structure.py b/apps/company/migrations/0004_person_link_structure.py new file mode 100644 index 000000000..94f4deb9d --- /dev/null +++ b/apps/company/migrations/0004_person_link_structure.py @@ -0,0 +1,128 @@ +# Generated by Codex for KAN-278 Stage B. + +import uuid + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("company", "0003_alter_clientcontact_company_and_more"), + ] + + operations = [ + migrations.CreateModel( + name="Person", + fields=[ + ( + "id", + models.UUIDField( + default=uuid.uuid4, + editable=False, + primary_key=True, + serialize=False, + ), + ), + ("name", models.CharField(db_index=True, max_length=255)), + ("email", models.EmailField(blank=True, max_length=254, null=True)), + ("is_active", models.BooleanField(default=True)), + ("created_at", models.DateTimeField(auto_now_add=True)), + ("updated_at", models.DateTimeField(auto_now=True)), + ], + options={"ordering": ["name"]}, + ), + migrations.RemoveConstraint( + model_name="clientcontact", + name="unique_company_contact_name", + ), + migrations.RemoveConstraint( + model_name="clientcontactmethod", + name="contact_method_one_owner", + ), + migrations.RemoveConstraint( + model_name="clientcontactmethod", + name="unique_company_contact_method_value", + ), + migrations.RemoveConstraint( + model_name="clientcontactmethod", + name="unique_company_primary_contact_method", + ), + migrations.RemoveConstraint( + model_name="clientcontactmethod", + name="unique_contact_contact_method_value", + ), + migrations.RemoveConstraint( + model_name="clientcontactmethod", + name="unique_contact_primary_contact_method", + ), + migrations.RenameModel( + old_name="ClientContact", + new_name="CompanyPersonLink", + ), + migrations.RenameModel( + old_name="ClientContactMethod", + new_name="ContactMethod", + ), + migrations.AddField( + model_name="companypersonlink", + name="person", + field=models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.CASCADE, + related_name="company_links", + to="company.person", + ), + ), + migrations.AddField( + model_name="companypersonlink", + name="xero_name", + field=models.CharField(blank=True, max_length=255, null=True), + ), + migrations.AddField( + model_name="contactmethod", + name="person", + field=models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.CASCADE, + related_name="contact_methods", + to="company.person", + ), + ), + migrations.AlterField( + model_name="contactmethod", + name="contact", + field=models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.CASCADE, + related_name="contact_methods", + to="company.companypersonlink", + ), + ), + migrations.AlterModelOptions( + name="companypersonlink", + options={ + "ordering": ["-is_primary", "name"], + "verbose_name": "Company Person Link", + "verbose_name_plural": "Company Person Links", + }, + ), + migrations.AlterModelOptions( + name="contactmethod", + options={ + "ordering": ["method_type", "-is_primary", "label", "value"], + "verbose_name": "Contact Method", + "verbose_name_plural": "Contact Methods", + }, + ), + migrations.AddConstraint( + model_name="companypersonlink", + constraint=models.UniqueConstraint( + fields=("company", "name"), name="unique_company_contact_name" + ), + ), + ] diff --git a/apps/company/migrations/0005_backfill_person.py b/apps/company/migrations/0005_backfill_person.py new file mode 100644 index 000000000..cb252e244 --- /dev/null +++ b/apps/company/migrations/0005_backfill_person.py @@ -0,0 +1,139 @@ +# Generated by Codex for KAN-278 Stage B. + +from typing import Any + +from django.db import migrations + + +def forwards(apps: Any, schema_editor: Any) -> None: + Person = apps.get_model("company", "Person") + CompanyPersonLink = apps.get_model("company", "CompanyPersonLink") + ContactMethod = apps.get_model("company", "ContactMethod") + Job = apps.get_model("job", "Job") + PhoneCallRecord = apps.get_model("crm", "PhoneCallRecord") + + for link in CompanyPersonLink.objects.filter(person__isnull=True).order_by("id"): + person = Person.objects.create( + name=link.name, + email=link.email, + is_active=link.is_active, + ) + link.person = person + link.xero_name = link.name + link.save(update_fields=["person", "xero_name", "updated_at"]) + + person_by_link_id = dict( + CompanyPersonLink.objects.exclude(person__isnull=True).values_list( + "id", "person_id" + ) + ) + + for method in ContactMethod.objects.exclude(contact__isnull=True).order_by("id"): + person_id = person_by_link_id[method.contact_id] + method.person_id = person_id + method.contact_id = None + method.save(update_fields=["person", "contact", "updated_at"]) + + for job in Job.objects.exclude(contact__isnull=True).order_by("id"): + job.person_id = person_by_link_id[job.contact_id] + job.save(update_fields=["person"]) + + for call in PhoneCallRecord.objects.exclude(contact__isnull=True).order_by("id"): + call.person_id = person_by_link_id[call.contact_id] + call.save(update_fields=["person", "updated_at"]) + + +def backwards(apps: Any, schema_editor: Any) -> None: + Person = apps.get_model("company", "Person") + CompanyPersonLink = apps.get_model("company", "CompanyPersonLink") + ContactMethod = apps.get_model("company", "ContactMethod") + Job = apps.get_model("job", "Job") + PhoneCallRecord = apps.get_model("crm", "PhoneCallRecord") + + def link_id_for_person( + *, person_id: Any, company_id: Any | None, owner: str + ) -> Any: + links = CompanyPersonLink.objects.filter(person_id=person_id) + if company_id is not None: + links = links.filter(company_id=company_id) + link_ids = list(links.order_by("id").values_list("id", flat=True)[:2]) + if len(link_ids) == 1: + return link_ids[0] + if len(link_ids) == 0: + raise RuntimeError( + f"Cannot reverse people backfill for {owner}: " + f"person_id={person_id} has no matching company link" + ) + raise RuntimeError( + f"Cannot reverse people backfill for {owner}: " + f"person_id={person_id} maps to multiple company links" + ) + + for call in PhoneCallRecord.objects.exclude(person__isnull=True).order_by("id"): + call.contact_id = link_id_for_person( + person_id=call.person_id, + company_id=call.company_id, + owner=f"PhoneCallRecord {call.id}", + ) + call.person_id = None + call.save(update_fields=["contact", "person", "updated_at"]) + + for job in Job.objects.exclude(person__isnull=True).order_by("id"): + job.contact_id = link_id_for_person( + person_id=job.person_id, + company_id=job.company_id, + owner=f"Job {job.id}", + ) + job.person_id = None + job.save(update_fields=["contact", "person"]) + + for method in ContactMethod.objects.exclude(person__isnull=True).order_by("id"): + method.contact_id = link_id_for_person( + person_id=method.person_id, + company_id=None, + owner=f"ContactMethod {method.id}", + ) + method.person_id = None + method.save(update_fields=["contact", "person", "updated_at"]) + + linked_person_ids = list( + CompanyPersonLink.objects.exclude(person__isnull=True).values_list( + "person_id", flat=True + ) + ) + for link in CompanyPersonLink.objects.exclude(person__isnull=True).select_related( + "person" + ): + link.name = link.person.name + link.email = link.person.email + link.is_active = link.person.is_active + link.person_id = None + link.xero_name = None + link.save( + update_fields=[ + "name", + "email", + "is_active", + "person", + "xero_name", + "updated_at", + ] + ) + + # These Person rows were created by forwards(). Removing them makes a + # reverse/forward rehearsal return to the same row count instead of leaving + # one unreferenced duplicate for every legacy contact. + Person.objects.filter(id__in=linked_person_ids).delete() + + +class Migration(migrations.Migration): + + dependencies = [ + ("company", "0004_person_link_structure"), + ("job", "0004_job_person_alter_job_contact"), + ("crm", "0005_phonecallrecord_person_alter_phonecallrecord_contact"), + ] + + operations = [ + migrations.RunPython(forwards, backwards), + ] diff --git a/apps/company/migrations/0006_alter_companypersonlink_options_and_more.py b/apps/company/migrations/0006_alter_companypersonlink_options_and_more.py new file mode 100644 index 000000000..16146a4b9 --- /dev/null +++ b/apps/company/migrations/0006_alter_companypersonlink_options_and_more.py @@ -0,0 +1,200 @@ +# Generated by Django 6.0.4 on 2026-07-08 22:47 + +from typing import Any + +import django.db.models.deletion +from django.db import migrations, models + + +def repair_people_final_shape(apps: Any, schema_editor: Any) -> None: + Person = apps.get_model("company", "Person") + CompanyPersonLink = apps.get_model("company", "CompanyPersonLink") + ContactMethod = apps.get_model("company", "ContactMethod") + + for link in CompanyPersonLink.objects.filter(person__isnull=True).iterator(): + person = Person.objects.create( + name=link.name, + email=link.email, + is_active=link.is_active, + ) + link.person = person + if link.xero_name is None: + link.xero_name = link.name + link.save(update_fields=["person", "xero_name"]) + + for method in ContactMethod.objects.filter( + person__isnull=True, contact__isnull=False + ).select_related("contact"): + method.person_id = method.contact.person_id + method.contact_id = None + method.save(update_fields=["person", "contact", "updated_at"]) + + # Collapse exact duplicates before final partial unique constraints land. + owner_fields = [ + ("company_id", ContactMethod.objects.filter(company__isnull=False)), + ("person_id", ContactMethod.objects.filter(person__isnull=False)), + ] + for owner_field, queryset in owner_fields: + duplicates = ( + queryset.values(owner_field, "method_type", "normalized_value") + .annotate(count=models.Count("id")) + .filter(count__gt=1) + ) + for duplicate in duplicates: + matches = queryset.filter( + **{ + owner_field: duplicate[owner_field], + "method_type": duplicate["method_type"], + "normalized_value": duplicate["normalized_value"], + } + ).order_by("-is_primary", "created_at", "id") + keep = matches.first() + matches.exclude(pk=keep.pk).delete() + + primary_duplicates = ( + queryset.filter(is_primary=True) + .values(owner_field, "method_type") + .annotate(count=models.Count("id")) + .filter(count__gt=1) + ) + for duplicate in primary_duplicates: + matches = queryset.filter( + **{ + owner_field: duplicate[owner_field], + "method_type": duplicate["method_type"], + "is_primary": True, + } + ).order_by("created_at", "id") + keep = matches.first() + matches.exclude(pk=keep.pk).update(is_primary=False) + + missing_links = CompanyPersonLink.objects.filter(person__isnull=True).count() + missing_methods = ContactMethod.objects.filter( + person__isnull=True, contact__isnull=False + ).count() + ownerless_methods = ContactMethod.objects.filter( + company__isnull=True, person__isnull=True + ).count() + overowned_methods = ContactMethod.objects.filter( + company__isnull=False, person__isnull=False + ).count() + if missing_links or missing_methods or ownerless_methods or overowned_methods: + raise RuntimeError( + "Final people schema repair failed: " + f"missing_links={missing_links}, missing_methods={missing_methods}, " + f"ownerless_methods={ownerless_methods}, " + f"overowned_methods={overowned_methods}" + ) + + +class Migration(migrations.Migration): + + dependencies = [ + ("company", "0005_backfill_person"), + ] + + operations = [ + migrations.AlterModelOptions( + name="companypersonlink", + options={ + "ordering": ["-is_primary", "person__name"], + "verbose_name": "Company Person Link", + "verbose_name_plural": "Company Person Links", + }, + ), + migrations.RemoveConstraint( + model_name="companypersonlink", + name="unique_company_contact_name", + ), + migrations.RemoveField( + model_name="company", + name="additional_contact_persons", + ), + migrations.RemoveField( + model_name="company", + name="primary_contact_email", + ), + migrations.RemoveField( + model_name="company", + name="primary_contact_name", + ), + migrations.RunPython(repair_people_final_shape, migrations.RunPython.noop), + migrations.RemoveField( + model_name="contactmethod", + name="contact", + ), + migrations.AlterField( + model_name="companypersonlink", + name="person", + field=models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="company_links", + to="company.person", + ), + ), + migrations.AddConstraint( + model_name="companypersonlink", + constraint=models.UniqueConstraint( + fields=("company", "person"), name="unique_company_person_link" + ), + ), + migrations.AddConstraint( + model_name="companypersonlink", + constraint=models.UniqueConstraint( + condition=models.Q(("xero_name__isnull", False)), + fields=("company", "xero_name"), + name="unique_company_person_link_xero_name", + ), + ), + migrations.AddConstraint( + model_name="contactmethod", + constraint=models.CheckConstraint( + condition=models.Q( + models.Q(("company__isnull", False), ("person__isnull", True)), + models.Q(("company__isnull", True), ("person__isnull", False)), + _connector="OR", + ), + name="contact_method_one_owner", + ), + ), + migrations.AddConstraint( + model_name="contactmethod", + constraint=models.UniqueConstraint( + condition=models.Q(("company__isnull", False)), + fields=("company", "method_type", "normalized_value"), + name="unique_company_contact_method_value", + ), + ), + migrations.AddConstraint( + model_name="contactmethod", + constraint=models.UniqueConstraint( + condition=models.Q(("person__isnull", False)), + fields=("person", "method_type", "normalized_value"), + name="unique_person_contact_method_value", + ), + ), + migrations.AddConstraint( + model_name="contactmethod", + constraint=models.UniqueConstraint( + condition=models.Q(("company__isnull", False), ("is_primary", True)), + fields=("company", "method_type"), + name="unique_company_primary_contact_method", + ), + ), + migrations.AddConstraint( + model_name="contactmethod", + constraint=models.UniqueConstraint( + condition=models.Q(("is_primary", True), ("person__isnull", False)), + fields=("person", "method_type"), + name="unique_person_primary_contact_method", + ), + ), + migrations.RemoveField( + model_name="companypersonlink", + name="email", + ), + migrations.RemoveField( + model_name="companypersonlink", + name="name", + ), + ] diff --git a/apps/company/migrations/0007_remove_xero_person_identity.py b/apps/company/migrations/0007_remove_xero_person_identity.py new file mode 100644 index 000000000..e89172ba9 --- /dev/null +++ b/apps/company/migrations/0007_remove_xero_person_identity.py @@ -0,0 +1,37 @@ +# Generated by Django 6.0.4 on 2026-07-12 08:32 + +from typing import Any + +from django.db import migrations + + +def delete_unreferenced_people(apps: Any, schema_editor: Any) -> None: + Person = apps.get_model("company", "Person") + database_alias = schema_editor.connection.alias + Person.objects.using(database_alias).filter( + company_links__isnull=True, + contact_methods__isnull=True, + jobs__isnull=True, + phone_calls__isnull=True, + ).delete() + + +class Migration(migrations.Migration): + + dependencies = [ + ("company", "0006_alter_companypersonlink_options_and_more"), + ("crm", "0006_remove_phonecallrecord_contact_and_more"), + ("job", "0006_rename_job_event_people_company_terms"), + ] + + operations = [ + migrations.RunPython(delete_unreferenced_people, migrations.RunPython.noop), + migrations.RemoveConstraint( + model_name="companypersonlink", + name="unique_company_person_link_xero_name", + ), + migrations.RemoveField( + model_name="companypersonlink", + name="xero_name", + ), + ] diff --git a/apps/company/migrations/0008_apply_reviewed_duplicate_cleanup.py b/apps/company/migrations/0008_apply_reviewed_duplicate_cleanup.py new file mode 100644 index 000000000..26aafc441 --- /dev/null +++ b/apps/company/migrations/0008_apply_reviewed_duplicate_cleanup.py @@ -0,0 +1,27 @@ +from django.apps.registry import Apps +from django.db import migrations +from django.db.backends.base.schema import BaseDatabaseSchemaEditor + + +def apply_reviewed_cleanup( + apps: Apps, schema_editor: BaseDatabaseSchemaEditor +) -> None: + # This installation-specific cleanup intentionally uses the tested merge + # services so JobEvents and every cross-app Company FK remain consistent. + from apps.company.services.kan278_duplicate_cleanup import ( + apply_reviewed_duplicate_cleanup, + ) + + apply_reviewed_duplicate_cleanup() + + +class Migration(migrations.Migration): + + dependencies = [ + ("company", "0007_remove_xero_person_identity"), + ("accounting", "0003_rename_client_company"), + ] + + operations = [ + migrations.RunPython(apply_reviewed_cleanup, migrations.RunPython.noop), + ] 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 66% rename from apps/client/models.py rename to apps/company/models.py index 3ec324fac..887551080 100644 --- a/apps/client/models.py +++ b/apps/company/models.py @@ -29,8 +29,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) @@ -44,21 +44,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", @@ -67,9 +67,6 @@ class Client(models.Model): "allow_jobs", "xero_contact_id", "xero_tenant_id", - "primary_contact_name", - "primary_contact_email", - "additional_contact_persons", "xero_last_modified", "xero_last_synced", "xero_archived", @@ -94,14 +91,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) @@ -110,36 +107,29 @@ class Client(models.Model): null=True, blank=True ) # For debugging, stores the raw JSON from Xero - # Fields for the primary contact person - primary_contact_name = models.CharField(max_length=255, null=True, blank=True) - primary_contact_email = models.EmailField(null=True, blank=True) - - # Store all contact persons from the Xero ContactPersons list - additional_contact_persons = models.JSONField(null=True, blank=True, default=list) - django_created_at = models.DateTimeField(auto_now_add=True) django_updated_at = models.DateTimeField(auto_now=True) 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: @@ -147,11 +137,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"], ), ] @@ -177,11 +167,11 @@ def save( def validate_for_xero(self) -> bool: """ - 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 @@ -189,8 +179,8 @@ def validate_for_xero(self) -> bool: 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"] @@ -202,7 +192,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"] @@ -210,7 +200,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. @@ -220,7 +210,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() @@ -246,24 +236,22 @@ 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. Single-object flows only (Xero sync, PO PDFs). Queryset consumers must - use ClientContactMethod.primary_phone_annotation instead. + use ContactMethod.primary_phone_annotation instead. """ method = ( - self.contact_methods.filter( - method_type=ClientContactMethod.MethodType.PHONE - ) + self.contact_methods.filter(method_type=ContactMethod.MethodType.PHONE) .order_by(*PRIMARY_PHONE_ORDERING) .first() ) return method.value if method else "" - def get_final_client(self) -> "Client": + def get_final_company(self) -> "Company": """ - 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. """ @@ -272,7 +260,7 @@ def get_final_client(self) -> "Client": 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 @@ -280,28 +268,55 @@ def get_final_client(self) -> "Client": return current -class ClientContact(models.Model): - """ - Represents a contact person for a client. - This model stores contact information that was previously synced with Xero - but is now managed entirely within our application. - """ +class Person(models.Model): + """A human independent of any single company relationship.""" - # 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) - # 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) - # 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 = [ + PERSON_API_FIELDS = [ "id", - "client", "name", "email", + "is_active", + "created_at", + "updated_at", + ] + + id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) + name = models.CharField(max_length=255, db_index=True) + email = models.EmailField(null=True, blank=True) + is_active = models.BooleanField(default=True) + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + class Meta: + ordering = ["name"] + + def __str__(self) -> str: + return self.name + + def save( + self, + *, + force_insert: bool | tuple[ModelBase, ...] = False, + force_update: bool = False, + using: str | None = None, + update_fields: Iterable[str] | None = None, + ) -> None: + update_fields = _augment_update_fields(update_fields, "updated_at") + super().save( + force_insert=force_insert, + force_update=force_update, + using=using, + update_fields=update_fields, + ) + + +class CompanyPersonLink(models.Model): + """A person's role/relationship at a company.""" + + COMPANYPERSONLINK_API_FIELDS = [ + "id", + "company", + "person", "position", "is_primary", "notes", @@ -310,22 +325,22 @@ class ClientContact(models.Model): "updated_at", ] - # No internal fields for ClientContact - all fields are exposed - CLIENTCONTACT_INTERNAL_FIELDS = [] - - # All ClientContact model fields (derived) - CLIENTCONTACT_ALL_FIELDS = CLIENTCONTACT_API_FIELDS + CLIENTCONTACT_INTERNAL_FIELDS + COMPANYPERSONLINK_INTERNAL_FIELDS: list[str] = [] + COMPANYPERSONLINK_ALL_FIELDS = ( + COMPANYPERSONLINK_API_FIELDS + COMPANYPERSONLINK_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( - null=True, blank=True, help_text="Email address of the contact" + person = models.ForeignKey( + Person, + on_delete=models.CASCADE, + related_name="company_links", ) position = models.CharField( max_length=255, @@ -335,7 +350,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" @@ -348,18 +363,18 @@ class ClientContact(models.Model): updated_at = models.DateTimeField(auto_now=True) class Meta: - ordering = ["-is_primary", "name"] - verbose_name = "Client Contact" - verbose_name_plural = "Client Contacts" + ordering = ["-is_primary", "person__name"] + verbose_name = "Company Person Link" + verbose_name_plural = "Company Person Links" constraints = [ models.UniqueConstraint( - fields=["client", "name"], - name="unique_client_contact_name", + fields=["company", "person"], + name="unique_company_person_link", ), ] def __str__(self): - return f"{self.name} ({self.client.name})" + return f"{self.person.name} ({self.company.name})" def save( self, @@ -370,13 +385,13 @@ def save( update_fields: Iterable[str] | None = None, ) -> None: 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 - if self.is_primary: - ClientContact.objects.filter(client=self.client, is_primary=True).exclude( - id=self.id - ).update(is_primary=False) + # If this link is being set as primary, ensure no other links + # for this company are marked as primary + if self.is_primary and self.is_active: + db = using or self._state.db or DEFAULT_DB_ALIAS + CompanyPersonLink.objects.using(db).filter( + company_id=self.company_id, is_primary=True, is_active=True + ).exclude(id=self.id).update(is_primary=False) super().save( force_insert=force_insert, force_update=force_update, @@ -386,14 +401,14 @@ 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/person owner. - ``conflict`` is the existing :class:`ClientContactMethod` owned by a - different effective client, or ``None`` when the number is an active + ``conflict`` is the existing :class:`ContactMethod` owned by a + different effective company, or ``None`` when the number is an active internal :class:`~apps.crm.models.PhoneEndpoint`. """ - def __init__(self, conflict: "ClientContactMethod | None") -> None: + def __init__(self, conflict: "ContactMethod | None") -> None: self.conflict = conflict if conflict is None: message = "phone number is an active internal phone endpoint" @@ -406,11 +421,11 @@ def __init__(self, conflict: "ClientContactMethod | None") -> None: # stable tie-break. Single source — every primary-phone consumer must use it. PRIMARY_PHONE_ORDERING: Final[tuple[str, str, str]] = ("-is_primary", "label", "value") -PhoneOwner = Literal["client", "contact"] +PhoneOwner = Literal["company", "person"] -class ClientContactMethod(models.Model): - """Phone or email address owned by a client or one of its contacts.""" +class ContactMethod(models.Model): + """Phone or email address owned by a company or person.""" class MethodType(models.TextChoices): PHONE = "phone", "Phone" @@ -421,15 +436,15 @@ 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, related_name="contact_methods", ) - contact = models.ForeignKey( - ClientContact, + person = models.ForeignKey( + Person, on_delete=models.CASCADE, null=True, blank=True, @@ -450,48 +465,40 @@ class Source(models.TextChoices): class Meta: ordering = ["method_type", "-is_primary", "label", "value"] - verbose_name = "Client Contact Method" - verbose_name_plural = "Client Contact Methods" + verbose_name = "Contact Method" + verbose_name_plural = "Contact Methods" 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, person__isnull=True) + | models.Q(company__isnull=True, person__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), + name="unique_company_contact_method_value", ), models.UniqueConstraint( - fields=["contact", "method_type", "normalized_value"], - condition=models.Q(client__isnull=True, contact__isnull=False), - name="unique_contact_contact_method_value", + fields=["person", "method_type", "normalized_value"], + condition=models.Q(person__isnull=False), + name="unique_person_contact_method_value", ), models.UniqueConstraint( - fields=["client", "method_type"], - condition=models.Q( - client__isnull=False, - contact__isnull=True, - is_primary=True, - ), - name="unique_client_primary_contact_method", + fields=["company", "method_type"], + condition=models.Q(company__isnull=False, is_primary=True), + name="unique_company_primary_contact_method", ), models.UniqueConstraint( - fields=["contact", "method_type"], - condition=models.Q( - client__isnull=True, - contact__isnull=False, - is_primary=True, - ), - name="unique_contact_primary_contact_method", + fields=["person", "method_type"], + condition=models.Q(person__isnull=False, is_primary=True), + name="unique_person_primary_contact_method", ), ] def __str__(self) -> str: - owner = self.contact or self.client + owner = self.person or self.company return f"{self.method_type}: {self.value} ({owner})" @classmethod @@ -499,7 +506,7 @@ def primary_phone_annotation(cls, *, owner: PhoneOwner, outer_ref: str) -> Coale """Queryset annotation: the owner's primary phone value, "" when it has none. ``outer_ref`` names the outer queryset's column holding the owner's id - (e.g. "pk" on a Client queryset, "client_id" on a Job queryset). + (e.g. "pk" on a Company queryset, "company_id" on a Job queryset). """ candidates = ( cls.objects.filter( @@ -514,6 +521,21 @@ def primary_phone_annotation(cls, *, owner: PhoneOwner, outer_ref: str) -> Coale output_field=models.CharField(), ) + @classmethod + def primary_phone_for_link_annotation(cls) -> Coalesce: + """Primary phone for a company-person link.""" + candidates = ( + cls.objects.filter(method_type=cls.MethodType.PHONE) + .filter(person=models.OuterRef("person_id")) + .order_by(*PRIMARY_PHONE_ORDERING) + .values("value")[:1] + ) + return Coalesce( + models.Subquery(candidates), + models.Value(""), + output_field=models.CharField(), + ) + def save( self, *, @@ -535,8 +557,8 @@ def save( try: type(self).check_phone_assignment( self.normalized_value, - client_id=self.client_id, - contact=self.contact, + company_id=self.company_id, + person=self.person, instance=self, using=db, ) @@ -544,7 +566,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 " @@ -553,19 +575,17 @@ def save( if self.is_primary: queryset = ( - ClientContactMethod.objects.using(db) + ContactMethod.objects.using(db) .filter( method_type=self.method_type, is_primary=True, ) .exclude(id=self.id) ) - if self.contact_id: - queryset = queryset.filter(contact_id=self.contact_id) + if self.person_id: + queryset = queryset.filter(person_id=self.person_id) else: - queryset = queryset.filter( - client_id=self.client_id, contact__isnull=True - ) + queryset = queryset.filter(company_id=self.company_id) queryset.update(is_primary=False) super().save( @@ -594,31 +614,58 @@ 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 - if self.contact is not None: - return self.contact.client_id + def owner_company_id(self) -> uuid.UUID | None: + """The effective company that owns this method (directly or via its contact).""" + company_ids = self.owner_company_ids() + if len(company_ids) == 1: + return next(iter(company_ids)) + if company_ids: + return sorted(company_ids)[0] return None + def owner_company_ids(self) -> set[uuid.UUID]: + """Companies this method can be traced to through its owner.""" + if self.company_id: + return {self.company_id} + if self.person is not None: + if "company_links" in getattr(self.person, "_prefetched_objects_cache", {}): + return { + link.company_id + for link in self.person.company_links.all() + if link.is_active + } + return set( + self.person.company_links.filter(is_active=True).values_list( + "company_id", flat=True + ) + ) + return set() + @classmethod - def conflicting_client( + def conflicting_company( cls, normalized_value: str, - effective_client_id: uuid.UUID | None, + effective_company_ids: set[uuid.UUID], 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. + ) -> "ContactMethod | None": + """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", "person") + .prefetch_related( + models.Prefetch( + "person__company_links", + queryset=CompanyPersonLink.objects.using(using).filter( + is_active=True + ), + ) + ) .filter( method_type=cls.MethodType.PHONE, normalized_value=normalized_value, @@ -630,7 +677,7 @@ def conflicting_client( ( other for other in queryset - if other.owner_client_id() != effective_client_id + if not other.owner_company_ids().intersection(effective_company_ids) ), None, ) @@ -640,41 +687,41 @@ def check_phone_assignment( cls, normalized_value: str, *, - client_id: uuid.UUID | None, - contact: "ClientContact | None", - instance: "ClientContactMethod | None" = None, + company_id: uuid.UUID | None, + person: "Person | None" = None, + instance: "ContactMethod | 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 ``conflict=None`` for an internal-endpoint collision. """ - contact_id = contact.pk if contact is not None else None + person_id = person.pk if person is not None else None if instance is not None and not instance._state.adding: stored = ( cls.objects.using(using) .filter(pk=instance.pk) - .values("normalized_value", "client_id", "contact_id") + .values("normalized_value", "company_id", "person_id") .first() ) if ( stored is not None and stored["normalized_value"] == normalized_value - and stored["client_id"] == client_id - and stored["contact_id"] == contact_id + and stored["company_id"] == company_id + and stored["person_id"] == person_id ): return # unchanged association -> grandfathered @@ -687,15 +734,19 @@ def check_phone_assignment( ): raise PhoneAssignmentConflictError(None) - if client_id is not None: - effective_client_id = client_id - elif contact is not None: - effective_client_id = contact.client_id + if company_id is not None: + effective_company_ids = {company_id} + elif person is not None: + effective_company_ids = set( + CompanyPersonLink.objects.using(using) + .filter(person_id=person.pk, is_active=True) + .values_list("company_id", flat=True) + ) else: - effective_client_id = None - conflict = cls.conflicting_client( + effective_company_ids = set() + conflict = cls.conflicting_company( normalized_value, - effective_client_id, + effective_company_ids, exclude_id=instance.pk if instance is not None else None, using=using, ) @@ -703,29 +754,28 @@ def check_phone_assignment( raise PhoneAssignmentConflictError(conflict) 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}" + if self.person: + return f"person {self.person.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", ) @@ -738,20 +788,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. @@ -759,9 +809,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 @@ -769,7 +819,7 @@ class SupplierPickupAddress(models.Model): # Database fields exposed via API serializers SUPPLIERPICKUPADDRESS_API_FIELDS = [ "id", - "client", + "company", "name", "street", "suburb", @@ -796,8 +846,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", @@ -878,13 +928,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: @@ -903,13 +953,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/company/person_serializers.py b/apps/company/person_serializers.py new file mode 100644 index 000000000..5707e50ee --- /dev/null +++ b/apps/company/person_serializers.py @@ -0,0 +1,207 @@ +"""API contracts for first-class People and their company relationships.""" + +from typing import Any + +from drf_spectacular.utils import extend_schema_field +from rest_framework import serializers + +from apps.company.models import CompanyPersonLink, ContactMethod, Person +from apps.company.services.person_service import ( + PersonCompanyLinkData, + PersonDirectoryService, + PhoneCompanyOwner, + PhoneOwnershipResult, + PhonePersonMatch, +) + + +class PersonCompanyLinkSerializer( + serializers.Serializer[PersonCompanyLinkData | list[PersonCompanyLinkData]] +): + company_id = serializers.UUIDField() + company_name = serializers.CharField() + position = serializers.CharField(allow_null=True) + is_primary = serializers.BooleanField() + notes = serializers.CharField(allow_null=True) + is_active = serializers.BooleanField() + + +class PersonCompanySummarySerializer(serializers.Serializer[dict[str, str]]): + company_id = serializers.UUIDField() + company_name = serializers.CharField() + + +class PersonSummarySerializer(serializers.ModelSerializer[Person]): + # Declared explicitly so the response contract says always-present. The + # ModelSerializer default would infer required=False from the model default, + # which renders as optional in the schema even though every row carries it. + # These serializers are response-only; identity writes use + # PersonIdentityUpdateSerializer. + is_active = serializers.BooleanField() + primary_phone = serializers.SerializerMethodField() + companies = serializers.SerializerMethodField() + + class Meta: + model = Person + fields = ["id", "name", "email", "is_active", "primary_phone", "companies"] + + def get_primary_phone(self, person: Person) -> str: + if "primary_phone" in person.__dict__: + return str(person.__dict__["primary_phone"]) + method = ( + person.contact_methods.filter(method_type=ContactMethod.MethodType.PHONE) + .order_by("-is_primary", "label", "value") + .first() + ) + return method.value if method else "" + + @extend_schema_field(PersonCompanySummarySerializer(many=True)) + def get_companies(self, person: Person) -> list[dict[str, str]]: + return [ + { + "company_id": link["company_id"], + "company_name": link["company_name"], + } + for link in sorted( + ( + link + for link in PersonDirectoryService.company_links(person) + if link["is_active"] + ), + key=lambda link: link["company_name"], + ) + ] + + +class PersonDetailSerializer(PersonSummarySerializer): + company_links = serializers.SerializerMethodField() + + class Meta(PersonSummarySerializer.Meta): + fields = [ + "id", + "name", + "email", + "is_active", + "created_at", + "updated_at", + "primary_phone", + "companies", + "company_links", + ] + + @extend_schema_field(PersonCompanyLinkSerializer(many=True)) + def get_company_links(self, person: Person) -> list[PersonCompanyLinkData]: + return list(PersonDirectoryService.company_links(person)) + + +class PersonIdentityUpdateSerializer(serializers.ModelSerializer[Person]): + class Meta: + model = Person + fields = ["name", "email"] + extra_kwargs = { + "name": {"required": False}, + "email": { + "required": False, + "allow_blank": True, + "allow_null": True, + }, + } + + +class CompanyPersonSerializer(serializers.ModelSerializer[CompanyPersonLink]): + person_id = serializers.UUIDField(source="person.id", read_only=True) + person_name = serializers.CharField(source="person.name", read_only=True) + person_email = serializers.EmailField( + source="person.email", read_only=True, allow_null=True + ) + primary_phone = serializers.CharField(source="phone", read_only=True) + + class Meta: + model = CompanyPersonLink + fields = [ + "person_id", + "person_name", + "person_email", + "primary_phone", + "position", + "is_primary", + "notes", + ] + + +class CompanyPersonCreateSerializer(serializers.Serializer[None]): + name = serializers.CharField(max_length=255) + email = serializers.EmailField(required=False, allow_blank=True, allow_null=True) + phone = serializers.CharField(required=False, allow_blank=True, allow_null=True) + position = serializers.CharField( + required=False, allow_blank=True, allow_null=True, max_length=255 + ) + notes = serializers.CharField(required=False, allow_blank=True, allow_null=True) + is_primary = serializers.BooleanField(required=False, default=False) + + def validate_phone(self, value: str | None) -> str | None: + if value and not ContactMethod.normalize_phone(value): + raise serializers.ValidationError( + "Phone number must contain at least one digit" + ) + return value + + +class CompanyLinkWriteSerializer(serializers.Serializer[None]): + position = serializers.CharField( + required=False, allow_blank=True, allow_null=True, max_length=255, default=None + ) + notes = serializers.CharField( + required=False, allow_blank=True, allow_null=True, default=None + ) + is_primary = serializers.BooleanField(required=False, default=False) + + +class PhoneOwnershipRequestSerializer(serializers.Serializer[None]): + phone = serializers.CharField() + + def validate_phone(self, value: str) -> str: + if not ContactMethod.normalize_phone(value): + raise serializers.ValidationError( + "Phone number must contain at least one digit" + ) + return value + + +class PhonePersonMatchSerializer(serializers.Serializer[PhonePersonMatch]): + person_id = serializers.UUIDField() + person_name = serializers.CharField() + person_email = serializers.EmailField(allow_null=True) + company_links = PersonCompanyLinkSerializer(many=True) + + +class PhoneCompanyOwnerSerializer(serializers.Serializer[PhoneCompanyOwner]): + company_id = serializers.UUIDField() + company_name = serializers.CharField() + + +class PhoneOwnershipSerializer(serializers.Serializer[PhoneOwnershipResult]): + status = serializers.ChoiceField( + choices=["available", "people", "company", "internal"] + ) + normalized_phone = serializers.CharField() + can_create_person = serializers.BooleanField() + people = PhonePersonMatchSerializer(many=True) + companies = PhoneCompanyOwnerSerializer(many=True) + + +class PhoneOwnershipConflictSerializer(PhoneOwnershipSerializer): + pass + + +class PersonContactMethodWriteSerializer(serializers.Serializer[None]): + method_type = serializers.ChoiceField(choices=ContactMethod.MethodType.choices) + value = serializers.CharField(max_length=255) + is_primary = serializers.BooleanField(required=False, default=False) + + def get_fields(self) -> dict[str, "serializers.Field[Any, Any, Any, Any]"]: + fields = super().get_fields() + fields["label"] = serializers.CharField( + required=False, allow_blank=True, default="" + ) + return fields diff --git a/apps/client/serializers.py b/apps/company/serializers.py similarity index 50% rename from apps/client/serializers.py rename to apps/company/serializers.py index 6704bd5f6..31cf1f281 100644 --- a/apps/client/serializers.py +++ b/apps/company/serializers.py @@ -1,20 +1,37 @@ -from typing import Any +from typing import Any, NotRequired, TypedDict from django.core.exceptions import ValidationError as DjangoValidationError from django.db import transaction from rest_framework import serializers -from apps.client.models import ( - Client, - ClientContact, - ClientContactMethod, +from apps.company.models import ( + Company, + CompanyPersonLink, + ContactMethod, + Person, PhoneAssignmentConflictError, SupplierPickupAddress, SupplierSearchAlias, ) -def set_primary_phone(owner: Client | ClientContact, raw_value: str) -> None: +def _ordered_company_links(person: Person) -> list[CompanyPersonLink]: + prefetched_links = getattr(person, "_prefetched_objects_cache", {}).get( + "company_links" + ) + if prefetched_links is not None: + return sorted( + prefetched_links, + key=lambda link: (not link.is_primary, link.company.name), + ) + return list( + person.company_links.select_related("company").order_by( + "-is_primary", "company__name" + ) + ) + + +def set_primary_phone(owner: Company | Person, raw_value: str) -> None: """Point the owner's primary phone at ``raw_value`` (non-blank). Reuses an existing method carrying the same normalized number (promoting @@ -27,15 +44,20 @@ def set_primary_phone(owner: Client | ClientContact, raw_value: str) -> None: from apps.crm.tasks import rematch_phone_calls_task value = raw_value.strip() - owner_field = "client" if isinstance(owner, Client) else "contact" - phone_methods = ClientContactMethod.objects.filter( - method_type=ClientContactMethod.MethodType.PHONE, **{owner_field: owner} + if isinstance(owner, Company): + owner_field = "company" + else: + owner_field = "person" + phone_methods = ContactMethod.objects.filter( + method_type=ContactMethod.MethodType.PHONE, **{owner_field: owner} ) old_primary = phone_methods.filter(is_primary=True).first() old_number = old_primary.normalized_value if old_primary else "" - same_number = phone_methods.filter( - normalized_value=ClientContactMethod.normalize_phone(value) - ).first() + normalized_value = ContactMethod.normalize_phone(value) + if not normalized_value: + raise DjangoValidationError("Phone number must contain at least one digit") + + same_number = phone_methods.filter(normalized_value=normalized_value).first() if same_number is not None: same_number.value = value @@ -47,8 +69,8 @@ def set_primary_phone(owner: Client | ClientContact, raw_value: str) -> None: old_primary.save() method = old_primary else: - method = ClientContactMethod.objects.create( - method_type=ClientContactMethod.MethodType.PHONE, + method = ContactMethod.objects.create( + method_type=ContactMethod.MethodType.PHONE, value=value, is_primary=True, **{owner_field: owner}, @@ -60,24 +82,95 @@ def set_primary_phone(owner: Client | ClientContact, raw_value: str) -> None: transaction.on_commit(lambda: rematch_phone_calls_task.delay(numbers)) -class ClientContactSerializer(serializers.ModelSerializer): - """Serializer for ClientContact model.""" +class CompanyPersonCreateAttrs(TypedDict): + """DRF-normalized person attrs required when creating a company link.""" + + name: str + email: NotRequired[str | None] + + +class CompanyPersonUpdateAttrs(TypedDict, total=False): + """DRF-normalized person attrs accepted when updating a company link.""" + + name: str + email: str | None + + +class CompanyPersonLinkCreateAttrs(TypedDict): + """DRF-normalized writable attrs for CompanyPersonLinkSerializer.create.""" + + company: Company + person: CompanyPersonCreateAttrs + position: NotRequired[str | None] + is_primary: NotRequired[bool] + notes: NotRequired[str | None] + phone: NotRequired[str | None] + + +class CompanyPersonLinkModelCreateAttrs(TypedDict): + """Model attrs passed to DRF after the nested person attrs are persisted.""" + + company: Company + person: Person + position: NotRequired[str | None] + is_primary: NotRequired[bool] + notes: NotRequired[str | None] + - # Backed by ClientContactMethod: reads come from the viewset's +class CompanyPersonLinkUpdateAttrs(TypedDict, total=False): + """DRF-normalized writable attrs for CompanyPersonLinkSerializer.update.""" + + company: Company + person: CompanyPersonUpdateAttrs + position: str | None + is_primary: bool + notes: str | None + phone: str | None + + +class CompanyPersonLinkSerializer(serializers.ModelSerializer): + """Serializer for a person's relationship to a company.""" + + person = serializers.UUIDField(source="person_id", read_only=True) + person_name = serializers.CharField(source="person.name") + person_email = serializers.EmailField( + source="person.email", required=False, allow_blank=True, allow_null=True + ) + # Backed by ContactMethod: reads come from the viewset's # primary_phone_annotation; writes upsert the contact's primary method. phone = serializers.CharField( required=False, allow_blank=True, allow_null=True, default="" ) class Meta: - model = ClientContact - fields = ClientContact.CLIENTCONTACT_API_FIELDS + ["phone"] + model = CompanyPersonLink + fields = [ + "id", + "company", + "person", + "person_name", + "person_email", + "position", + "is_primary", + "notes", + "is_active", + "created_at", + "updated_at", + "phone", + ] read_only_fields = ["id", "is_active", "created_at", "updated_at"] + extra_kwargs = { + "company": {"help_text": "The company this person is linked to"}, + "is_primary": { + "help_text": "Indicates if this is the primary person for the company" + }, + "notes": {"help_text": "Additional notes about this person"}, + } def to_internal_value(self, data: Any) -> Any: """Convert empty strings to None for nullable fields before validation.""" # Fields that should be NULL instead of empty string - nullable_fields = ["email", "position", "notes"] + nullable_fields = ["person_email", "position", "notes"] for field in nullable_fields: if field in data and data[field] == "": @@ -85,53 +178,81 @@ def to_internal_value(self, data: Any) -> Any: return super().to_internal_value(data) - def create(self, validated_data): - raw_phone = validated_data.pop("phone", None) # not a model field - contact = super().create(validated_data) - return self._apply_phone(contact, raw_phone) - - def update(self, instance, validated_data): + def create(self, validated_data: CompanyPersonLinkCreateAttrs) -> CompanyPersonLink: + raw_phone = validated_data.get("phone") # not a model field + person_value = validated_data["person"] + with transaction.atomic(): + person = Person.objects.create( + name=person_value["name"], + email=person_value.get("email"), + is_active=True, + ) + link_data: CompanyPersonLinkModelCreateAttrs = { + "company": validated_data["company"], + "person": person, + } + if "position" in validated_data: + link_data["position"] = validated_data["position"] + if "is_primary" in validated_data: + link_data["is_primary"] = validated_data["is_primary"] + if "notes" in validated_data: + link_data["notes"] = validated_data["notes"] + link = super().create(link_data) + return self._apply_phone(link, raw_phone) + + def update( + self, instance: CompanyPersonLink, validated_data: CompanyPersonLinkUpdateAttrs + ) -> CompanyPersonLink: raw_phone = validated_data.pop("phone", None) # not a model field - contact = super().update(instance, validated_data) - return self._apply_phone(contact, raw_phone) + person_data = validated_data.pop("person", None) + with transaction.atomic(): + link = super().update(instance, validated_data) + if person_data is not None: + person_update_fields = ["updated_at"] + if "name" in person_data: + link.person.name = person_data["name"] + person_update_fields.append("name") + if "email" in person_data: + link.person.email = person_data["email"] + person_update_fields.append("email") + link.person.save(update_fields=person_update_fields) + return self._apply_phone(link, raw_phone) def _apply_phone( - self, contact: ClientContact, raw_phone: str | None - ) -> ClientContact: + self, link: CompanyPersonLink, raw_phone: str | None + ) -> CompanyPersonLink: """Upsert the primary phone; blank/omitted input is a no-op (deleting numbers is PhoneNumberManager's job, not this form's).""" if raw_phone and raw_phone.strip(): try: - set_primary_phone(contact, raw_phone) + set_primary_phone(link.person, raw_phone) except DjangoValidationError as exc: raise serializers.ValidationError({"phone": exc.messages}) from exc else: pass # blank phone: leave existing contact methods untouched # The phone field always reads from a queryset annotation; give the # write response the same shape by re-fetching through it. - return ClientContact.objects.annotate( - phone=ClientContactMethod.primary_phone_annotation( - owner="contact", outer_ref="pk" - ) - ).get(pk=contact.pk) + return CompanyPersonLink.objects.annotate( + phone=ContactMethod.primary_phone_for_link_annotation() + ).get(pk=link.pk) -class ClientContactMethodSerializer(serializers.ModelSerializer): - """Serializer for canonical client/contact phone and email methods.""" +class ContactMethodSerializer(serializers.ModelSerializer): + """Serializer for canonical company/person phone and email methods.""" - owner_client = serializers.SerializerMethodField() - client_name = serializers.SerializerMethodField() - contact_name = serializers.SerializerMethodField() + owner_company = serializers.SerializerMethodField() + company_name = serializers.SerializerMethodField() + person_name = serializers.SerializerMethodField() class Meta: - model = ClientContactMethod + model = ContactMethod fields = [ "id", - "client", - "owner_client", - "client_name", - "contact", - "contact_name", + "company", + "owner_company", + "company_name", + "person", + "person_name", "method_type", "value", "normalized_value", @@ -143,45 +264,55 @@ class Meta: ] read_only_fields = [ "id", - "owner_client", - "client_name", - "contact_name", + "owner_company", + "company_name", + "person_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_client_name(self, obj: ClientContactMethod) -> str: - if obj.client: - return obj.client.name - return obj.contact.client.name if obj.contact else "" - - def get_contact_name(self, obj: ClientContactMethod) -> str: - return obj.contact.name if obj.contact else "" + def get_owner_company(self, obj: ContactMethod) -> str: + if obj.company_id: + return str(obj.company_id) + if obj.person_id: + company_id = obj.owner_company_id() + return str(company_id) if company_id else "" + return "" + + def get_company_name(self, obj: ContactMethod) -> str: + if obj.company: + return obj.company.name + if obj.person_id: + person = obj.person + if person is None: + raise RuntimeError(f"Contact method {obj.id} has no person") + link = next(iter(_ordered_company_links(person)), None) + return link.company.name if link else "" + return "" + + def get_person_name(self, obj: ContactMethod) -> str: + return obj.person.name if obj.person else "" def validate(self, attrs: dict[str, Any]) -> dict[str, Any]: - client = attrs.get("client", getattr(self.instance, "client", None)) - contact = attrs.get("contact", getattr(self.instance, "contact", None)) - if bool(client) == bool(contact): + company = attrs.get("company", getattr(self.instance, "company", None)) + person = attrs.get("person", getattr(self.instance, "person", None)) + owner_count = sum(1 for owner in (company, person) if owner) + if owner_count != 1: raise serializers.ValidationError( - "Exactly one of client or contact is required" + "Exactly one of company or person is required" ) method_type = attrs.get( "method_type", getattr(self.instance, "method_type", None) ) value = attrs.get("value", getattr(self.instance, "value", None)) - if method_type == ClientContactMethod.MethodType.PHONE: - normalized = ClientContactMethod.normalize_phone(value) + if method_type == ContactMethod.MethodType.PHONE: + normalized = ContactMethod.normalize_phone(value) try: - ClientContactMethod.check_phone_assignment( + ContactMethod.check_phone_assignment( normalized, - client_id=client.id if client else None, - contact=contact, + company_id=company.id if company else None, + person=person, instance=self.instance, ) except PhoneAssignmentConflictError as exc: @@ -189,7 +320,7 @@ def validate(self, attrs: dict[str, Any]) -> dict[str, Any]: raise serializers.ValidationError( { "value": "Internal phone endpoint cannot be assigned " - "to a client." + "to a company." } ) from exc raise serializers.ValidationError( @@ -240,16 +371,16 @@ def to_internal_value(self, data: Any) -> Any: return super().to_internal_value(data) -class ClientSerializer(serializers.ModelSerializer): - contacts = ClientContactSerializer(many=True, read_only=True) +class CompanySerializer(serializers.ModelSerializer): + contacts = CompanyPersonLinkSerializer(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 @@ -259,9 +390,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"] @@ -273,15 +404,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() @@ -296,10 +427,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() @@ -307,12 +438,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): @@ -327,28 +458,28 @@ 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) - # Stored as the client's primary ClientContactMethod, not a Client column + # Stored as the client's primary ContactMethod, not a Client column phone = serializers.CharField(required=False, allow_blank=True, allow_null=True) address = serializers.CharField(required=False, allow_blank=True, allow_null=True) is_account_customer = serializers.BooleanField(default=True) 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() @@ -356,16 +487,16 @@ class ClientErrorResponseSerializer(serializers.Serializer): error_id = 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() @@ -376,9 +507,6 @@ class ClientDetailResponseSerializer(serializers.Serializer): allow_jobs = serializers.BooleanField() xero_contact_id = serializers.CharField(allow_blank=True) xero_tenant_id = serializers.CharField(allow_blank=True) - primary_contact_name = serializers.CharField(allow_blank=True) - primary_contact_email = serializers.CharField(allow_blank=True) - additional_contact_persons = serializers.ListField(required=False) xero_last_modified = serializers.DateTimeField(allow_null=True) xero_last_synced = serializers.DateTimeField(allow_null=True) xero_archived = serializers.BooleanField() @@ -391,52 +519,49 @@ class ClientDetailResponseSerializer(serializers.Serializer): phone = serializers.CharField(allow_blank=True) -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) - # Stored as the client's primary ClientContactMethod, not a Client column + # Stored as the client's primary ContactMethod, not a Client column phone = serializers.CharField(required=False, allow_blank=True, allow_null=True) address = serializers.CharField(required=False, allow_blank=True) is_account_customer = serializers.BooleanField(required=False) 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() -class JobContactBaseSerializer(serializers.Serializer): - """Fields shared by the job contact response and update serializers.""" +class JobPersonBaseSerializer(serializers.Serializer): + """Fields shared by the job person response and update serializers.""" id = serializers.UUIDField() name = serializers.CharField() email = serializers.CharField(allow_blank=True, allow_null=True) - position = serializers.CharField(allow_blank=True, allow_null=True) - is_primary = serializers.BooleanField() - notes = serializers.CharField(allow_blank=True, allow_null=True) -class JobContactResponseSerializer(JobContactBaseSerializer): - """Serializer for job contact information response""" +class JobPersonResponseSerializer(JobPersonBaseSerializer): + """Serializer for job person information response""" -class JobContactUpdateSerializer(JobContactBaseSerializer): - """Serializer for job contact update request""" +class JobPersonUpdateSerializer(JobPersonBaseSerializer): + """Serializer for job person 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() @@ -450,7 +575,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/company/services/__init__.py b/apps/company/services/__init__.py new file mode 100644 index 000000000..c617787c2 --- /dev/null +++ b/apps/company/services/__init__.py @@ -0,0 +1,141 @@ +# This file is autogenerated by update_init.py script + +# Conditional imports (only when Django is ready) +try: + from django.apps import apps + + if apps.ready: + from .company_merge_service import ( + CompanyMergeCounts, + merge_companies, + reassign_company_fk_records, + ) + from .company_rest_service import CompanyPhoneAnnotations, CompanyRestService + from .duplicate_identity_report import ( + DuplicateCompanyGroup, + DuplicateCompanyMember, + DuplicateIdentityEvidence, + DuplicateIdentityReport, + DuplicateIdentityReportService, + DuplicateIdentityReportSummary, + DuplicatePersonGroup, + DuplicatePersonMember, + normalize_company_address, + normalize_company_name, + person_names_strongly_compatible, + ) + from .duplicate_person_report import ( + DuplicatePersonCandidate, + DuplicatePersonCompanyLink, + DuplicatePersonContactMethod, + DuplicatePersonMatch, + DuplicatePersonReport, + DuplicatePersonReportService, + DuplicatePersonReportSummary, + DuplicatePersonSummary, + normalize_person_email, + normalize_person_name, + person_names_compatible, + ) + from .duplicate_phone_report import ( + DuplicatePhoneIssue, + DuplicatePhoneOwner, + DuplicatePhoneReportService, + DuplicatePhoneSummary, + DuplicatePhonesReport, + ) + from .geocoding_service import ( + GeocodingError, + GeocodingNotConfiguredError, + GeocodingResult, + geocode_address, + get_api_key, + ) + from .kan278_duplicate_cleanup import ( + CompanyMergeDecision, + InvalidLinkDecision, + PersonMergeDecision, + PersonSelector, + RetainedDecision, + apply_reviewed_duplicate_cleanup, + ) + from .person_merge_service import PersonMergeCounts, merge_people + from .person_service import ( + CompanyLinkData, + NewPersonData, + PersonCompanyLinkData, + PersonDirectoryService, + PersonPhoneConflictError, + PhoneCompanyOwner, + PhoneOwnershipResult, + PhonePersonMatch, + archive_person, + classify_phone_ownership, + create_person_for_company, + put_company_link, + remove_company_link, + ) +except (ImportError, RuntimeError): + # Django not ready or circular import, skip conditional imports + pass + +__all__ = [ + "CompanyLinkData", + "CompanyMergeCounts", + "CompanyMergeDecision", + "CompanyPhoneAnnotations", + "CompanyRestService", + "DuplicateCompanyGroup", + "DuplicateCompanyMember", + "DuplicateIdentityEvidence", + "DuplicateIdentityReport", + "DuplicateIdentityReportService", + "DuplicateIdentityReportSummary", + "DuplicatePersonCandidate", + "DuplicatePersonCompanyLink", + "DuplicatePersonContactMethod", + "DuplicatePersonGroup", + "DuplicatePersonMatch", + "DuplicatePersonMember", + "DuplicatePersonReport", + "DuplicatePersonReportService", + "DuplicatePersonReportSummary", + "DuplicatePersonSummary", + "DuplicatePhoneIssue", + "DuplicatePhoneOwner", + "DuplicatePhoneReportService", + "DuplicatePhoneSummary", + "DuplicatePhonesReport", + "GeocodingError", + "GeocodingNotConfiguredError", + "GeocodingResult", + "InvalidLinkDecision", + "NewPersonData", + "PersonCompanyLinkData", + "PersonDirectoryService", + "PersonMergeCounts", + "PersonMergeDecision", + "PersonPhoneConflictError", + "PersonSelector", + "PhoneCompanyOwner", + "PhoneOwnershipResult", + "PhonePersonMatch", + "RetainedDecision", + "apply_reviewed_duplicate_cleanup", + "archive_person", + "classify_phone_ownership", + "create_person_for_company", + "geocode_address", + "get_api_key", + "merge_companies", + "merge_people", + "normalize_company_address", + "normalize_company_name", + "normalize_person_email", + "normalize_person_name", + "person_names_compatible", + "person_names_strongly_compatible", + "put_company_link", + "reassign_company_fk_records", + "remove_company_link", +] diff --git a/apps/company/services/company_merge_service.py b/apps/company/services/company_merge_service.py new file mode 100644 index 000000000..47835f47f --- /dev/null +++ b/apps/company/services/company_merge_service.py @@ -0,0 +1,292 @@ +""" +Reassign FK records from one Company (source) to another (destination). + +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_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 +from typing import TypedDict +from uuid import UUID + +from django.db import transaction +from django.utils import timezone + +from apps.accounts.models import Staff +from apps.company.models import Company, CompanyPersonLink, ContactMethod +from apps.workflow.services.error_persistence import persist_app_error + +logger = logging.getLogger("xero") + + +class CompanyMergeCounts(TypedDict): + jobs: int + contacts: int + contact_methods: int + phone_calls: int + invoices: int + bills: int + credit_notes: int + quotes: int + purchase_orders: int + supplier_products: int + supplier_price_lists: int + scrape_jobs: int + + +def _move_company_contact_methods(source: Company, destination: Company) -> int: + """Move only company-owned methods during a company merge.""" + + source_queryset = ContactMethod.objects.filter(company=source) + destination_queryset = ContactMethod.objects.filter(company=destination) + + affected = 0 + + # Drop source methods the destination already owns (same method_type + + # normalized_value), respecting the per-owner unique constraints. + destination_pairs = set( + destination_queryset.values_list("method_type", "normalized_value") + ) + duplicate_ids = [ + method_id + for method_id, method_type, normalized_value in source_queryset.values_list( + "id", "method_type", "normalized_value" + ) + if (method_type, normalized_value) in destination_pairs + ] + if duplicate_ids: + ContactMethod.objects.filter(id__in=duplicate_ids).delete() + affected += len(duplicate_ids) + + # A moving primary method wins over the destination's existing primary of + # the same method_type (mirrors ContactMethod.save()'s demotion), + # keeping the partial unique constraints on (owner, method_type, is_primary) + # satisfied. + moving_primary_types = list( + source_queryset.filter(is_primary=True).values_list("method_type", flat=True) + ) + if moving_primary_types: + destination_queryset.filter( + method_type__in=moving_primary_types, + is_primary=True, + ).update(is_primary=False) + + # queryset.update() bypasses ContactMethod.save()'s + # 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. + affected += source_queryset.update( + company=destination, + updated_at=timezone.now(), + ) + return affected + + +def _move_company_contacts_and_methods( + source: Company, destination: Company +) -> dict[str, int]: + """Move CRM contact ownership before the source company is deleted.""" + + from apps.crm.models import PhoneCallRecord + + counts = { + "contacts": 0, + "contact_methods": 0, + "phone_calls": 0, + } + + counts["contact_methods"] += _move_company_contact_methods(source, destination) + + destination_links_by_person = { + existing.person_id: existing + for existing in CompanyPersonLink.objects.filter(company=destination) + } + for link in CompanyPersonLink.objects.filter(company=source).iterator(): + destination_link = destination_links_by_person.get(link.person_id) + if destination_link is None: + link.company = destination + link.save(update_fields=["company", "updated_at"]) + destination_links_by_person[link.person_id] = link + else: + counts["phone_calls"] += PhoneCallRecord.objects.filter( + person=link.person, + company=source, + ).update( + company=destination, + ) + link.delete() + + counts["contacts"] += 1 + + counts["phone_calls"] += PhoneCallRecord.objects.filter(company=source).update( + company=destination + ) + return counts + + +def reassign_company_fk_records( + source: Company, + destination: Company, + staff: Staff, + *, + logger_prefix: str = "", +) -> CompanyMergeCounts: + """ + Move every company-referencing FK record from ``source`` to ``destination``. + + Returns a dict of per-model rowcounts, e.g. ``{"jobs": 3, ...}``. + + Job records are iterated and saved so JobEvents are generated. All other + tables use bulk ``.update()``. + + Args: + staff: Staff to attribute the JobEvents to. Xero-sync callers should + pass ``Staff.get_automation_user()``. + + Raises: + ValueError: if ``destination == source`` (the service refuses this + no-op because it almost certainly indicates a caller bug, e.g. + a chain-walk that terminated at a cycle). + """ + if destination.id == source.id: + raise ValueError( + 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. + from apps.accounting.models import Bill, CreditNote, Invoice, Quote + from apps.job.models import Job + from apps.purchasing.models import PurchaseOrder + from apps.quoting.models import ScrapeJob, SupplierPriceList, SupplierProduct + + try: + with transaction.atomic(): + jobs_moved = 0 + for job in Job.objects.filter(company=source): + job.company = destination + job.save(staff=staff, update_fields=["company"]) + jobs_moved += 1 + + crm_counts = _move_company_contacts_and_methods(source, destination) + counts: CompanyMergeCounts = { + "jobs": jobs_moved, + "contacts": crm_counts["contacts"], + "contact_methods": crm_counts["contact_methods"], + "phone_calls": crm_counts["phone_calls"], + "invoices": Invoice.objects.filter(company=source).update( + company=destination + ), + "bills": Bill.objects.filter(company=source).update( + company=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 + ), + "supplier_products": SupplierProduct.objects.filter( + supplier=source + ).update(supplier=destination), + "supplier_price_lists": SupplierPriceList.objects.filter( + supplier=source + ).update(supplier=destination), + "scrape_jobs": ScrapeJob.objects.filter(supplier=source).update( + supplier=destination + ), + } + + logger.info( + "%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", + logger_prefix, + source.id, + destination.id, + counts["jobs"], + counts["contacts"], + counts["contact_methods"], + counts["phone_calls"], + counts["invoices"], + counts["bills"], + counts["credit_notes"], + counts["quotes"], + counts["purchase_orders"], + counts["supplier_products"], + counts["supplier_price_lists"], + counts["scrape_jobs"], + ) + return counts + + except Exception as exc: + persist_app_error(exc) + raise + + +def merge_companies( + source_id: UUID, + destination_id: UUID, + staff: Staff, +) -> CompanyMergeCounts: + """Merge a Company while retaining its Xero-linked tombstone row.""" + if source_id == destination_id: + raise ValueError("Source and destination Company must be different") + + with transaction.atomic(): + companies = { + company.id: company + for company in Company.objects.select_for_update() + .filter(id__in=[source_id, destination_id]) + .order_by("id") + } + source = companies.get(source_id) + if source is None: + raise ValueError(f"Source Company {source_id} does not exist") + destination = companies.get(destination_id) + if destination is None: + raise ValueError(f"Destination Company {destination_id} does not exist") + if destination.merged_into_id is not None: + raise ValueError(f"Destination Company {destination_id} is already merged") + if source.merged_into_id not in (None, destination_id): + raise ValueError( + f"Source Company {source_id} is already merged into " + f"{source.merged_into_id}" + ) + + destination.is_account_customer = ( + destination.is_account_customer or source.is_account_customer + ) + destination.is_supplier = destination.is_supplier or source.is_supplier + destination.allow_jobs = destination.allow_jobs or source.allow_jobs + if not destination.email and source.email: + destination.email = source.email + if not destination.address and source.address: + destination.address = source.address + destination.save( + update_fields=[ + "is_account_customer", + "is_supplier", + "allow_jobs", + "email", + "address", + ] + ) + + source.merged_into = destination + source.allow_jobs = False + source.save(update_fields=["merged_into", "allow_jobs"]) + return reassign_company_fk_records(source, destination, staff) diff --git a/apps/company/services/company_rest_service.py b/apps/company/services/company_rest_service.py new file mode 100644 index 000000000..05d6286cb --- /dev/null +++ b/apps/company/services/company_rest_service.py @@ -0,0 +1,1200 @@ +""" +Company REST Service Layer + +Following SRP (Single Responsibility Principle) and clean code guidelines. +All business logic for Company REST operations should be implemented here. +""" + +import json +import logging +import re +from typing import TYPE_CHECKING, Any, Dict, List, Optional, TypedDict +from uuid import UUID, uuid4 + +if TYPE_CHECKING: + from django_stubs_ext import WithAnnotations + + from apps.accounts.models import Staff + +from django.core.exceptions import ValidationError as DjangoValidationError +from django.db import transaction +from django.db.models import Case, IntegerField, Q, When +from django.http import HttpRequest +from django.shortcuts import get_object_or_404 +from django.utils import timezone + +from apps.company.models import ( + PRIMARY_PHONE_ORDERING, + Company, + ContactMethod, +) +from apps.company.serializers import ( + CompanyCreateSerializer, + CompanyUpdateSerializer, + set_primary_phone, +) +from apps.company.utils import date_to_datetime +from apps.crm.tasks import rematch_phone_calls_task +from apps.workflow.accounting.registry import get_provider +from apps.workflow.exceptions import AlreadyLoggedException +from apps.workflow.services.error_persistence import ( + persist_and_raise, + persist_app_error, +) +from apps.workflow.services.search_telemetry import SearchTelemetryService + +COMPANY_SEARCH_TOKEN_RE = re.compile(r"[a-z0-9]+") + + +class CompanyPhoneAnnotations(TypedDict): + """Queryset annotation required by _format_company_summary and + _format_company_detail; not a Company model field.""" + + phone: str + + +if TYPE_CHECKING: + # Evaluated only by the type checker (annotation is quoted at use site), so + # the dev-only django_stubs_ext dependency is never imported at runtime. + _AnnotatedCompanyWithPhone = WithAnnotations[Company, CompanyPhoneAnnotations] + +logger = logging.getLogger(__name__) +company_search_logger = logging.getLogger("company_search") + + +class CompanyRestService: + """ + Service layer for Company REST operations. + Implements all business rules related to Company manipulation via REST API. + """ + + @staticmethod + def get_all_companies() -> List[Dict[str, Any]]: + """ + Retrieves all companies with basic information for dropdowns. + + Returns: + List of company dictionaries with id and name + """ + try: + companies = Company.objects.all().order_by("name") + return [ + { + "id": str(company.id), + "name": company.name, + } + for company in companies + ] + except AlreadyLoggedException: + raise + except Exception as exc: + persist_and_raise(exc) + + @staticmethod + def search_companies(query: str, limit: int = 10) -> List[Dict[str, Any]]: + """ + 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 company dictionaries with detailed information + + Raises: + ValueError: If query is too short + """ + try: + # Guard clause - validate query length + if not query or len(query.strip()) < 3: + return [] + + # Sanitize and limit + query = query.strip() + limit = max(1, min(limit, 50)) + + # Execute optimized search + companies = CompanyRestService._execute_company_search(query, limit) + return CompanyRestService._format_company_search_results(companies) + + except AlreadyLoggedException: + raise + except Exception as exc: + persist_and_raise(exc, additional_context={"query": query, "limit": limit}) + + @staticmethod + def list_companies( + query: str | None = None, + page: int = 1, + page_size: int = 50, + sort_by: str = "name", + sort_dir: str = "asc", + ) -> Dict[str, Any]: + """ + Lists companies with pagination, sorting, and optional search. + + Args: + query: Optional search query (min 3 chars for filtering) + page: Page number (1-indexed) + page_size: Results per page + sort_by: Field to sort by + sort_dir: Sort direction ('asc' or 'desc') + + Returns: + Dict with results, count, page, page_size, total_pages + """ + try: + # Validate sort field - whitelist allowed fields + allowed_sort_fields = { + "name": "name", + "email": "email", + "is_account_customer": "is_account_customer", + "last_invoice_date": "last_invoice_date", + "total_spend": "total_spend", + } + sort_field = allowed_sort_fields.get(sort_by, "name") + + # Build ordering + if sort_dir.lower() == "desc": + sort_field = f"-{sort_field}" + + queryset = ( + Company.objects.with_invoice_summary() + .defer("raw_json") + .annotate( + phone=ContactMethod.primary_phone_annotation( + owner="company", outer_ref="pk" + ) + ) + ) + + # Apply search filter if query provided + if 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] + companies = CompanyRestService._hydrate_company_search_results(page_ids) + else: + ordering = (sort_field,) + # Get total count before pagination + total_count = queryset.count() + + # Apply sorting and pagination + offset = (page - 1) * 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": CompanyRestService._format_company_search_results(companies), + "count": total_count, + "page": page, + "page_size": page_size, + "total_pages": total_pages, + } + + except AlreadyLoggedException: + raise + except ValueError: + raise + except Exception as exc: + persist_and_raise( + exc, + additional_context={ + "query": query, + "page": page, + "page_size": page_size, + }, + ) + + @staticmethod + def get_company_by_id(company_id: UUID) -> Dict[str, Any]: + """ + Retrieves a specific company by ID with full details. + + Args: + company_id: Company UUID + + Returns: + Dict with complete company information + + Raises: + ValueError: If company not found + """ + try: + company = ( + Company.objects.with_invoice_summary() + .annotate( + phone=ContactMethod.primary_phone_annotation( + owner="company", outer_ref="pk" + ) + ) + .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 ValueError: + raise + except Exception as exc: + persist_and_raise( + exc, + additional_context={ + "operation": "get_company_by_id", + "company_id": str(company_id), + }, + ) + + @staticmethod + def create_company(data: Dict[str, Any]) -> Company: + """ + Creates a new company locally and in the accounting provider. + + Args: + data: Company creation data + + Returns: + Created Company instance + + Raises: + ValueError: If validation fails or accounting provider sync fails + """ + try: + # Validate using DRF serializer + serializer = CompanyCreateSerializer(data=data) + if not serializer.is_valid(): + error_messages = [] + for field, errors in serializer.errors.items(): + error_messages.extend([f"{field}: {e}" for e in errors]) + raise ValueError("; ".join(error_messages)) + + # Check accounting provider authentication + provider = get_provider() + token = provider.get_valid_token() + if not token: + raise ValueError("Accounting provider authentication required") + + # Create in Xero first + company = CompanyRestService._create_company_in_xero( + serializer.validated_data + ) + return company + + except AlreadyLoggedException: + raise + except Exception as exc: + persist_and_raise( + exc, + additional_context={ + "operation": "create_company", + "payload_keys": list(data.keys()), + }, + ) + + @staticmethod + def update_company( + company_id: UUID, data: Dict[str, Any] + ) -> "_AnnotatedCompanyWithPhone": + """ + Updates an existing company. + If company is synced with Xero, updates Xero first then syncs locally. + + Args: + company_id: Company UUID + data: Updated company data + + Returns: + Updated Company instance + + Raises: + ValueError: If company not found or validation fails + """ + try: + company = Company.objects.filter(id=company_id).first() + if company is None: + raise ValueError(f"Company with id {company_id} not found") + + # Store xero_contact_id before validation + original_xero_contact_id = company.xero_contact_id + + # Validate using DRF serializer + serializer = CompanyUpdateSerializer(data=data) + if not serializer.is_valid(): + error_messages = [] + for field, errors in serializer.errors.items(): + error_messages.extend([f"{field}: {e}" for e in errors]) + raise ValueError("; ".join(error_messages)) + + validated_data = serializer.validated_data + # Stored as the company's primary ContactMethod, not a Company + # field, so it never reaches the generic setattr loop below. + phone_supplied = "phone" in validated_data + phone = validated_data.pop("phone", None) + + # Guard clause - validate required fields + if not validated_data.get("name") and not company.name: + raise ValueError("Company name is required") + + # DEBUG: Log company state after validation + logger.info( + f"Company data after validation: xero_contact_id={original_xero_contact_id}", + extra={ + "company_id": str(company.id), + "original_xero_contact_id": original_xero_contact_id, + "operation": "update_company_debug_after_validation", + }, + ) + + # Check if company is synced with Xero + if original_xero_contact_id: + # Update in Xero first, then sync locally + updated_company = CompanyRestService._update_company_in_xero( + company, + validated_data, + phone_supplied=phone_supplied, + raw_phone=phone, + ) + logger.info( + f"Company {updated_company.id} updated in Xero and synced locally", + extra={ + "company_id": str(updated_company.id), + "company_name": updated_company.name, + "xero_contact_id": updated_company.xero_contact_id, + "operation": "update_company_xero_sync", + }, + ) + else: + # Local-only update for companies not synced with Xero + with transaction.atomic(): + for field, value in validated_data.items(): + setattr(company, field, value) + company.xero_last_modified = timezone.now() + company.save() + + CompanyRestService._apply_company_phone_change( + company, + phone_supplied=phone_supplied, + raw_phone=phone, + ) + + logger.info( + f"Company {company.id} updated locally (no Xero sync)", + extra={ + "company_id": str(company.id), + "company_name": company.name, + "operation": "update_company_local_only", + }, + ) + updated_company = company + + # The response's phone field always reads from a queryset annotation; + # refetch through it, restoring the with_invoice_summary() aggregates + # _format_company_detail needs. + updated_with_phone: _AnnotatedCompanyWithPhone = ( + Company.objects.with_invoice_summary() + .annotate( + phone=ContactMethod.primary_phone_annotation( + owner="company", outer_ref="pk" + ) + ) + .get(id=updated_company.id) + ) + return updated_with_phone + except AlreadyLoggedException: + raise + except ValueError: + raise + except Exception as exc: + persist_and_raise( + exc, + additional_context={ + "operation": "update_company", + "company_id": str(company_id), + "payload_keys": list(data.keys()), + }, + ) + + @staticmethod + def get_company_contacts(company_id: UUID) -> List[Dict[str, Any]]: + """ + Retrieves all contacts for a specific company. + + Args: + company_id: Company UUID + + Returns: + List of contact dictionaries + + Raises: + ValueError: If company not found + """ + try: + company = get_object_or_404(Company, id=company_id) + links = ( + company.contacts.select_related("person").all().order_by("person__name") + ) + + return [ + { + "id": str(link.id), + "person": str(link.person_id), + "person_name": link.person.name, + "person_email": link.person.email, + "position": link.position, + "is_primary": link.is_primary, + } + for link in links + ] + + except AlreadyLoggedException: + raise + except Exception as exc: + persist_and_raise( + exc, + additional_context={ + "operation": "get_company_contacts", + "company_id": str(company_id), + }, + ) + + @staticmethod + def get_job_person(job_id: UUID) -> Dict[str, Any]: + """ + Retrieves person information for a specific job. + + Args: + job_id: Job UUID + + Returns: + Dict with person information + + Raises: + ValueError: If job not found or no person associated + """ + # Import here to avoid circular imports + from apps.job.models import Job + + try: + job = Job.objects.select_related("person").get(id=job_id) + except Job.DoesNotExist: + raise ValueError(f"Job with id {job_id} not found") + except AlreadyLoggedException: + raise + except Exception as exc: + persist_and_raise( + exc, + additional_context={ + "operation": "get_job_person", + "job_id": str(job_id), + }, + ) + + if not job.person: + # Documented business validation failure should not be persisted + raise ValueError(f"No person associated with job {job_id}") + + person = job.person + try: + return { + "id": str(person.id), + "name": person.name, + "email": person.email, + } + except AlreadyLoggedException: + raise + except Exception as exc: + persist_and_raise( + exc, + additional_context={ + "operation": "serialize_job_person", + "job_id": str(job_id), + }, + ) + + @staticmethod + def update_job_person( + job_id: UUID, person_data: Dict[str, Any], user: "Staff" + ) -> Dict[str, Any]: + """ + Updates the person for a specific job. + + Args: + job_id: Job UUID + person_data: Person data to update + user: Staff performing the update + + Returns: + Dict with updated contact information + + Raises: + ValueError: If job not found, person not found, or validation fails + """ + try: + # Import here to avoid circular imports + from apps.company.models import Person + from apps.job.models import Job + + try: + job = Job.objects.select_related("company", "person").get(id=job_id) + except Job.DoesNotExist: + raise ValueError(f"Job with id {job_id} not found") + + person_id = person_data.get("id") + if not person_id: + raise ValueError("Person ID is required") + + try: + person = Person.objects.get(id=person_id) + except Person.DoesNotExist as exc: + raise ValueError(f"Person with id {person_id} not found") from exc + + job.person = person + job.save(staff=user) + + logger.info( + f"Person {person_id} assigned to job {job_id}", + extra={ + "job_id": str(job_id), + "person_id": str(person_id), + "company_id": str(job.company_id), + "operation": "update_job_person", + }, + ) + + return { + "id": str(person.id), + "name": person.name, + "email": person.email, + } + + except AlreadyLoggedException: + raise + except ValueError: + raise + except Exception as exc: + persist_and_raise( + exc, + additional_context={ + "operation": "update_job_person", + "job_id": str(job_id), + "person_id": person_data.get("id"), + }, + ) + + @staticmethod + def _execute_company_search(query: str, limit: int): + """ + Executes company search with appropriate filters and annotations. + """ + ranked_ids = CompanyRestService._rank_matching_company_ids( + Company.objects.filter(allow_jobs=True), query + ) + return CompanyRestService._hydrate_company_search_results(ranked_ids[:limit]) + + @staticmethod + def _hydrate_company_search_results(company_ids): + if not company_ids: + return [] + + ordering = Case( + *[ + When(id=company_id, then=position) + for position, company_id in enumerate(company_ids) + ], + output_field=IntegerField(), + ) + return list( + Company.objects.with_invoice_summary() + .defer("raw_json") # Not needed for search results + .annotate( + phone=ContactMethod.primary_phone_annotation( + owner="company", outer_ref="pk" + ) + ) + .only( + "id", + "name", + "email", + "address", + "is_account_customer", + "is_supplier", + "allow_jobs", + "xero_contact_id", + ) + .filter(id__in=company_ids) + .order_by(ordering) + ) + + @staticmethod + def _rank_matching_company_ids(queryset, query: str): + tokens = CompanyRestService._company_search_tokens(query) + if not tokens: + return [] + + candidate_filter = CompanyRestService._company_name_candidate_filter(tokens) + candidates = queryset.filter(candidate_filter).values_list("id", "name") + + ranked = [ + ( + CompanyRestService._company_name_score(name, query, tokens), + company_id, + ) + for company_id, name in candidates.iterator() + if CompanyRestService._company_name_matches(name, tokens) + ] + ranked.sort(key=lambda item: item[0]) + return [company_id for _, company_id in ranked] + + @staticmethod + def _company_search_tokens(query: str) -> list[str]: + return COMPANY_SEARCH_TOKEN_RE.findall(query.lower()) + + @staticmethod + def _normalized_company_search_text(value: str) -> str: + return " ".join(COMPANY_SEARCH_TOKEN_RE.findall(value.lower())) + + @staticmethod + 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 _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 _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 + elif normalized_name.startswith(normalized_query): + tier = 1 + elif normalized_query in normalized_name: + tier = 2 + else: + tier = 3 + + token_scores = [ + CompanyRestService._company_token_match_score(token, name_tokens) + for token in tokens + ] + positions = [normalized_name.find(token) for token in tokens] + ordered_penalty = 0 if positions == sorted(positions) else 1 + return ( + tier, + max(token_scores), + sum(token_scores), + sum(positions), + ordered_penalty, + len(normalized_name), + normalized_name, + ) + + @staticmethod + 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): + return 1 + return 99 + + @staticmethod + 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] + ) + tokens = CompanyRestService._company_search_tokens(query) + return [ + CompanyRestService._company_search_log_result( + rank=index + 1, + result=company, + query=query, + tokens=tokens, + ) + for index, company in enumerate(companies) + ] + + @staticmethod + def log_company_search_results( + *, + request: Optional[HttpRequest], + source: str, + query: str, + companies, + total_count: int, + ) -> None: + if len(query.strip()) < 3: + return + + tokens = CompanyRestService._company_search_tokens(query) + user = getattr(request, "user", None) if request else None + payload = { + "event": "company_search_results", + "search_id": str(uuid4()), + "source": source, + "query": query, + "path": getattr(request, "path", None), + "query_string": ( + request.META.get("QUERY_STRING", "") if request is not None else "" + ), + "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(companies), + "results": [ + CompanyRestService._company_search_log_result( + rank=index + 1, + result=company, + query=query, + tokens=tokens, + ) + for index, company in enumerate(companies) + ], + } + company_search_logger.info(json.dumps(payload, sort_keys=True, default=str)) + SearchTelemetryService.log_search( + request=request, + domain="company", + source=source, + query=query, + result_count=total_count, + returned_result_ids=[ + result["id"] if isinstance(result, dict) else result.id + for result in companies + ], + metadata={"results": payload["results"][:100]}, + ) + + @staticmethod + def log_company_search_click( + *, + request: Optional[HttpRequest], + source: str, + query: str, + company_id, + rank: Optional[int], + ) -> None: + company = Company.objects.only("id", "name").get(id=company_id) + user = getattr(request, "user", None) if request else None + payload = { + "event": "company_search_click", + "search_id": str(uuid4()), + "source": source, + "query": query, + "path": getattr(request, "path", None), + "query_string": ( + request.META.get("QUERY_STRING", "") if request is not None else "" + ), + "user_id": str(getattr(user, "id", "")) if user else None, + "user_email": getattr(user, "email", None) if user else None, + "company_id": str(company.id), + "company_name": company.name, + "rank": rank, + } + company_search_logger.info(json.dumps(payload, sort_keys=True, default=str)) + SearchTelemetryService.log_click( + request=request, + domain="company", + source=source, + query=query, + selected_result_id=str(company.id), + selected_label=company.name, + selected_rank=rank, + metadata={"company_name": company.name}, + ) + + @staticmethod + def _company_search_log_result( + *, + rank: int, + result: Company | Dict[str, Any], + query: str, + tokens: list[str], + ) -> Dict[str, Any]: + if isinstance(result, dict): + company_id = result["id"] + company_name = result["name"] + else: + company_id = str(result.id) + company_name = result.name + + name_tokens = CompanyRestService._company_search_tokens(company_name) + return { + "rank": rank, + "company_id": company_id, + "company_name": company_name, + "search_score": CompanyRestService._company_name_score( + company_name, query, tokens + ), + "search_reasons": [ + { + "token": token, + "reason": CompanyRestService._company_token_match_reason( + token, name_tokens + ), + "score": CompanyRestService._company_token_match_score( + token, name_tokens + ), + } + for token in tokens + ], + } + + @staticmethod + 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): + return "token_prefix" + return "no_match" + + @staticmethod + def _format_company_summary( + company: "_AnnotatedCompanyWithPhone", + ) -> Dict[str, Any]: + """ + Formats a single company summary for list/search responses. + + Callers must annotate their queryset with + ContactMethod.primary_phone_annotation (see CompanyPhoneAnnotations). + """ + return { + "id": str(company.id), + "name": company.name, + "email": company.email or "", + "phone": company.phone, + "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_company_search_results(companies) -> List[Dict[str, Any]]: + """ + Formats company search results for API response. + """ + return [ + CompanyRestService._format_company_summary(company) for company in companies + ] + + @staticmethod + def _format_company_detail( + company: "_AnnotatedCompanyWithPhone", + ) -> Dict[str, Any]: + """ + Formats complete company details for API response. + + Callers must annotate their queryset with + ContactMethod.primary_phone_annotation (see CompanyPhoneAnnotations). + """ + return { + "id": str(company.id), + "name": company.name, + "email": company.email or "", + "phone": company.phone, + "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 "", + "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_company_in_xero(company_data: Dict[str, Any]) -> Company: + """ + Creates company in the accounting provider and locally. + """ + provider = get_provider() + 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"Company '{name}' already exists in {provider.provider_name}" + f" with ID: {existing.external_id}" + ) + + # Create local company first + with transaction.atomic(): + company = Company.objects.create( + name=name, + 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(), + ) + phone = company_data.get("phone") + CompanyRestService._apply_company_phone_change( + company, + phone_supplied="phone" in company_data, + raw_phone=phone, + ) + + # Push to accounting provider (persists xero_contact_id on the company object) + result = provider.create_contact(company) + if not result.success: + company_id = company.id + company.delete() + logger.warning( + "Deleted local company after accounting provider create failure", + extra={ + "company_id": str(company_id), + "company_name": name, + "provider": provider.provider_name, + "operation": "create_company_in_xero_cleanup", + }, + ) + raise ValueError( + f"Failed to create company in {provider.provider_name}: {result.error}" + ) + + logger.info( + f"Company {company.id} created locally and in {provider.provider_name}", + extra={ + "company_id": str(company.id), + "company_name": company.name, + "xero_contact_id": company.xero_contact_id, + "operation": "create_company_in_xero", + }, + ) + return company + + @staticmethod + def _update_company_in_xero( + company: Company, + data: Dict[str, Any], + *, + phone_supplied: bool, + raw_phone: str | None, + ) -> Company: + """ + Updates company locally and in the accounting provider. + """ + provider = get_provider() + + # Check accounting provider authentication + token = provider.get_valid_token() + if not token: + exc = RuntimeError("Accounting provider authentication required") + persist_and_raise( + exc, + additional_context={ + "operation": "_update_company_in_xero", + "company_id": str(company.id), + "provider": provider.provider_name, + }, + ) + + # Update local fields first + with transaction.atomic(): + 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: + company.allow_jobs = data["allow_jobs"] + company.xero_last_modified = timezone.now() + company.save() + CompanyRestService._apply_company_phone_change( + company, + phone_supplied=phone_supplied, + raw_phone=raw_phone, + ) + + # FIXME: `allow_jobs` is a local-only field (not synced to Xero) but + # toggling it still routes through this method, which unconditionally + # bumps `xero_last_modified` and pushes to Xero below. That wastes + # 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_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 company to accounting provider + result = provider.update_contact(company) + if not result.success: + exc = RuntimeError( + f"Failed to update company in {provider.provider_name}: {result.error}" + ) + persist_and_raise( + exc, + additional_context={ + "operation": "_update_company_in_xero", + "company_id": str(company.id), + "provider": provider.provider_name, + "provider_error": result.error, + }, + ) + + logger.info( + f"Company {company.id} updated locally and in {provider.provider_name}", + extra={ + "company_id": str(company.id), + "company_name": company.name, + "xero_contact_id": company.xero_contact_id, + "operation": "_update_company_in_xero", + }, + ) + + return company + + @staticmethod + def _apply_company_phone_change( + company: Company, + *, + phone_supplied: bool, + raw_phone: str | None, + ) -> None: + if not phone_supplied: + logger.debug( + "Company phone omitted; leaving contact methods unchanged", + extra={ + "company_id": str(company.id), + "operation": "company_phone_omitted", + }, + ) + return + + if raw_phone is not None and raw_phone.strip(): + try: + set_primary_phone(company, raw_phone) + except DjangoValidationError as exc: + raise ValueError("; ".join(exc.messages)) from exc + else: + return + + CompanyRestService._clear_company_primary_phone(company) + + @staticmethod + def _clear_company_primary_phone(company: Company) -> None: + primary = ( + ContactMethod.objects.filter( + company=company, + method_type=ContactMethod.MethodType.PHONE, + is_primary=True, + ) + .order_by(*PRIMARY_PHONE_ORDERING) + .first() + ) + if primary is None: + logger.info( + "Company primary phone clear requested but no primary phone exists", + extra={ + "company_id": str(company.id), + "operation": "company_phone_clear_noop", + }, + ) + return + + old_number = primary.normalized_value + primary.delete() + if not old_number: + logger.warning( + "Deleted company primary phone without normalized value", + extra={ + "company_id": str(company.id), + "contact_method_id": str(primary.id), + "operation": "company_phone_clear_missing_normalized_value", + }, + ) + return + + transaction.on_commit(lambda: rematch_phone_calls_task.delay([old_number])) + + @staticmethod + def get_company_jobs(company_id: UUID) -> List[Dict[str, Any]]: + """ + Retrieves all jobs for a specific company. + + Args: + company_id: Company UUID + + Returns: + List of job header dictionaries + + Raises: + ValueError: If company not found + """ + try: + # 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 company using JOB_DIRECT_FIELDS as source of truth + query_fields = ["id", "company_id"] + Job.JOB_DIRECT_FIELDS + jobs = ( + Job.objects.filter(company_id=company_id) + # quote joined in because job.quoted reads it per job below + .select_related("company", "quote") + .only(*query_fields, "quote__id") + .order_by("-job_number") + ) + + # Format job data + return [ + { + "job_id": str(job.id), + "job_number": job.job_number, + "name": job.name, + "company": ( + {"id": str(job.company.id), "name": job.company.name} + if job.company + else None + ), + "status": job.status, + "pricing_methodology": job.pricing_methodology, + "speed_quality_tradeoff": job.speed_quality_tradeoff, + "fully_invoiced": job.fully_invoiced, + "has_quote_in_xero": job.quoted, + "is_fixed_price": job.pricing_methodology == "fixed_price", + "quote_acceptance_date": job.quote_acceptance_date, + "paid": job.paid, + "rejected_flag": job.rejected_flag, + "min_people": job.min_people, + "max_people": job.max_people, + } + for job in jobs + ] + + except Exception as e: + persist_app_error(e) + raise diff --git a/apps/company/services/duplicate_identity_report.py b/apps/company/services/duplicate_identity_report.py new file mode 100644 index 000000000..bb8d99cb6 --- /dev/null +++ b/apps/company/services/duplicate_identity_report.py @@ -0,0 +1,919 @@ +"""Group duplicate Company and Person records by corroborated identity evidence.""" + +import re +import unicodedata +from collections import defaultdict +from collections.abc import Iterable +from datetime import datetime +from hashlib import sha256 +from typing import Literal, TypedDict +from uuid import UUID + +from django.db import models +from django.utils import timezone +from rapidfuzz import fuzz + +from apps.company.models import Company, CompanyPersonLink, ContactMethod, Person +from apps.company.services.duplicate_person_report import ( + DuplicatePersonSummary, + normalize_person_email, + normalize_person_name, + person_names_compatible, +) + +EntityKind = Literal["company", "person"] +Recommendation = Literal["merge", "review"] +EvidenceKind = Literal[ + "name", + "email", + "email_domain", + "phone", + "address", + "shared_person", +] +Pair = tuple[UUID, UUID] + +MAX_RARE_OWNERS = 3 +MAX_AUTO_GROUP_SIZE = 5 +COMPANY_NAME_SIMILARITY = 90 +PERSON_NAME_SIMILARITY = 85 + +PUBLIC_EMAIL_DOMAINS = frozenset( + { + "gmail.com", + "hotmail.com", + "icloud.com", + "outlook.com", + "xtra.co.nz", + "yahoo.co.nz", + "yahoo.com", + } +) +GENERIC_EMAIL_LOCAL_PARTS = frozenset( + { + "accounts", + "admin", + "contact", + "enquiries", + "enquiry", + "info", + "office", + "reception", + "sales", + } +) +COMPANY_NAME_NOISE = frozenset( + { + "co", + "company", + "inc", + "incorporated", + "limited", + "ltd", + "llc", + "new", + "nz", + "zealand", + } +) + + +class DuplicateIdentityEvidence(TypedDict): + kind: EvidenceKind + normalized_value: str + owner_count: int + + +class DuplicateCompanyMember(TypedDict): + company_id: str + name: str + email: str | None + address: str | None + allow_jobs: bool + is_account_customer: bool + is_supplier: bool + xero_archived: bool + job_count: int + contact_names: list[str] + + +class DuplicatePersonMember(DuplicatePersonSummary): + pass + + +class DuplicateCompanyGroup(TypedDict): + group_id: str + fingerprint: str + recommendation: Recommendation + reason_codes: list[str] + canonical_id: str | None + members: list[DuplicateCompanyMember] + evidence: list[DuplicateIdentityEvidence] + + +class DuplicatePersonGroup(TypedDict): + group_id: str + fingerprint: str + recommendation: Recommendation + reason_codes: list[str] + canonical_id: str | None + members: list[DuplicatePersonMember] + evidence: list[DuplicateIdentityEvidence] + + +class DuplicateIdentityReportSummary(TypedDict): + company_merge_groups: int + company_review_groups: int + person_merge_groups: int + person_review_groups: int + + +class DuplicateIdentityReport(TypedDict): + company_groups: list[DuplicateCompanyGroup] + person_groups: list[DuplicatePersonGroup] + summary: DuplicateIdentityReportSummary + checked_at: datetime + + +def _ordered_pair(first_id: UUID, second_id: UUID) -> Pair: + if str(first_id) < str(second_id): + return first_id, second_id + return second_id, first_id + + +def _normalise_text(value: str | None) -> str: + folded = unicodedata.normalize("NFKC", value or "").casefold() + return " ".join(re.sub(r"[^\w]+", " ", folded).split()) + + +def normalize_company_name(value: str) -> str: + """Remove presentation/legal noise while retaining the business identity.""" + normalized = _normalise_text(value.replace("&", " and ").replace("+", " plus ")) + normalized = re.sub(r"^cash\s+sale\s+", "", normalized) + tokens = normalized.split() + while tokens and tokens[-1] in COMPANY_NAME_NOISE: + tokens.pop() + return " ".join(tokens) + + +def normalize_company_address(value: str | None) -> str: + """Return only sufficiently specific addresses as identity evidence.""" + normalized = _normalise_text(value) + if not re.search(r"\d", normalized): + return "" + if len(normalized.split()) < 3: + return "" + return normalized + + +def _email_domain(value: str | None) -> str: + normalized = normalize_person_email(value) + if "@" not in normalized: + return "" + domain = normalized.rsplit("@", 1)[1] + if domain in PUBLIC_EMAIL_DOMAINS: + return "" + return domain + + +def _generic_email(value: str) -> bool: + if "@" not in value: + return True + return value.split("@", 1)[0] in GENERIC_EMAIL_LOCAL_PARTS + + +def person_names_strongly_compatible(first_name: str, second_name: str) -> bool: + """Match exact/nickname names and restrained one-token spelling variants.""" + if person_names_compatible(first_name, second_name): + return True + first_tokens = normalize_person_name(first_name).split() + second_tokens = normalize_person_name(second_name).split() + if len(first_tokens) < 2 or len(second_tokens) < 2: + return False + + first_given = first_tokens[0] + second_given = second_tokens[0] + first_surname = " ".join(first_tokens[1:]) + second_surname = " ".join(second_tokens[1:]) + given_exact = first_given == second_given + surname_exact = first_surname == second_surname + if not given_exact and not surname_exact: + return False + return ( + fuzz.WRatio( + normalize_person_name(first_name), + normalize_person_name(second_name), + ) + >= PERSON_NAME_SIMILARITY + ) + + +def _components(edges: Iterable[Pair]) -> list[set[UUID]]: + adjacency: dict[UUID, set[UUID]] = defaultdict(set) + for first_id, second_id in edges: + adjacency[first_id].add(second_id) + adjacency[second_id].add(first_id) + result: list[set[UUID]] = [] + visited: set[UUID] = set() + for root_id in sorted(adjacency, key=str): + if root_id in visited: + continue + component: set[UUID] = set() + pending = [root_id] + while pending: + entity_id = pending.pop() + if entity_id in component: + continue + component.add(entity_id) + pending.extend(adjacency[entity_id]) + visited.update(component) + result.append(component) + return result + + +def _group_id(kind: EntityKind, member_ids: set[UUID]) -> str: + identity = f"{kind}:" + ":".join(sorted(str(member_id) for member_id in member_ids)) + return sha256(identity.encode()).hexdigest()[:16] + + +def _fingerprint( + kind: EntityKind, + member_ids: set[UUID], + evidence: list[DuplicateIdentityEvidence], +) -> str: + evidence_values = [ + f"{item['kind']}={item['normalized_value']}:{item['owner_count']}" + for item in evidence + ] + payload = "|".join( + [kind, *sorted(str(member_id) for member_id in member_ids), *evidence_values] + ) + return sha256(payload.encode()).hexdigest() + + +class DuplicateIdentityReportService: + """Detect actionable duplicate identities without emitting pairwise noise.""" + + def get_report(self) -> DuplicateIdentityReport: + companies = list( + Company.objects.filter(merged_into__isnull=True).order_by("id") + ) + people = list(Person.objects.order_by("id")) + company_ids = [company.id for company in companies] + person_ids = [person.id for person in people] + + links = list( + CompanyPersonLink.objects.filter( + company_id__in=company_ids, + person_id__in=person_ids, + ).select_related("company", "person") + ) + methods = list( + ContactMethod.objects.filter( + models.Q(company_id__in=company_ids) + | models.Q(person_id__in=person_ids) + ) + ) + + links_by_company: dict[UUID, list[CompanyPersonLink]] = defaultdict(list) + links_by_person: dict[UUID, list[CompanyPersonLink]] = defaultdict(list) + for link in links: + links_by_company[link.company_id].append(link) + links_by_person[link.person_id].append(link) + + company_methods: dict[UUID, list[ContactMethod]] = defaultdict(list) + person_methods: dict[UUID, list[ContactMethod]] = defaultdict(list) + for method in methods: + if method.company_id is not None: + company_methods[method.company_id].append(method) + elif method.person_id is not None: + person_methods[method.person_id].append(method) + else: + raise RuntimeError(f"ContactMethod {method.id} has no owner") + + person_pair_evidence, person_signal_owners = self._person_pair_evidence( + people, + person_methods, + ) + provisional_people = self._provisional_person_edges( + people, + person_pair_evidence, + person_signal_owners, + ) + company_pair_evidence = self._company_pair_evidence( + companies, + company_methods, + links_by_person, + provisional_people, + ) + company_groups, effective_company = self._company_groups( + companies, + links_by_company, + company_pair_evidence, + ) + person_groups = self._person_groups( + people, + links_by_person, + person_methods, + person_pair_evidence, + person_signal_owners, + effective_company, + ) + return { + "company_groups": company_groups, + "person_groups": person_groups, + "summary": { + "company_merge_groups": sum( + group["recommendation"] == "merge" for group in company_groups + ), + "company_review_groups": sum( + group["recommendation"] == "review" for group in company_groups + ), + "person_merge_groups": sum( + group["recommendation"] == "merge" for group in person_groups + ), + "person_review_groups": sum( + group["recommendation"] == "review" for group in person_groups + ), + }, + "checked_at": timezone.now(), + } + + @staticmethod + def _person_pair_evidence( + people: list[Person], + methods_by_person: dict[UUID, list[ContactMethod]], + ) -> tuple[ + dict[Pair, dict[EvidenceKind, set[str]]], + dict[tuple[EvidenceKind, str], set[UUID]], + ]: + signal_owners: dict[tuple[EvidenceKind, str], set[UUID]] = defaultdict(set) + for person in people: + name = normalize_person_name(person.name) + if name: + signal_owners[("name", name)].add(person.id) + email = normalize_person_email(person.email) + if email: + signal_owners[("email", email)].add(person.id) + for method in methods_by_person[person.id]: + kind: EvidenceKind + if method.method_type == ContactMethod.MethodType.EMAIL: + kind = "email" + elif method.method_type == ContactMethod.MethodType.PHONE: + kind = "phone" + else: + raise ValueError( + f"Unknown contact method type {method.method_type!r}" + ) + signal_owners[(kind, method.normalized_value)].add(person.id) + + pair_evidence: dict[Pair, dict[EvidenceKind, set[str]]] = defaultdict( + lambda: defaultdict(set) + ) + for (kind, value), owners in signal_owners.items(): + if kind != "name" and len(owners) > MAX_RARE_OWNERS: + continue + ordered = sorted(owners, key=str) + for index, first_id in enumerate(ordered): + for second_id in ordered[index + 1 :]: + pair_evidence[(first_id, second_id)][kind].add(value) + return pair_evidence, signal_owners + + @staticmethod + def _provisional_person_edges( + people: list[Person], + pair_evidence: dict[Pair, dict[EvidenceKind, set[str]]], + signal_owners: dict[tuple[EvidenceKind, str], set[UUID]], + ) -> set[Pair]: + people_by_id = {person.id: person for person in people} + result: set[Pair] = set() + for pair, matches in pair_evidence.items(): + first = people_by_id[pair[0]] + second = people_by_id[pair[1]] + compatible = person_names_strongly_compatible(first.name, second.name) + rare_email = any( + len(signal_owners[("email", value)]) <= MAX_RARE_OWNERS + and not _generic_email(value) + for value in matches.get("email", set()) + ) + rare_phone = any( + len(signal_owners[("phone", value)]) <= MAX_RARE_OWNERS + for value in matches.get("phone", set()) + ) + if compatible and (rare_email or rare_phone): + result.add(pair) + return result + + @staticmethod + def _company_pair_evidence( + companies: list[Company], + methods_by_company: dict[UUID, list[ContactMethod]], + links_by_person: dict[UUID, list[CompanyPersonLink]], + strong_person_edges: set[Pair], + ) -> dict[Pair, dict[EvidenceKind, set[str]]]: + signal_owners: dict[tuple[EvidenceKind, str], set[UUID]] = defaultdict(set) + for company in companies: + name = normalize_company_name(company.name) + if name: + signal_owners[("name", name)].add(company.id) + address = normalize_company_address(company.address) + if address: + signal_owners[("address", address)].add(company.id) + email_values = {normalize_person_email(company.email)} + email_values.update( + method.normalized_value + for method in methods_by_company[company.id] + if method.method_type == ContactMethod.MethodType.EMAIL + ) + for email in email_values - {""}: + signal_owners[("email", email)].add(company.id) + domain = _email_domain(email) + if domain: + signal_owners[("email_domain", domain)].add(company.id) + for method in methods_by_company[company.id]: + if method.method_type == ContactMethod.MethodType.PHONE: + signal_owners[("phone", method.normalized_value)].add(company.id) + + pair_evidence: dict[Pair, dict[EvidenceKind, set[str]]] = defaultdict( + lambda: defaultdict(set) + ) + for (kind, value), owners in signal_owners.items(): + if kind != "name" and len(owners) > MAX_RARE_OWNERS: + continue + ordered = sorted(owners, key=str) + for index, first_id in enumerate(ordered): + for second_id in ordered[index + 1 :]: + pair_evidence[(first_id, second_id)][kind].add(value) + + for person_pair in strong_person_edges: + first_links = links_by_person[person_pair[0]] + second_links = links_by_person[person_pair[1]] + for first_link in first_links: + for second_link in second_links: + if first_link.company_id == second_link.company_id: + continue + company_pair = _ordered_pair( + first_link.company_id, + second_link.company_id, + ) + pair_evidence[company_pair]["shared_person"].add( + f"{person_pair[0]}:{person_pair[1]}" + ) + + return pair_evidence + + def _company_groups( + self, + companies: list[Company], + links_by_company: dict[UUID, list[CompanyPersonLink]], + pair_evidence: dict[Pair, dict[EvidenceKind, set[str]]], + ) -> tuple[list[DuplicateCompanyGroup], dict[UUID, UUID]]: + companies_by_id = {company.id: company for company in companies} + auto_edges: set[Pair] = set() + review_edges: set[Pair] = set() + reasons_by_pair: dict[Pair, set[str]] = defaultdict(set) + for pair, matches in pair_evidence.items(): + first = companies_by_id[pair[0]] + second = companies_by_id[pair[1]] + exact_name = bool(matches.get("name")) + name_similarity = fuzz.WRatio( + normalize_company_name(first.name), + normalize_company_name(second.name), + ) + organisation_families = { + "email" if kind in {"email", "email_domain"} else kind + for kind in set(matches) + & { + "email", + "email_domain", + "phone", + "address", + } + } + shared_person_count = len(matches.get("shared_person", set())) + if exact_name: + reasons_by_pair[pair].add("exact_company_name") + if name_similarity >= COMPANY_NAME_SIMILARITY: + reasons_by_pair[pair].add("similar_company_name") + if organisation_families: + reasons_by_pair[pair].add("shared_organisation_identity") + if shared_person_count: + reasons_by_pair[pair].add("shared_person_identity") + + strong = exact_name or ( + name_similarity >= COMPANY_NAME_SIMILARITY + and bool(organisation_families or shared_person_count) + ) + if strong: + auto_edges.add(pair) + elif shared_person_count or len(organisation_families) >= 2: + review_edges.add(pair) + else: + # A lone domain/email/phone/address is useful corroboration but + # not an actionable duplicate on its own. Related companies, + # branches, co-tenants, and shared office details are common + # enough that surfacing every such pair recreates the review + # explosion this report exists to prevent. + continue + + auto_components = _components(auto_edges) + oversized_members = set().union( + *( + component + for component in auto_components + if len(component) > MAX_AUTO_GROUP_SIZE + ), + set(), + ) + accepted_auto = [ + component + for component in auto_components + if len(component) <= MAX_AUTO_GROUP_SIZE + ] + if oversized_members: + for pair in auto_edges: + if pair[0] in oversized_members or pair[1] in oversized_members: + review_edges.add(pair) + reasons_by_pair[pair].add("oversized_component") + + effective_company: dict[UUID, UUID] = { + company.id: company.id for company in companies + } + for component in accepted_auto: + canonical = min( + (companies_by_id[company_id] for company_id in component), + key=lambda company: self._company_rank( + company, + links_by_company[company.id], + ), + ) + for company_id in component: + effective_company[company_id] = canonical.id + + review_edges = { + pair + for pair in review_edges + if effective_company[pair[0]] != effective_company[pair[1]] + } + review_components = _components(review_edges) + groups = [ + self._company_group( + component, + "merge", + companies_by_id, + links_by_company, + pair_evidence, + reasons_by_pair, + ) + for component in accepted_auto + ] + groups.extend( + self._company_group( + component, + "review", + companies_by_id, + links_by_company, + pair_evidence, + reasons_by_pair, + ) + for component in review_components + ) + groups.sort( + key=lambda group: ( + group["recommendation"] != "merge", + group["members"][0]["name"].casefold(), + group["group_id"], + ) + ) + return groups, effective_company + + @staticmethod + def _company_rank( + company: Company, + links: list[CompanyPersonLink], + ) -> tuple[bool, bool, bool, int, object, str]: + from apps.job.models import Job + + return ( + company.xero_archived, + not company.allow_jobs, + normalize_company_name(company.name) != _normalise_text(company.name), + -(Job.objects.filter(company=company).count() + len(links)), + company.django_created_at, + str(company.id), + ) + + def _company_group( + self, + member_ids: set[UUID], + recommendation: Recommendation, + companies_by_id: dict[UUID, Company], + links_by_company: dict[UUID, list[CompanyPersonLink]], + pair_evidence: dict[Pair, dict[EvidenceKind, set[str]]], + reasons_by_pair: dict[Pair, set[str]], + ) -> DuplicateCompanyGroup: + companies = [companies_by_id[company_id] for company_id in member_ids] + canonical = min( + companies, + key=lambda company: self._company_rank( + company, + links_by_company[company.id], + ), + ) + evidence = self._evidence_for_members(member_ids, pair_evidence) + reason_codes = sorted( + { + reason + for pair, reasons in reasons_by_pair.items() + if set(pair) <= member_ids + for reason in reasons + } + ) + if recommendation == "review" and reason_codes == ["shared_person_identity"]: + reason_codes = ["shared_person_only"] + return { + "group_id": _group_id("company", member_ids), + "fingerprint": _fingerprint("company", member_ids, evidence), + "recommendation": recommendation, + "reason_codes": reason_codes, + "canonical_id": str(canonical.id) if recommendation == "merge" else None, + "members": [ + self._company_member(company, links_by_company[company.id]) + for company in sorted(companies, key=lambda item: item.name.casefold()) + ], + "evidence": evidence, + } + + @staticmethod + def _company_member( + company: Company, + links: list[CompanyPersonLink], + ) -> DuplicateCompanyMember: + from apps.job.models import Job + + return { + "company_id": str(company.id), + "name": company.name, + "email": company.email, + "address": company.address, + "allow_jobs": company.allow_jobs, + "is_account_customer": company.is_account_customer, + "is_supplier": company.is_supplier, + "xero_archived": company.xero_archived, + "job_count": Job.objects.filter(company=company).count(), + "contact_names": sorted( + {link.person.name for link in links}, + key=str.casefold, + ), + } + + def _person_groups( + self, + people: list[Person], + links_by_person: dict[UUID, list[CompanyPersonLink]], + methods_by_person: dict[UUID, list[ContactMethod]], + pair_evidence: dict[Pair, dict[EvidenceKind, set[str]]], + signal_owners: dict[tuple[EvidenceKind, str], set[UUID]], + effective_company: dict[UUID, UUID], + ) -> list[DuplicatePersonGroup]: + people_by_id = {person.id: person for person in people} + auto_edges: set[Pair] = set() + review_edges: set[Pair] = set() + reasons_by_pair: dict[Pair, set[str]] = defaultdict(set) + for pair, matches in pair_evidence.items(): + first = people_by_id[pair[0]] + second = people_by_id[pair[1]] + first_companies = { + effective_company[link.company_id] for link in links_by_person[first.id] + } + second_companies = { + effective_company[link.company_id] + for link in links_by_person[second.id] + } + same_company = bool(first_companies & second_companies) + exact_name = bool(matches.get("name")) + compatible = person_names_strongly_compatible(first.name, second.name) + rare_email = any( + len(signal_owners[("email", value)]) <= MAX_RARE_OWNERS + and not _generic_email(value) + for value in matches.get("email", set()) + ) + rare_phone = any( + len(signal_owners[("phone", value)]) <= MAX_RARE_OWNERS + for value in matches.get("phone", set()) + ) + if same_company and exact_name: + auto_edges.add(pair) + reasons_by_pair[pair].add("same_company_exact_name") + elif compatible and (rare_email or rare_phone): + auto_edges.add(pair) + reasons_by_pair[pair].add("compatible_name_and_contact") + elif rare_email or rare_phone: + review_edges.add(pair) + reasons_by_pair[pair].add("conflicting_names_shared_contact") + else: + continue + + auto_components = _components(auto_edges) + accepted_auto = [ + component + for component in auto_components + if len(component) <= MAX_AUTO_GROUP_SIZE + ] + oversized = [ + component + for component in auto_components + if len(component) > MAX_AUTO_GROUP_SIZE + ] + for component in oversized: + ordered = sorted(component, key=str) + for index, first_id in enumerate(ordered): + for second_id in ordered[index + 1 :]: + review_edges.add((first_id, second_id)) + reasons_by_pair[(first_id, second_id)].add("oversized_component") + + auto_owner: dict[UUID, UUID] = {} + for component in accepted_auto: + root = min(component, key=str) + for person_id in component: + auto_owner[person_id] = root + review_edges = { + pair + for pair in review_edges + if auto_owner.get(pair[0], pair[0]) != auto_owner.get(pair[1], pair[1]) + } + review_components = _components(review_edges) + groups = [ + self._person_group( + component, + "merge", + people_by_id, + links_by_person, + methods_by_person, + pair_evidence, + reasons_by_pair, + ) + for component in accepted_auto + ] + groups.extend( + self._person_group( + component, + "review", + people_by_id, + links_by_person, + methods_by_person, + pair_evidence, + reasons_by_pair, + ) + for component in review_components + ) + groups.sort( + key=lambda group: ( + group["recommendation"] != "merge", + group["members"][0]["name"].casefold(), + group["group_id"], + ) + ) + return groups + + @staticmethod + def _person_rank( + person: Person, + links: list[CompanyPersonLink], + methods: list[ContactMethod], + ) -> tuple[bool, int, int, bool, object, str]: + from apps.crm.models import PhoneCallRecord + from apps.job.models import Job + + activity = ( + Job.objects.filter(person=person).count() + + PhoneCallRecord.objects.filter(person=person).count() + ) + return ( + not person.is_active, + -activity, + -(len(links) + len(methods)), + len(normalize_person_name(person.name).split()) < 2, + person.created_at, + str(person.id), + ) + + def _person_group( + self, + member_ids: set[UUID], + recommendation: Recommendation, + people_by_id: dict[UUID, Person], + links_by_person: dict[UUID, list[CompanyPersonLink]], + methods_by_person: dict[UUID, list[ContactMethod]], + pair_evidence: dict[Pair, dict[EvidenceKind, set[str]]], + reasons_by_pair: dict[Pair, set[str]], + ) -> DuplicatePersonGroup: + people = [people_by_id[person_id] for person_id in member_ids] + canonical = min( + people, + key=lambda person: self._person_rank( + person, + links_by_person[person.id], + methods_by_person[person.id], + ), + ) + evidence = self._evidence_for_members(member_ids, pair_evidence) + return { + "group_id": _group_id("person", member_ids), + "fingerprint": _fingerprint("person", member_ids, evidence), + "recommendation": recommendation, + "reason_codes": sorted( + { + reason + for pair, reasons in reasons_by_pair.items() + if set(pair) <= member_ids + for reason in reasons + } + ), + "canonical_id": str(canonical.id) if recommendation == "merge" else None, + "members": [ + self._person_member( + person, + links_by_person[person.id], + methods_by_person[person.id], + ) + for person in sorted(people, key=lambda item: item.name.casefold()) + ], + "evidence": evidence, + } + + @staticmethod + def _person_member( + person: Person, + links: list[CompanyPersonLink], + methods: list[ContactMethod], + ) -> DuplicatePersonMember: + from apps.crm.models import PhoneCallRecord + from apps.job.models import Job + + return { + "person_id": str(person.id), + "name": person.name, + "email": person.email, + "is_active": person.is_active, + "created_at": person.created_at, + "updated_at": person.updated_at, + "company_links": [ + { + "link_id": str(link.id), + "company_id": str(link.company_id), + "company_name": link.company.name, + "position": link.position, + "is_primary": link.is_primary, + "is_active": link.is_active, + } + for link in sorted( + links, + key=lambda item: (item.company.name.casefold(), str(item.id)), + ) + ], + "contact_methods": [ + { + "method_id": str(method.id), + "method_type": method.method_type, + "value": method.value, + "normalized_value": method.normalized_value, + "contact_label": method.label, + "is_primary": method.is_primary, + } + for method in sorted( + methods, + key=lambda item: ( + item.method_type, + item.normalized_value, + str(item.id), + ), + ) + ], + "job_count": Job.objects.filter(person=person).count(), + "phone_call_count": PhoneCallRecord.objects.filter(person=person).count(), + } + + @staticmethod + def _evidence_for_members( + member_ids: set[UUID], + pair_evidence: dict[Pair, dict[EvidenceKind, set[str]]], + ) -> list[DuplicateIdentityEvidence]: + values: dict[tuple[EvidenceKind, str], set[UUID]] = defaultdict(set) + for pair, matches in pair_evidence.items(): + if not set(pair) <= member_ids: + continue + for kind, normalized_values in matches.items(): + for normalized_value in normalized_values: + values[(kind, normalized_value)].update(pair) + return [ + { + "kind": kind, + "normalized_value": normalized_value, + "owner_count": len(owners), + } + for (kind, normalized_value), owners in sorted( + values.items(), + key=lambda item: (item[0][0], item[0][1]), + ) + ] diff --git a/apps/company/services/duplicate_person_report.py b/apps/company/services/duplicate_person_report.py new file mode 100644 index 000000000..7653de51c --- /dev/null +++ b/apps/company/services/duplicate_person_report.py @@ -0,0 +1,369 @@ +"""Find distinct Person rows that share exact identity signals.""" + +import re +from collections import defaultdict +from datetime import datetime +from functools import lru_cache +from typing import Literal, TypedDict +from uuid import UUID + +from django.db.models import Count +from django.utils import timezone +from nicknames import name_triplets + +from apps.company.models import CompanyPersonLink, ContactMethod, Person + +MatchKind = Literal["name", "email", "phone"] +Confidence = Literal["high", "medium", "low"] + + +class DuplicatePersonCompanyLink(TypedDict): + link_id: str + company_id: str + company_name: str + position: str | None + is_primary: bool + is_active: bool + + +class DuplicatePersonContactMethod(TypedDict): + method_id: str + method_type: str + value: str + normalized_value: str + contact_label: str + is_primary: bool + + +class DuplicatePersonSummary(TypedDict): + person_id: str + name: str + email: str | None + is_active: bool + created_at: datetime + updated_at: datetime + company_links: list[DuplicatePersonCompanyLink] + contact_methods: list[DuplicatePersonContactMethod] + job_count: int + phone_call_count: int + + +class DuplicatePersonMatch(TypedDict): + kind: MatchKind + normalized_value: str + + +class DuplicatePersonCandidate(TypedDict): + confidence: Confidence + matches: list[DuplicatePersonMatch] + shared_company_ids: list[str] + first_person: DuplicatePersonSummary + second_person: DuplicatePersonSummary + + +class DuplicatePersonReportSummary(TypedDict): + candidate_pairs: int + people_flagged: int + high: int + medium: int + low: int + + +class DuplicatePersonReport(TypedDict): + duplicate_people: list[DuplicatePersonCandidate] + summary: DuplicatePersonReportSummary + checked_at: datetime + + +def normalize_person_name(value: str) -> str: + """Normalize an exact-name signal without introducing fuzzy matching.""" + return " ".join(re.sub(r"[^\w]+", " ", value.casefold()).split()) + + +def normalize_person_email(value: str | None) -> str: + return (value or "").strip().casefold() + + +def _confidence(match_kinds: set[MatchKind]) -> Confidence: + if "name" in match_kinds and match_kinds & {"email", "phone"}: + return "high" + if "phone" in match_kinds or "email" in match_kinds: + return "medium" + return "low" + + +def _name_tokens(value: str) -> list[str]: + return normalize_person_name(value).split() + + +@lru_cache(maxsize=1) +def _alias_groups() -> dict[str, set[str]]: + groups: dict[str, set[str]] = defaultdict(set) + for triplet in name_triplets(): + canonical = normalize_person_name(triplet.name1) + groups[canonical].add(canonical) + groups[canonical].add(normalize_person_name(triplet.name2)) + return groups + + +def _build_alias_index(groups: dict[str, set[str]]) -> dict[str, frozenset[str]]: + canonical_by_name: dict[str, set[str]] = defaultdict(set) + for canonical, names in groups.items(): + for name in names: + canonical_by_name[name].add(canonical) + return { + name: frozenset(canonical_names) + for name, canonical_names in canonical_by_name.items() + } + + +@lru_cache(maxsize=1) +def _default_alias_index() -> dict[str, frozenset[str]]: + return _build_alias_index(_alias_groups()) + + +def person_names_compatible( + first_name: str, + second_name: str, + *, + alias_groups: dict[str, set[str]] | None = None, +) -> bool: + """Return whether two names can support an exact contact-method match.""" + first_tokens = _name_tokens(first_name) + second_tokens = _name_tokens(second_name) + if not first_tokens or not second_tokens: + return False + if first_tokens == second_tokens: + return True + + alias_index = ( + _default_alias_index() + if alias_groups is None + else _build_alias_index(alias_groups) + ) + first_names_match = first_tokens[0] == second_tokens[0] + first_canonical = alias_index.get(first_tokens[0], frozenset()) + second_canonical = alias_index.get(second_tokens[0], frozenset()) + if not first_names_match and not first_canonical & second_canonical: + return False + + first_surname = first_tokens[1:] + second_surname = second_tokens[1:] + if first_surname and second_surname: + return first_surname == second_surname + return True + + +class DuplicatePersonReportService: + """Build exact-signal candidate pairs for operator review.""" + + def get_report(self) -> DuplicatePersonReport: + people = list(Person.objects.order_by("id")) + person_ids = [person.id for person in people] + links_by_person: dict[UUID, list[CompanyPersonLink]] = defaultdict(list) + for link in CompanyPersonLink.objects.filter( + person_id__in=person_ids + ).select_related("company"): + links_by_person[link.person_id].append(link) + methods_by_person: dict[UUID, list[ContactMethod]] = defaultdict(list) + for method in ContactMethod.objects.filter(person_id__in=person_ids): + if method.person_id is None: + raise RuntimeError(f"Person contact method {method.id} has no Person") + methods_by_person[method.person_id].append(method) + job_counts = self._job_counts(person_ids) + call_counts = self._call_counts(person_ids) + summaries = { + person.id: self._person_summary( + person, + links=links_by_person[person.id], + methods=methods_by_person[person.id], + job_count=job_counts.get(person.id, 0), + phone_call_count=call_counts.get(person.id, 0), + ) + for person in people + } + + signal_owners: dict[tuple[MatchKind, str], set[UUID]] = defaultdict(set) + for person in people: + name = normalize_person_name(person.name) + if name: + signal_owners[("name", name)].add(person.id) + + email = normalize_person_email(person.email) + if email: + signal_owners[("email", email)].add(person.id) + + for method in methods_by_person[person.id]: + if method.method_type == ContactMethod.MethodType.EMAIL: + kind: MatchKind = "email" + elif method.method_type == ContactMethod.MethodType.PHONE: + kind = "phone" + else: + raise ValueError( + f"Unknown contact method type {method.method_type!r}" + ) + if method.normalized_value: + signal_owners[(kind, method.normalized_value)].add(person.id) + + pair_matches: dict[tuple[UUID, UUID], dict[MatchKind, set[str]]] = defaultdict( + lambda: defaultdict(set) + ) + for (kind, normalized_value), owners in signal_owners.items(): + ordered_owners = sorted(owners, key=str) + for first_index, first_id in enumerate(ordered_owners): + for second_id in ordered_owners[first_index + 1 :]: + pair_matches[(first_id, second_id)][kind].add(normalized_value) + + aliases = _alias_groups() + for pair, matches in pair_matches.items(): + if "name" in matches: + continue + first_id, second_id = pair + if not matches.keys() & {"email", "phone"}: + continue + if person_names_compatible( + summaries[first_id]["name"], + summaries[second_id]["name"], + alias_groups=aliases, + ): + normalized_names = sorted( + { + normalize_person_name(summaries[first_id]["name"]), + normalize_person_name(summaries[second_id]["name"]), + } + ) + matches["name"].add(" ~ ".join(normalized_names)) + + candidates = [ + self._candidate(pair, matches, summaries) + for pair, matches in pair_matches.items() + ] + confidence_order: dict[Confidence, int] = {"high": 0, "medium": 1, "low": 2} + candidates.sort( + key=lambda candidate: ( + confidence_order[candidate["confidence"]], + candidate["first_person"]["name"].casefold(), + candidate["second_person"]["name"].casefold(), + candidate["first_person"]["person_id"], + candidate["second_person"]["person_id"], + ) + ) + flagged_ids = { + person["person_id"] + for candidate in candidates + for person in (candidate["first_person"], candidate["second_person"]) + } + return { + "duplicate_people": candidates, + "summary": { + "candidate_pairs": len(candidates), + "people_flagged": len(flagged_ids), + "high": sum(c["confidence"] == "high" for c in candidates), + "medium": sum(c["confidence"] == "medium" for c in candidates), + "low": sum(c["confidence"] == "low" for c in candidates), + }, + "checked_at": timezone.now(), + } + + @staticmethod + def _job_counts(person_ids: list[UUID]) -> dict[UUID, int]: + from apps.job.models import Job + + return { + person_id: count + for person_id, count in Job.objects.filter(person_id__in=person_ids) + .values_list("person_id") + .annotate(count=Count("id")) + } + + @staticmethod + def _call_counts(person_ids: list[UUID]) -> dict[UUID, int]: + from apps.crm.models import PhoneCallRecord + + return { + person_id: count + for person_id, count in PhoneCallRecord.objects.filter( + person_id__in=person_ids + ) + .values_list("person_id") + .annotate(count=Count("id")) + } + + @staticmethod + def _person_summary( + person: Person, + *, + links: list[CompanyPersonLink], + methods: list[ContactMethod], + job_count: int, + phone_call_count: int, + ) -> DuplicatePersonSummary: + link_summaries: list[DuplicatePersonCompanyLink] = [ + { + "link_id": str(link.id), + "company_id": str(link.company_id), + "company_name": link.company.name, + "position": link.position, + "is_primary": link.is_primary, + "is_active": link.is_active, + } + for link in sorted( + links, + key=lambda item: (item.company.name.casefold(), str(item.id)), + ) + ] + method_summaries: list[DuplicatePersonContactMethod] = [ + { + "method_id": str(method.id), + "method_type": method.method_type, + "value": method.value, + "normalized_value": method.normalized_value, + "contact_label": method.label, + "is_primary": method.is_primary, + } + for method in sorted( + methods, + key=lambda item: ( + item.method_type, + item.normalized_value, + str(item.id), + ), + ) + ] + return { + "person_id": str(person.id), + "name": person.name, + "email": person.email, + "is_active": person.is_active, + "created_at": person.created_at, + "updated_at": person.updated_at, + "company_links": link_summaries, + "contact_methods": method_summaries, + "job_count": job_count, + "phone_call_count": phone_call_count, + } + + @staticmethod + def _candidate( + pair: tuple[UUID, UUID], + matches_by_kind: dict[MatchKind, set[str]], + summaries: dict[UUID, DuplicatePersonSummary], + ) -> DuplicatePersonCandidate: + first_id, second_id = pair + match_kind_order: tuple[MatchKind, ...] = ("name", "email", "phone") + matches: list[DuplicatePersonMatch] = [ + {"kind": kind, "normalized_value": value} + for kind in match_kind_order + for value in sorted(matches_by_kind.get(kind, set())) + ] + first = summaries[first_id] + second = summaries[second_id] + first_companies = {link["company_id"] for link in first["company_links"]} + second_companies = {link["company_id"] for link in second["company_links"]} + return { + "confidence": _confidence(set(matches_by_kind)), + "matches": matches, + "shared_company_ids": sorted(first_companies & second_companies), + "first_person": first, + "second_person": second, + } diff --git a/apps/client/services/duplicate_phone_report.py b/apps/company/services/duplicate_phone_report.py similarity index 50% rename from apps/client/services/duplicate_phone_report.py rename to apps/company/services/duplicate_phone_report.py index 656cde169..d0f7fee0d 100644 --- a/apps/client/services/duplicate_phone_report.py +++ b/apps/company/services/duplicate_phone_report.py @@ -1,21 +1,11 @@ -"""Data Quality report: phone numbers that break the one-number-one-client 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 - company's own internal ``PhoneEndpoint`` lines (e.g. a staff line mis-filed as a - customer contact). -""" +"""Data Quality report: phone numbers that break the one-number-one-company rule.""" from datetime import datetime from typing import TypedDict -from django.db.models import Count -from django.db.models.functions import Coalesce from django.utils import timezone -from apps.client.models import ClientContactMethod +from apps.company.models import ContactMethod from apps.crm.models import PhoneEndpoint @@ -23,7 +13,7 @@ class DuplicatePhoneOwner(TypedDict): method_id: str owner_kind: str owner_name: str - effective_client_id: str | None + effective_company_id: str | None class DuplicatePhoneIssue(TypedDict): @@ -34,7 +24,7 @@ class DuplicatePhoneIssue(TypedDict): class DuplicatePhoneSummary(TypedDict): - cross_client: int + cross_company: int internal_line: int @@ -48,50 +38,74 @@ 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_company": len(cross_company), "internal_line": len(internal_line), }, "checked_at": timezone.now(), } - def _cross_client_conflicts(self) -> list[DuplicatePhoneIssue]: - phones = ClientContactMethod.objects.filter( - method_type=ClientContactMethod.MethodType.PHONE + def _cross_company_conflicts(self) -> list[DuplicatePhoneIssue]: + phones = ContactMethod.objects.filter( + method_type=ContactMethod.MethodType.PHONE ) - conflict_numbers = [ - row["normalized_value"] - for row in phones.values("normalized_value") - .annotate( - clients=Count( - Coalesce("client_id", "contact__client_id"), distinct=True - ) + companies_by_owner_by_number: dict[str, dict[tuple[str, str], set[str]]] = {} + for method in phones.select_related("company", "person").prefetch_related( + "person__company_links" + ): + if method.person_id is not None: + owner_key = ("person", str(method.person_id)) + elif method.company_id is not None: + owner_key = ("company", str(method.company_id)) + else: + raise RuntimeError(f"Contact method {method.id} has no owner") + owners = companies_by_owner_by_number.setdefault( + method.normalized_value, {} ) - .filter(clients__gt=1) + owners.setdefault(owner_key, set()).update( + str(company_id) for company_id in method.owner_company_ids() + ) + conflict_numbers = [ + number + for number, owners in companies_by_owner_by_number.items() + if self._owners_have_no_common_company(owners) ] 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", "person") + .prefetch_related("person__company_links") .order_by("normalized_value", "id") ): grouped.setdefault(method.normalized_value, []).append(self._owner(method)) return [ { "normalized_value": number, - "issue": "cross_client", + "issue": "cross_company", "endpoint_label": None, "owners": owners, } for number, owners in grouped.items() ] + @staticmethod + def _owners_have_no_common_company( + companies_by_owner: dict[tuple[str, str], set[str]], + ) -> bool: + if len(companies_by_owner) < 2: + return False + owner_company_sets = iter(companies_by_owner.values()) + common_companies = set(next(owner_company_sets)) + for company_ids in owner_company_sets: + common_companies.intersection_update(company_ids) + return not common_companies + def _internal_line_collisions(self) -> list[DuplicatePhoneIssue]: endpoint_labels = dict( PhoneEndpoint.objects.filter(is_active=True).values_list( @@ -108,23 +122,24 @@ def _internal_line_collisions(self) -> list[DuplicatePhoneIssue]: "owners": [self._owner(method)], } for method in ( - ClientContactMethod.objects.filter( - method_type=ClientContactMethod.MethodType.PHONE, + ContactMethod.objects.filter( + method_type=ContactMethod.MethodType.PHONE, normalized_value__in=list(endpoint_labels), ) - .select_related("client", "contact", "contact__client") + .select_related("company", "person") + .prefetch_related("person__company_links") .order_by("normalized_value", "id") ) ] @staticmethod - def _owner(method: ClientContactMethod) -> DuplicatePhoneOwner: - effective_client_id = method.owner_client_id() + def _owner(method: ContactMethod) -> DuplicatePhoneOwner: + 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 "person", "owner_name": method.owner_display_name(), - "effective_client_id": ( - str(effective_client_id) if effective_client_id else None + "effective_company_id": ( + 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/company/services/kan278_duplicate_cleanup.py b/apps/company/services/kan278_duplicate_cleanup.py new file mode 100644 index 000000000..6354ddde9 --- /dev/null +++ b/apps/company/services/kan278_duplicate_cleanup.py @@ -0,0 +1,3097 @@ +"""Finite, human-reviewed KAN-278 cleanup for the production dataset. + +The decisions below are data, not matching rules. Every production candidate was +reviewed and is either in a named merge decision or in a named retained decision. +No database identity is embedded here: database IDs are resolved only after the +human-readable evidence has been checked against the database. +""" + +from dataclasses import dataclass +from uuid import UUID + +from django.db import transaction +from django.db.models import Q + +from apps.accounts.models import Staff +from apps.company.models import Company, CompanyPersonLink, ContactMethod, Person +from apps.company.services.company_merge_service import merge_companies +from apps.company.services.duplicate_identity_report import ( + DuplicateIdentityReportService, +) +from apps.company.services.person_merge_service import merge_people + + +@dataclass(frozen=True) +class CompanyMergeDecision: + canonical_name: str + names: tuple[str, ...] + expected_rows: int + evidence: str + + +@dataclass(frozen=True) +class PersonSelector: + name: str + email: str | None + company_name: str | None + + +@dataclass(frozen=True) +class PersonMergeDecision: + canonical: PersonSelector + members: tuple[PersonSelector, ...] + expected_people: int + evidence: str + + +@dataclass(frozen=True) +class RetainedDecision: + names: tuple[str, ...] + evidence: str + + +@dataclass(frozen=True) +class InvalidLinkDecision: + company_name: str + person_name: str + person_email: str | None + evidence: str + + +REVIEWED_COMPANY_MERGES: tuple[CompanyMergeDecision, ...] = ( + CompanyMergeDecision( + canonical_name="2Talk Limited", + names=("2Talk", "2Talk Limited"), + expected_rows=2, + evidence="production jobs 2Talk=0, 2Talk Limited=0", + ), + CompanyMergeDecision( + canonical_name="acryfab", + names=("acryfab", "CASH SALE - BLAYNE NEWTON"), + expected_rows=2, + evidence="production jobs acryfab=1, CASH SALE - BLAYNE NEWTON=1; shared contact blayne newton; shared email blayne@acryfab.co.nz; shared email domain acryfab.co.nz", + ), + CompanyMergeDecision( + canonical_name="Actual Building Services", + names=("Actual Building Services", "CASH SALE - SHANE ANNISS"), + expected_rows=2, + evidence="production jobs Actual Building Services=0, CASH SALE - SHANE ANNISS=1; shared email shane.anniss@xtra.co.nz", + ), + CompanyMergeDecision( + canonical_name="Aesthetix", + names=("Aesthetix", "Priel Segev"), + expected_rows=2, + evidence="production jobs Aesthetix=3, Priel Segev=0; shared email domain aesthetix.co.nz; shared email priel@aesthetix.co.nz", + ), + CompanyMergeDecision( + canonical_name="Archer Hospitality", + names=("Archer Hospitality", "Lionel Don"), + expected_rows=2, + evidence="production jobs Archer Hospitality=0, Lionel Don=1; shared email domain archerconcepts.co.nz", + ), + CompanyMergeDecision( + canonical_name="Assa Abloy - Entrance Systems Ltd", + names=("Assa Abloy - Entrance Systems Ltd", "Davis"), + expected_rows=2, + evidence="production jobs Assa Abloy - Entrance Systems Ltd=0, Davis=0; shared email domain assaabloy.com", + ), + CompanyMergeDecision( + canonical_name="B C Hastings", + names=("B C Hastings", "Brian Hastings", "CASH - SALE - BRIAN HASTINGS"), + expected_rows=3, + evidence="production jobs B C Hastings=0, Brian Hastings=1, CASH - SALE - BRIAN HASTINGS=0; shared email billieh@xtra.co.nz; shared phone +64221770482", + ), + CompanyMergeDecision( + canonical_name="Bad Girl Creek Production", + names=("Bad Girl Creek Production", "Manu One Limited - account closed"), + expected_rows=2, + evidence="production jobs Bad Girl Creek Production=0, Manu One Limited - account closed=0; shared email gmills989@gmail.com", + ), + CompanyMergeDecision( + canonical_name="Bikaner Foods", + names=("Bikaner Foods", "CASH SALE - ASHOKA SHARMA"), + expected_rows=2, + evidence="production jobs Bikaner Foods=0, CASH SALE - ASHOKA SHARMA=1; shared email ashoknz@yahoo.com; shared email domain yahoo.com", + ), + CompanyMergeDecision( + canonical_name="Blackstone Project Management", + names=("Blackstone Project Management", "Bobby Prajito"), + expected_rows=2, + evidence="production jobs Blackstone Project Management=3, Bobby Prajito=0; shared email bobby@blackstonepm.co.nz; shared email domain blackstonepm.co.nz", + ), + CompanyMergeDecision( + canonical_name="C V Compton Ltd", + names=("C V Compton Ltd", "Onsite Mechanics NZ Ltd"), + expected_rows=2, + evidence="production jobs C V Compton Ltd=1, Onsite Mechanics NZ Ltd=1; shared email domain cvcompton.co.nz", + ), + CompanyMergeDecision( + canonical_name="Camson Hoist Hire Limited", + names=("Camson Hoist Hire Limited", "CASH SALE - Lift truck"), + expected_rows=2, + evidence="production jobs Camson Hoist Hire Limited=0, CASH SALE - Lift truck=1; shared email domain liftrucks.co.nz", + ), + CompanyMergeDecision( + canonical_name="CASH SALE - Alex McCormick", + names=("CASH SALE - Alex McC", "CASH SALE - Alex McCormick"), + expected_rows=2, + evidence="production jobs CASH SALE - Alex McC=0, CASH SALE - Alex McCormick=0; shared phone +64226160300", + ), + CompanyMergeDecision( + canonical_name="CASH SALE - ASSET FORCE", + names=("CASH SALE - ASSET FORCE", "CASH SALE - ONE STOP CUTTING SHOP"), + expected_rows=2, + evidence="production jobs CASH SALE - ASSET FORCE=2, CASH SALE - ONE STOP CUTTING SHOP=1; shared email domain assetforce.co.nz; shared email jonas@assetforce.co.nz; shared phone +64277562134", + ), + CompanyMergeDecision( + canonical_name="CASH SALE - AZ PANELS REPAIRS", + names=("Azam 021677855", "CASH SALE - AZ PANELS REPAIRS"), + expected_rows=2, + evidence="production jobs Azam 021677855=1, CASH SALE - AZ PANELS REPAIRS=1; shared phone +6421677855", + ), + CompanyMergeDecision( + canonical_name="CASH SALE - BRONWYN JACKSON", + names=("Bronnie 02102255430", "CASH SALE - BRONWYN JACKSON"), + expected_rows=2, + evidence="production jobs Bronnie 02102255430=1, CASH SALE - BRONWYN JACKSON=2; shared phone +642102255430", + ), + CompanyMergeDecision( + canonical_name="CASH SALE - Church Street Panelbeaters", + names=( + "CASH SALE - Church St Motors", + "CASH SALE - CHURCH ST PANELBEATERS", + "CASH SALE - Church Street Panelbeaters", + ), + expected_rows=3, + evidence="production jobs CASH SALE - Church St Motors=3, CASH SALE - CHURCH ST PANELBEATERS=0, CASH SALE - Church Street Panelbeaters=1; shared contact onkar goundar; shared email church.st.panelbeaters@gmail.com; shared phone +64275670142", + ), + CompanyMergeDecision( + canonical_name="CASH SALE - DAVID BOOTH", + names=("CASH SALE - Dave Booth", "CASH SALE - DAVID BOOTH"), + expected_rows=2, + evidence="production jobs CASH SALE - Dave Booth=1, CASH SALE - DAVID BOOTH=1; shared email carpenterdavebooth@gmail.com; shared phone +642102631588", + ), + CompanyMergeDecision( + canonical_name="CASH SALE - DOT DESIGN SAM GOODRIDGE", + names=( + "CASH SALE - DOT DEISIGN DAM GOODRIDGE", + "CASH SALE - DOT DESIGN SAM GOODRIDGE", + ), + expected_rows=2, + evidence="production jobs CASH SALE - DOT DEISIGN DAM GOODRIDGE=0, CASH SALE - DOT DESIGN SAM GOODRIDGE=1; shared phone +610420355724", + ), + CompanyMergeDecision( + canonical_name="CASH SALE - ELIJAH HARIDAS", + names=("CASH SALE - ELIJAH HARIDAS",), + expected_rows=2, + evidence="production jobs CASH SALE - ELIJAH HARIDAS=0, CASH SALE - ELIJAH HARIDAS=1; shared contact elijah haridas; shared email elijahraulharidas@gmail.com; shared phone +642904302129", + ), + CompanyMergeDecision( + canonical_name="CASH SALE - LIAM GREENWAY", + names=("CASH - SALE - Liam Greenway", "CASH SALE - LIAM GREENWAY"), + expected_rows=2, + evidence="production jobs CASH - SALE - Liam Greenway=1, CASH SALE - LIAM GREENWAY=1; shared email lbgreenway@gmail.com", + ), + CompanyMergeDecision( + canonical_name="CASH SALE - MARCUS SHELLEY", + names=("CASH SALE - MARCUS SHELLEY", "CASH SALE - MARCUS SHELLY"), + expected_rows=2, + evidence="production jobs CASH SALE - MARCUS SHELLEY=1, CASH SALE - MARCUS SHELLY=0; shared email theshelleysnz@gmail.com; shared phone +6421979629", + ), + CompanyMergeDecision( + canonical_name="CASH SALE - MARSH COOPER", + names=("CASH SALE - MARSH COOPER", "Marsh"), + expected_rows=2, + evidence="production jobs CASH SALE - MARSH COOPER=1, Marsh=1; shared phone +6421920315", + ), + CompanyMergeDecision( + canonical_name="CASH SALE - NEW GENERATION OPERATIONS", + names=( + "Ariki Gate and Cross Stock Depo", + "CASH SALE - NEW GENERATION OPERATIONS", + "Ian Matthew", + ), + expected_rows=3, + evidence="production jobs Ariki Gate and Cross Stock Depo=0, CASH SALE - NEW GENERATION OPERATIONS=1, Ian Matthew=1; shared email annettematthew1970@gmail.com; shared phone +64274754864", + ), + CompanyMergeDecision( + canonical_name="CASH SALE - ONEHUNGA AUTOMOTIVE NZ LTD", + names=( + "CASH SALE - ONEHUNGA AUTOMOTIVE NZ LTD", + "CASH SALE - ONEHUNGA AUTOMOTIVE NZ LTD - JABIL", + ), + expected_rows=2, + evidence="production jobs CASH SALE - ONEHUNGA AUTOMOTIVE NZ LTD=0, CASH SALE - ONEHUNGA AUTOMOTIVE NZ LTD - JABIL=1; shared email oanz202@gmail.com; shared phone +6421803134", + ), + CompanyMergeDecision( + canonical_name="CASH SALE - REM WADEH", + names=("CASH - SALE - RERM WADEH", "CASH SALE - REM WADEH"), + expected_rows=2, + evidence="production jobs CASH - SALE - RERM WADEH=0, CASH SALE - REM WADEH=2; shared contact rem wadeh; shared email rem_wadeh@hotmail.com; shared phone +64211523239", + ), + CompanyMergeDecision( + canonical_name="CASH SALE - RICHARD MATHIESON", + names=("CASH SALE - REICHARD MATHIESON", "CASH SALE - RICHARD MATHIESON"), + expected_rows=2, + evidence="production jobs CASH SALE - REICHARD MATHIESON=0, CASH SALE - RICHARD MATHIESON=1; shared phone +64272787383", + ), + CompanyMergeDecision( + canonical_name="CASH SALE - Richard Pryde", + names=("CASH SALE - Richard Pryde",), + expected_rows=2, + evidence="production jobs CASH SALE - Richard Pryde=1, CASH SALE - Richard Pryde=0", + ), + CompanyMergeDecision( + canonical_name="CASH SALE - Rick Van Swet", + names=("CASH SALE - Rick Van Swet", "Rick vanderset"), + expected_rows=2, + evidence="production jobs CASH SALE - Rick Van Swet=1, Rick vanderset=1; shared phone +6421400711", + ), + CompanyMergeDecision( + canonical_name="CASH SALE - Seran", + names=("CASH SALE - Seran", "CASH SALE Seran"), + expected_rows=2, + evidence="production jobs CASH SALE - Seran=1, CASH SALE Seran=0", + ), + CompanyMergeDecision( + canonical_name="CASH SALE - SUPERCITY COMMERCIAL INTERIORS LTD", + names=( + "CASH SALE - Glen Barratt", + "CASH SALE - SUPERCITY COMMERCIAL INTERIORS LTD", + ), + expected_rows=2, + evidence="production jobs CASH SALE - Glen Barratt=1, CASH SALE - SUPERCITY COMMERCIAL INTERIORS LTD=1; shared phone +64274888111", + ), + CompanyMergeDecision( + canonical_name="CASH SALE - VERONICA BARTLETT", + names=("CASH SALE - VERONICA BARTLETT",), + expected_rows=2, + evidence="production jobs CASH SALE - VERONICA BARTLETT=0, CASH SALE - VERONICA BARTLETT=1", + ), + CompanyMergeDecision( + canonical_name="CASH SALE - VIKING BUILDERS LTD - PHIL", + names=("CASH SALE - Phil", "CASH SALE - VIKING BUILDERS LTD - PHIL", "Phil"), + expected_rows=3, + evidence="production jobs CASH SALE - Phil=1, CASH SALE - VIKING BUILDERS LTD - PHIL=1, Phil=2; shared contact phil; shared phone +642108568997", + ), + CompanyMergeDecision( + canonical_name="CASH SALE- Penrose motors", + names=("CASH SALE - STUART WATTS", "CASH SALE- Penrose motors"), + expected_rows=2, + evidence="production jobs CASH SALE - STUART WATTS=1, CASH SALE- Penrose motors=1; shared contact stuart watts; shared phone +64278172546", + ), + CompanyMergeDecision( + canonical_name="CASH SALE. Dema Grant", + names=("CASH - SALE - Dee", "CASH SALE. Dema Grant"), + expected_rows=2, + evidence="production jobs CASH - SALE - Dee=1, CASH SALE. Dema Grant=1; shared phone +64279186461", + ), + CompanyMergeDecision( + canonical_name="Cassidy Construction Limited", + names=("CASH SALE - PETROS PAN", "Cassidy Construction Limited"), + expected_rows=2, + evidence="production jobs CASH SALE - PETROS PAN=1, Cassidy Construction Limited=3; shared email domain cassidy.co.nz", + ), + CompanyMergeDecision( + canonical_name="CGO Corporate Ltd", + names=( + "CASH SALE - Mammoth Brands", + "CGO (Corporate) Limited", + "CGO Corporate Ltd", + ), + expected_rows=3, + evidence="production jobs CASH SALE - Mammoth Brands=0, CGO (Corporate) Limited=0, CGO Corporate Ltd=7; shared contact sarah newman; shared email accsupport@mammothbrands.nz; shared email domain mammothbrands.nz; shared email sarah@mammothbrands.nz", + ), + CompanyMergeDecision( + canonical_name="Classic Buildings Limited", + names=("Classic Buildings Limited", "Solarwind"), + expected_rows=2, + evidence="production jobs Classic Buildings Limited=0, Solarwind=0; shared email david@lm3.co.nz; shared email domain lm3.co.nz", + ), + CompanyMergeDecision( + canonical_name="Co-Mac (PN) Limited", + names=("Co-Mac (PN) Limited", "comac"), + expected_rows=2, + evidence="production jobs Co-Mac (PN) Limited=0, comac=0; shared email domain comac.co.nz", + ), + CompanyMergeDecision( + canonical_name="CSP Pacific", + names=("CSP Pacific", "Martin Swart"), + expected_rows=2, + evidence="production jobs CSP Pacific=0, Martin Swart=0; shared email domain csp.co.nz", + ), + CompanyMergeDecision( + canonical_name="Cushman and Wakefield New Zealand Limited", + names=( + "cash sale - ROBERT - CUSHMAN WAKEFIELD", + "Cushman and Wakefield New Zealand Limited", + "Rusty Tagicakibau", + ), + expected_rows=3, + evidence="production jobs cash sale - ROBERT - CUSHMAN WAKEFIELD=2, Cushman and Wakefield New Zealand Limited=143, Rusty Tagicakibau=1; shared contact robert; shared email domain cushwake.com; shared email rusty.tagicakibau@cushwake.com; shared phone +64212450109", + ), + CompanyMergeDecision( + canonical_name="Custom Controls Ltd", + names=("CASH SALE - MARK SEARS", "Custom Controls Ltd"), + expected_rows=2, + evidence="production jobs CASH SALE - MARK SEARS=0, Custom Controls Ltd=4; shared contact mark sears; shared email domain customcontrols.co.nz; shared email msears@customcontrols.co.nz; shared phone +6421758031", + ), + CompanyMergeDecision( + canonical_name="D M Dunningham Limited", + names=("D M Dunningham Limited", "Dion - Dunninghams"), + expected_rows=2, + evidence="production jobs D M Dunningham Limited=3, Dion - Dunninghams=0; shared contact dion smit; shared email dion.smit@dunninghams.co.nz; shared email domain dunninghams.co.nz; shared phone +64275556091", + ), + CompanyMergeDecision( + canonical_name="Dave Chown", + names=("Dave Chown", "David Chown"), + expected_rows=2, + evidence="production jobs Dave Chown=0, David Chown=0; shared email tkkid1939@gmail.com", + ), + CompanyMergeDecision( + canonical_name="Davison Construction", + names=("CASH SALE - LARS DAVISON", "Davison Construction"), + expected_rows=2, + evidence="production jobs CASH SALE - LARS DAVISON=2, Davison Construction=5; shared email domain davison.kiwi; shared email lars@davison.kiwi; shared phone +64274052692", + ), + CompanyMergeDecision( + canonical_name="DGE Ltd", + names=("CASH SALE - Tony Dickson", "DGE Ltd"), + expected_rows=3, + evidence="production jobs CASH SALE - Tony Dickson=1, DGE Ltd=0, DGE Ltd=36; shared email domain dge.nz", + ), + CompanyMergeDecision( + canonical_name="Diesel Works", + names=("Diesel Works", "Paul Harvey"), + expected_rows=2, + evidence="production jobs Diesel Works=2, Paul Harvey=0; shared email domain dieselworks.co.nz; shared email paul@dieselworks.co.nz", + ), + CompanyMergeDecision( + canonical_name="Dormakaba NZ Limited", + names=("Brian Pates", "Dormakaba NZ Limited"), + expected_rows=2, + evidence="production jobs Brian Pates=0, Dormakaba NZ Limited=0; shared email domain dormakaba.com", + ), + CompanyMergeDecision( + canonical_name="Eden park panel and paint", + names=("CASH SALE#edenpark panel and paint", "Eden park panel and paint"), + expected_rows=2, + evidence="production jobs CASH SALE#edenpark panel and paint=1, Eden park panel and paint=0; shared contact ernesto; shared phone +6421629559", + ), + CompanyMergeDecision( + canonical_name="Electrical Importing Company", + names=("Alan", "CASH SALE - Alan", "Electrical Importing Company"), + expected_rows=3, + evidence="production jobs Alan=0, CASH SALE - Alan=0, Electrical Importing Company=5; shared contact alan kelway; shared email domain eic.nz; shared phone +6496342978", + ), + CompanyMergeDecision( + canonical_name="ELS New Zealand Limited", + names=( + "CASH SALE - Econic Laundry Solutions", + "CASH SALE - ELS New Zealand", + "ELS New Zealand Limited", + ), + expected_rows=3, + evidence="production jobs CASH SALE - Econic Laundry Solutions=1, CASH SALE - ELS New Zealand=1, ELS New Zealand Limited=0; shared contact bob lopesi; shared email domain elsnz.co.nz", + ), + CompanyMergeDecision( + canonical_name="Evans European Limited", + names=("Evans European Limited", "Onehunga Car Painters"), + expected_rows=2, + evidence="production jobs Evans European Limited=0, Onehunga Car Painters=0; shared email domain evans-euro.co.nz", + ), + CompanyMergeDecision( + canonical_name="Expac Engineering Ltd (new account)", + names=( + "Expac Engineering Ltd (new account)", + "Expac Engineering Services Old account", + ), + expected_rows=2, + evidence="production jobs Expac Engineering Ltd (new account)=0, Expac Engineering Services Old account=0", + ), + CompanyMergeDecision( + canonical_name="Expol Packaging", + names=("Chris Agius", "Expol Packaging"), + expected_rows=2, + evidence="production jobs Chris Agius=0, Expol Packaging=0; shared email domain expol.co.nz", + ), + CompanyMergeDecision( + canonical_name="Fab Works Limited", + names=("CASH SALE - MOHAMMED KHAN", "Fab Works Limited", "Mohammed Khan"), + expected_rows=3, + evidence="production jobs CASH SALE - MOHAMMED KHAN=0, Fab Works Limited=1, Mohammed Khan=0; shared phone +64212654413", + ), + CompanyMergeDecision( + canonical_name="Field Services", + names=("CASH SALE - Field Services", "Field Services"), + expected_rows=2, + evidence="production jobs CASH SALE - Field Services=1, Field Services=0; shared email domain fieldservices.co.nz", + ), + CompanyMergeDecision( + canonical_name="Fieldline NZ Limited", + names=("Fieldline - old account not in use", "Fieldline NZ Limited"), + expected_rows=2, + evidence="production jobs Fieldline - old account not in use=0, Fieldline NZ Limited=0; shared email domain fieldline.co.nz", + ), + CompanyMergeDecision( + canonical_name="Free Co Flooring", + names=("CASH SALE - Jason Hu", "Free Co Flooring"), + expected_rows=2, + evidence="production jobs CASH SALE - Jason Hu=0, Free Co Flooring=5; shared contact jason hu; shared email domain freecoflooring.co.nz; shared email jason@freecoflooring.co.nz; shared phone +642108588883", + ), + CompanyMergeDecision( + canonical_name="FUZED LTD T/A GAST AUTOMOTIVE", + names=("CASH SALE - Gaast Automotive", "FUZED LTD T/A GAST AUTOMOTIVE"), + expected_rows=2, + evidence="production jobs CASH SALE - Gaast Automotive=1, FUZED LTD T/A GAST AUTOMOTIVE=0; shared email domain gast.co.nz; shared email gareth@gast.co.nz", + ), + CompanyMergeDecision( + canonical_name="Garry Lawrence Roofing", + names=("CASH SALE - GARY LAWRENCE", "Garry Lawrence Roofing"), + expected_rows=2, + evidence="production jobs CASH SALE - GARY LAWRENCE=1, Garry Lawrence Roofing=1; shared contact gary lawrence; shared phone +6421969601", + ), + CompanyMergeDecision( + canonical_name="Gilmours Mt Roskill", + names=("Gilmours Mt Roskill", "Greg Martin"), + expected_rows=2, + evidence="production jobs Gilmours Mt Roskill=0, Greg Martin=0; shared email domain gilmours.co.nz; shared email gregory.martin@gilmours.co.nz", + ), + CompanyMergeDecision( + canonical_name="Gordon & Ryan (2022) Ltd", + names=("CASH SALE - SALIM SHAIKH", "Gordon & Ryan (2022) Ltd"), + expected_rows=2, + evidence="production jobs CASH SALE - SALIM SHAIKH=2, Gordon & Ryan (2022) Ltd=0; shared email domain gordonandryan.co.nz; shared email salim@gordonandryan.co.nz", + ), + CompanyMergeDecision( + canonical_name="Grace International", + names=("Grace International", "Vidak Druskovich"), + expected_rows=2, + evidence="production jobs Grace International=0, Vidak Druskovich=1; shared email vidakdruskovich@gmail.com", + ), + CompanyMergeDecision( + canonical_name="Graphic Packaging International NZ Ltd", + names=( + "CASH SALE - Graphic Packaging", + "Graphic Packaging International NZ Ltd", + ), + expected_rows=2, + evidence="production jobs CASH SALE - Graphic Packaging=1, Graphic Packaging International NZ Ltd=0; shared email domain graphicpkg.com", + ), + CompanyMergeDecision( + canonical_name="Groundtest Equipment", + names=("Groundtest Equipment", "Seite-Last Engineering"), + expected_rows=2, + evidence="production jobs Groundtest Equipment=1, Seite-Last Engineering=1; shared email domain groundtest.co.nz; shared email richard@groundtest.co.nz", + ), + CompanyMergeDecision( + canonical_name="Gus Kanji", + names=("CASH SALE - GUS KANJI", "Gus Kanji"), + expected_rows=2, + evidence="production jobs CASH SALE - GUS KANJI=4, Gus Kanji=5; shared contact gus kanji; shared email domain icloud.com; shared email guskanji@icloud.com; shared phone +64274500727", + ), + CompanyMergeDecision( + canonical_name="Health Pak Limited", + names=("Aaron Wilson", "Health Pak Limited"), + expected_rows=2, + evidence="production jobs Aaron Wilson=1, Health Pak Limited=1; shared phone +64210640540", + ), + CompanyMergeDecision( + canonical_name="Hercules Crane and Rigging", + names=("Hercules Crane and Rigging", "Hercules Cranes and Rigging"), + expected_rows=2, + evidence="production jobs Hercules Crane and Rigging=2, Hercules Cranes and Rigging=0; shared phone +64272398572", + ), + CompanyMergeDecision( + canonical_name="High Mark Foods", + names=("CASH SALE - MICHAEL CHEE", "High Mark Foods"), + expected_rows=2, + evidence="production jobs CASH SALE - MICHAEL CHEE=2, High Mark Foods=3; shared contact michael chee; shared phone +6421673290", + ), + CompanyMergeDecision( + canonical_name="HTS Group Ltd", + names=("Ed Macdonald", "HTS Group Ltd", "HTS Group LTD - Ed Macdonald"), + expected_rows=3, + evidence="production jobs Ed Macdonald=0, HTS Group Ltd=0, HTS Group LTD - Ed Macdonald=3; shared contact ed macdonald; shared email domain htsgroup.co.nz; shared email emacdonald@htsgroup.co.nz; shared phone +64272412956", + ), + CompanyMergeDecision( + canonical_name="HV Power Measurements and Protection Ltd", + names=("HV Power Measurements and Protection Ltd", "Wim Van Den Berg"), + expected_rows=2, + evidence="production jobs HV Power Measurements and Protection Ltd=15, Wim Van Den Berg=0; shared email domain hvpowerautomation.com", + ), + CompanyMergeDecision( + canonical_name="Hynds Pipe Systems Limited", + names=("CASH - SALE - HYNDS - Kayleen Currie", "Hynds Pipe Systems Limited"), + expected_rows=2, + evidence="production jobs CASH - SALE - HYNDS - Kayleen Currie=2, Hynds Pipe Systems Limited=18; shared email domain hynds.co.nz", + ), + CompanyMergeDecision( + canonical_name="Insurance Resources - PSC Connect NZ Ltd", + names=("Insurance Resources - PSC Connect NZ Ltd", "rick"), + expected_rows=2, + evidence="production jobs Insurance Resources - PSC Connect NZ Ltd=0, rick=0; shared email domain insuranceresources.co.nz", + ), + CompanyMergeDecision( + canonical_name="INTERIOR DB LIMITED", + names=("CASH SALE - CHRISTOPHER WATT", "INTERIOR DB LIMITED"), + expected_rows=2, + evidence="production jobs CASH SALE - CHRISTOPHER WATT=0, INTERIOR DB LIMITED=3; shared email christopher@interiordb.co.nz; shared email domain interiordb.co.nz; shared phone +64275382037", + ), + CompanyMergeDecision( + canonical_name="IPL Maintenance", + names=("CASH SALE - IPL maintenance", "IPL Maintenance"), + expected_rows=2, + evidence="production jobs CASH SALE - IPL maintenance=0, IPL Maintenance=7; shared contact dave allan; shared email domain yahoo.com; shared email iplmaintenance@yahoo.com; shared phone +64274884866", + ), + CompanyMergeDecision( + canonical_name="Irving", + names=("CASH SALE - IRVING PATCHETT", "CASH SALE -Irving", "Irving"), + expected_rows=3, + evidence="production: 'CASH SALE - IRVING' since renamed to 'CASH SALE - IRVING PATCHETT'; shared email herbpatch20@gmail.com; shared phone +6421378335", + ), + CompanyMergeDecision( + canonical_name="Jack Lum and Co Limited", + names=( + "CASH SALE - Jack Lum and Co.", + "CASH SALE - Mike Lum", + "Jack Lum and Co Limited", + ), + expected_rows=3, + evidence="production jobs CASH SALE - Jack Lum and Co.=1, CASH SALE - Mike Lum=0, Jack Lum and Co Limited=0; shared contact mike lum; shared email domain email.com; shared email mikelum@email.com; shared phone +6421301188", + ), + CompanyMergeDecision( + canonical_name="Jenny Stevens", + names=("CASH SALE - Jenny stevens", "Jenny Stevens"), + expected_rows=2, + evidence="production jobs CASH SALE - Jenny stevens=1, Jenny Stevens=1; shared contact jenny stevens; shared email jennifersteven@gmail.com; shared phone +64210583499", + ), + CompanyMergeDecision( + canonical_name="Jerry Friar", + names=("CASH SALE - Jerry Friar", "Jerry Friar"), + expected_rows=2, + evidence="production jobs CASH SALE - Jerry Friar=2, Jerry Friar=1; shared contact jerry friar; shared phone +6421563894", + ), + CompanyMergeDecision( + canonical_name="JJ Wafer Biscuits Limited", + names=("arshay@wafers.co.nz", "JJ Wafer Biscuits Limited"), + expected_rows=2, + evidence="production jobs arshay@wafers.co.nz=0, JJ Wafer Biscuits Limited=0; shared email domain wafers.co.nz", + ), + CompanyMergeDecision( + canonical_name="Jocelyn Croad", + names=("CASH - SALE - Jocelyn Croad", "Jocelyn Croad"), + expected_rows=2, + evidence="production jobs CASH - SALE - Jocelyn Croad=1, Jocelyn Croad=0", + ), + CompanyMergeDecision( + canonical_name="John", + names=("CASH SALE - JOHN", "John"), + expected_rows=2, + evidence="production jobs CASH SALE - JOHN=0, John=0", + ), + CompanyMergeDecision( + canonical_name="Joseph Haydock", + names=("Joesph Haydock", "Joseph Haydock"), + expected_rows=2, + evidence="production jobs Joesph Haydock=1, Joseph Haydock=1; shared phone +642040142511", + ), + CompanyMergeDecision( + canonical_name="K & V Properties Ltd", + names=("K & V Properties Ltd", "Vladimir"), + expected_rows=2, + evidence="production jobs K & V Properties Ltd=0, Vladimir=0; shared email brico191441@gmail.com", + ), + CompanyMergeDecision( + canonical_name="Keith Adams Plumbing", + names=("Cougar Trust", "Keith Adams Plumbing"), + expected_rows=2, + evidence="production jobs Cougar Trust=0, Keith Adams Plumbing=0; shared email plumber.keith@gmail.com", + ), + CompanyMergeDecision( + canonical_name="KTS Roofing and Spouting", + names=("CASH SALE - KIWI TRADE SERVICES", "KTS Roofing and Spouting"), + expected_rows=2, + evidence="production jobs CASH SALE - KIWI TRADE SERVICES=1, KTS Roofing and Spouting=0; shared email domain kts.kiwi", + ), + CompanyMergeDecision( + canonical_name="Lakeland Consulting", + names=("Corrin Lakeland", "Lakeland Consulting"), + expected_rows=2, + evidence="production jobs Corrin Lakeland=2, Lakeland Consulting=0; shared email lakeland@gmail.com", + ), + CompanyMergeDecision( + canonical_name="Laurie Miller", + names=("CASH SALE - Laurie Miller", "Laurie Miller"), + expected_rows=2, + evidence="production jobs CASH SALE - Laurie Miller=1, Laurie Miller=0", + ), + CompanyMergeDecision( + canonical_name="Leap Innovation NZ", + names=("Ben King", "Leap Innovation NZ"), + expected_rows=2, + evidence="production jobs Ben King=1, Leap Innovation NZ=1; shared email ben@leapinnovation.nz; shared email domain leapinnovation.nz; shared phone +64275259552", + ), + CompanyMergeDecision( + canonical_name="Lindsay Building Services", + names=( + "CASH SALE - Lindsay Building Services", + "Lindsay Building Services", + "Mike Lindsay", + "tony m", + ), + expected_rows=4, + evidence="production jobs CASH SALE - Lindsay Building Services=2, Lindsay Building Services=1, Mike Lindsay=0, tony m=1; shared email domain lindsaybuildingservices.co.nz; shared email mike@lindsaybuildingservices.co.nz; shared phone +64212216699", + ), + CompanyMergeDecision( + canonical_name="LT McGuinness Auckland Limited", + names=( + "CASH SALE - LT MCGUINESS", + "CASH SALE - LT MCGUINNESS", + "LT McGuinness Auckland Limited", + ), + expected_rows=3, + evidence="production jobs CASH SALE - LT MCGUINESS=0, CASH SALE - LT MCGUINNESS=0, LT McGuinness Auckland Limited=5; shared email byronc@mcguinness.co.nz; shared email domain mcguinness.co.nz; shared phone +6421527295", + ), + CompanyMergeDecision( + canonical_name="MAG Assembly", + names=("CASH SALE - Philip Officer", "MAG Assembly"), + expected_rows=2, + evidence="production jobs CASH SALE - Philip Officer=1, MAG Assembly=11; shared email domain magassembly.co.nz", + ), + CompanyMergeDecision( + canonical_name="Maraia", + names=("CASH SALE - Maraia", "Maraia"), + expected_rows=2, + evidence="production jobs CASH SALE - Maraia=0, Maraia=0; shared phone +64210341340; shared phone +64341340", + ), + CompanyMergeDecision( + canonical_name="MATERIAL HANDLING SOLUTIONS", + names=("Handro", "MATERIAL HANDLING SOLUTIONS"), + expected_rows=2, + evidence="production jobs Handro=1, MATERIAL HANDLING SOLUTIONS=1; shared phone +64212892852", + ), + CompanyMergeDecision( + canonical_name="MCALPINE HUSSMANN", + names=("CASH SALE - MCALPINE HUSSMANN", "MCALPINE HUSSMANN"), + expected_rows=2, + evidence="production: 'MC ALPINE HUSSMANN' since renamed to 'MCALPINE HUSSMANN'; shared email domain hussmann.com; shared email tim.moore@hussmann.com; shared phone +64272240844", + ), + CompanyMergeDecision( + canonical_name="McConnell Dowell Constructors Limited", + names=("CASH SALE - MIKE BONNETTE", "McConnell Dowell Constructors Limited"), + expected_rows=2, + evidence="production jobs CASH SALE - MIKE BONNETTE=1, McConnell Dowell Constructors Limited=0; shared email domain mcdgroup.com", + ), + CompanyMergeDecision( + canonical_name="McDonald Vague Limited", + names=("CASH SALE - McDonald Vague", "McDonald Vague Limited"), + expected_rows=2, + evidence="production jobs CASH SALE - McDonald Vague=1, McDonald Vague Limited=0", + ), + CompanyMergeDecision( + canonical_name="Mehmet Doker", + names=("CASH SALE - MEHMET DOKER", "Mehmet Doker"), + expected_rows=2, + evidence="production jobs CASH SALE - MEHMET DOKER=2, Mehmet Doker=0; shared email mehmetdoker@gmail.com", + ), + CompanyMergeDecision( + canonical_name="Michael Mackinven", + names=("CASH SALE - Michael Mackinven", "Michael Mackinven"), + expected_rows=2, + evidence="production jobs CASH SALE - Michael Mackinven=4, Michael Mackinven=0; shared email mackinven@hotmail.com", + ), + CompanyMergeDecision( + canonical_name="Mike (Michael) Loomb", + names=("CASH SALE - Mike Loomb", "Mike (Michael) Loomb"), + expected_rows=2, + evidence="production jobs CASH SALE - Mike Loomb=2, Mike (Michael) Loomb=1; shared email michaelloomb@hotmail.com; shared phone +64211951166", + ), + CompanyMergeDecision( + canonical_name="Miles Motor Group", + names=("Miles Motor Group", "Paul Curin"), + expected_rows=2, + evidence="production jobs Miles Motor Group=0, Paul Curin=0; shared email domain miles.co.nz; shared email pcurin@miles.co.nz", + ), + CompanyMergeDecision( + canonical_name="Motul New Zealand", + names=("CASH SALE - CHRIS", "Chris", "Motul New Zealand"), + expected_rows=3, + evidence="production jobs CASH SALE - CHRIS=2, Chris=0, Motul New Zealand=0; shared email domain motul.co.nz", + ), + CompanyMergeDecision( + canonical_name="NEP Broadcast Services New Zealand Limited", + names=("CASH SALE - Nick Haines", "NEP Broadcast Services New Zealand Limited"), + expected_rows=2, + evidence="production jobs CASH SALE - Nick Haines=0, NEP Broadcast Services New Zealand Limited=4; shared contact nick haines", + ), + CompanyMergeDecision( + canonical_name="NEW ZEALAND STARCH", + names=("NEW ZEALAND STARCH", "NZ STARCH"), + expected_rows=2, + evidence="production jobs NEW ZEALAND STARCH=1, NZ STARCH=1; shared contact brett johnson; shared email brett.johnson@nzstarch.co.nz; shared email domain nzstarch.co.nz; shared phone +64273005151", + ), + CompanyMergeDecision( + canonical_name="Nick Hansen", + names=("CASH SALE Nick Hansen", "Nick Hansen"), + expected_rows=2, + evidence="production jobs CASH SALE Nick Hansen=3, Nick Hansen=1; shared contact nick hansen; shared email domain hotmail.co.nz; shared email nickhprogresse@hotmail.co.nz; shared phone +64272968753", + ), + CompanyMergeDecision( + canonical_name="Nordson Australia Pty Limited", + names=("Nordson Australia Pty Limited", "Rodney Lambert"), + expected_rows=2, + evidence="production jobs Nordson Australia Pty Limited=3, Rodney Lambert=0; shared email domain nordson.com; shared email rodney.lambert@nordson.com", + ), + CompanyMergeDecision( + canonical_name="Northland Roofs NZ", + names=("CASH SALE - Northland Roofs NZ", "Northland Roofs NZ"), + expected_rows=2, + evidence="production jobs CASH SALE - Northland Roofs NZ=17, Northland Roofs NZ=11; shared contact ace punia; shared email ace.p@northlandroofs.com; shared email domain northlandroofs.com; shared email parisa.m@northlandroofs.com", + ), + CompanyMergeDecision( + canonical_name="NZ Crane Hire Limited", + names=("Danny - NZ Crane", "NZ Crane Hire Limited", "Scott Sandford"), + expected_rows=3, + evidence="production jobs Danny - NZ Crane=1, NZ Crane Hire Limited=14, Scott Sandford=0; shared contact danny newberry; shared email domain cranehire.co.nz; shared phone +6421747065", + ), + CompanyMergeDecision( + canonical_name="Oji Fibre Solutions - Penrose Mill", + names=("Mark Bendikson", "Oji Fibre Solutions - Penrose Mill"), + expected_rows=2, + evidence="production jobs Mark Bendikson=0, Oji Fibre Solutions - Penrose Mill=0; shared email domain ojifs.com", + ), + CompanyMergeDecision( + canonical_name="Omaha Beach Golf Club", + names=("Geoff Smith 0274985382", "Omaha Beach Golf Club"), + expected_rows=2, + evidence="production jobs Geoff Smith 0274985382=1, Omaha Beach Golf Club=0; shared email geoffsmith08@gmail.com", + ), + CompanyMergeDecision( + canonical_name="Onehunga Auto Electric Ltd", + names=("CASH SALE-Rob Wedding", "Onehunga Auto Electric Ltd"), + expected_rows=2, + evidence="production jobs CASH SALE-Rob Wedding=2, Onehunga Auto Electric Ltd=0; shared email onehungaae@gmail.com", + ), + CompanyMergeDecision( + canonical_name="Onsite Team", + names=("Onsite Engineering Ltd", "Onsite Team"), + expected_rows=2, + evidence="production jobs Onsite Engineering Ltd=0, Onsite Team=21; shared contact gary mcnabb; shared email domain onsiteteam.co.nz; shared email gary@onsiteteam.co.nz; shared phone +6421848568", + ), + CompanyMergeDecision( + canonical_name="Owen Dwyer", + names=("CASH SALE - Owen Dwyer", "Owen Dwyer"), + expected_rows=2, + evidence="production jobs CASH SALE - Owen Dwyer=2, Owen Dwyer=0", + ), + CompanyMergeDecision( + canonical_name="pacific bakery Mangere", + names=( + "CASH SALE-pacific bakery Mangere", + "Mangere town bakery", + "pacific bakery Mangere", + ), + expected_rows=3, + evidence="production jobs CASH SALE-pacific bakery Mangere=2, Mangere town bakery=1, pacific bakery Mangere=3; shared contact andrew winston; shared email domain yahoo.com; shared email pacbakery@yahoo.com; shared phone +64220308090", + ), + CompanyMergeDecision( + canonical_name="Paul De Blois", + names=("CASH SALE - PAUL DE BLOIS", "Paul De Blois"), + expected_rows=2, + evidence="production jobs CASH SALE - PAUL DE BLOIS=0, Paul De Blois=1; shared email domain hotmail.co.nz; shared email rsmk1paul@hotmail.co.nz", + ), + CompanyMergeDecision( + canonical_name="PB Traffic", + names=("PB Traffic", "PB traffic solutions"), + expected_rows=2, + evidence="production jobs PB Traffic=51, PB traffic solutions=0; shared contact akshay; shared contact emilio rueda; shared phone +64211462453; shared phone +64272097842", + ), + CompanyMergeDecision( + canonical_name="pcs painting", + names=("CASH SALE - PCS PAINTING", "pcs painting"), + expected_rows=2, + evidence="production jobs CASH SALE - PCS PAINTING=2, pcs painting=1; shared email pcspainting@xtra.co.nz; shared phone +6421723969", + ), + CompanyMergeDecision( + canonical_name="Penrose Paper Engineering", + names=( + "CASH SALE - MATT GREEN", + "CASH SALE MATT GREEN", + "CASH SALE-Kidantics", + "Matt Green", + "Penrose Paper Engineering", + ), + expected_rows=5, + evidence="production jobs CASH SALE - MATT GREEN=1, CASH SALE MATT GREEN=0, CASH SALE-Kidantics=1, Matt Green=1, Penrose Paper Engineering=2; shared contact matt green; shared email domain kidantics.co.nz; shared email matt@kidantics.co.nz; shared email penrosepaper@gmail.com", + ), + CompanyMergeDecision( + canonical_name="Performance Cleaners", + names=("Performance Cleaners", "Peter Barron"), + expected_rows=2, + evidence="production jobs Performance Cleaners=0, Peter Barron=0; shared email pcebarron@gmail.com", + ), + CompanyMergeDecision( + canonical_name="Pete Pederson", + names=("CASH SALE - Pete Pederson", "CASH SALE#Pete Pederson", "Pete Pederson"), + expected_rows=3, + evidence="production jobs CASH SALE - Pete Pederson=1, CASH SALE#Pete Pederson=1, Pete Pederson=0; shared phone +64274300224", + ), + CompanyMergeDecision( + canonical_name="Peter Champion", + names=("Peter Champion", "Peter Chapman"), + expected_rows=2, + evidence="production jobs Peter Champion=0, Peter Chapman=0; shared email ezycall@xtra.co.nz", + ), + CompanyMergeDecision( + canonical_name="Playhouse Theatre", + names=("Playhouse Theatre", "Tony Morrow"), + expected_rows=2, + evidence="production jobs Playhouse Theatre=0, Tony Morrow=0; shared email mdoherty.pjones@gmail.com", + ), + CompanyMergeDecision( + canonical_name="Point Maintenance", + names=("cash sale phillip", "Phillip Whitham", "Point Maintenance"), + expected_rows=3, + evidence="production jobs cash sale phillip=1, Phillip Whitham=1, Point Maintenance=4; shared contact philip; shared email phil.point@outlook.com; shared phone +64277033500", + ), + CompanyMergeDecision( + canonical_name="PPG Industrial Coatings NZ", + names=("BDM - Commercial Transport", "PPG Industrial Coatings NZ"), + expected_rows=2, + evidence="production jobs BDM - Commercial Transport=0, PPG Industrial Coatings NZ=10; shared email domain ppg.com", + ), + CompanyMergeDecision( + canonical_name="Proclimb", + names=("CASH SALE - SIMON ROSE", "Proclimb"), + expected_rows=2, + evidence="production jobs CASH SALE - SIMON ROSE=1, Proclimb=1; shared contact simon rose; shared email domain proclimb.co.nz; shared email simon.rose@proclimb.co.nz; shared phone +64276642307", + ), + CompanyMergeDecision( + canonical_name="QC Projects", + names=("John Williams", "John Williams -", "QC Projects"), + expected_rows=3, + evidence="production jobs John Williams=0, John Williams -=0, QC Projects=0; shared email domain qcprojects.co.nz; shared email john@qcprojects.co.nz", + ), + CompanyMergeDecision( + canonical_name="Rauland NZ Limited", + names=("CASH SALE - Rauland", "Rauland NZ Limited"), + expected_rows=2, + evidence="production jobs CASH SALE - Rauland=3, Rauland NZ Limited=1; shared contact alvin bonita; shared email alvinbonita@rauland.co.nz; shared email domain rauland.co.nz", + ), + CompanyMergeDecision( + canonical_name="Reece Taituha", + names=("CASH SALE - REECE TAITUHA", "Reece Taituha"), + expected_rows=2, + evidence="production jobs CASH SALE - REECE TAITUHA=1, Reece Taituha=0; shared email reecetaituha@live.com", + ), + CompanyMergeDecision( + canonical_name="REMUERA PROPERTY SERVICES", + names=( + "CASH SALE - REMUERA PROPERTY SERVICES", + "REMUERA PROPERTY SERVICES", + "Terry Brailford", + ), + expected_rows=3, + evidence="production jobs CASH SALE - REMUERA PROPERTY SERVICES=0, REMUERA PROPERTY SERVICES=2, Terry Brailford=1; shared email rpsltd@xtra.co.nz; shared phone +64274958816", + ), + CompanyMergeDecision( + canonical_name="Rexmark Developments", + names=("Rex Sellars", "Rexmark Developments"), + expected_rows=2, + evidence="production jobs Rex Sellars=0, Rexmark Developments=0; shared email domain sellar.nz; shared email rex@sellar.nz", + ), + CompanyMergeDecision( + canonical_name="Ripout NZ Ltd", + names=("CASH SALE - Mark Homan", "Ripout NZ Ltd"), + expected_rows=2, + evidence="production jobs CASH SALE - Mark Homan=0, Ripout NZ Ltd=5; shared contact mark homan; shared phone +6421744827", + ), + CompanyMergeDecision( + canonical_name="Robert Grant", + names=("Robbie Grant", "Robert Grant"), + expected_rows=2, + evidence="production jobs Robbie Grant=1, Robert Grant=1; shared phone +6421502802", + ), + CompanyMergeDecision( + canonical_name="Rocket Lab NZ Ltd", + names=("CASH SALE - KRISH RUPAN", "Rocket Lab NZ Ltd"), + expected_rows=2, + evidence="production jobs CASH SALE - KRISH RUPAN=1, Rocket Lab NZ Ltd=1; shared email domain rocketlab.co.nz", + ), + CompanyMergeDecision( + canonical_name="Ron - Landform", + names=("Ron - Landform", "Ron Grosse"), + expected_rows=2, + evidence="production jobs Ron - Landform=0, Ron Grosse=0; shared email landformnz@gmail.com", + ), + CompanyMergeDecision( + canonical_name="ROOTS R US Ltd - Balli", + names=("CASH SALE - BALLI (ROOTS R US LTD)", "ROOTS R US Ltd - Balli"), + expected_rows=2, + evidence="production jobs CASH SALE - BALLI (ROOTS R US LTD)=2, ROOTS R US Ltd - Balli=1; shared phone +64204336699", + ), + CompanyMergeDecision( + canonical_name="SD Aluminium LTD", + names=("SD Aluminium", "SD Aluminium LTD"), + expected_rows=2, + evidence="production jobs SD Aluminium=0, SD Aluminium LTD=0", + ), + CompanyMergeDecision( + canonical_name="Secair - Compressed Air Solutions", + names=("CASH - SALE - Earl Warne", "Secair - Compressed Air Solutions"), + expected_rows=2, + evidence="production jobs CASH - SALE - Earl Warne=1, Secair - Compressed Air Solutions=0; shared email domain secair.co.nz", + ), + CompanyMergeDecision( + canonical_name="Signbiz", + names=("CASH - SALE - SIGNBIZ", "Signbiz"), + expected_rows=2, + evidence="production jobs CASH - SALE - SIGNBIZ=1, Signbiz=0; shared email domain signbiz.co.nz", + ), + CompanyMergeDecision( + canonical_name="Silent Generator Company", + names=("Aaron Thorpe", "Silent Generator Company"), + expected_rows=2, + evidence="production jobs Aaron Thorpe=0, Silent Generator Company=1; shared email silentgeneratorcompany@gmail.com", + ), + CompanyMergeDecision( + canonical_name="Simon", + names=("CASH SALE - Simon", "Simon"), + expected_rows=2, + evidence="production jobs CASH SALE - Simon=0, Simon=1", + ), + CompanyMergeDecision( + canonical_name="SMG Ltd", + names=("SMG", "SMG Ltd"), + expected_rows=2, + evidence="production jobs SMG=0, SMG Ltd=0", + ), + CompanyMergeDecision( + canonical_name="Snowdon Consulting", + names=("Snowden", "Snowdon Consulting"), + expected_rows=2, + evidence="production jobs Snowden=1, Snowdon Consulting=12; shared contact charlie thomlinson; shared phone +642108658458", + ), + CompanyMergeDecision( + canonical_name="St David's Church", + names=("David Brown", "St David's Church"), + expected_rows=2, + evidence="production jobs David Brown=0, St David's Church=0; shared email david-empireroad@hotmail.com", + ), + CompanyMergeDecision( + canonical_name="Stainless Products Limited", + names=("Blair Tribe", "Stainless Products Limited"), + expected_rows=2, + evidence="production jobs Blair Tribe=0, Stainless Products Limited=0; shared email domain stainlessproducts.co.nz", + ), + CompanyMergeDecision( + canonical_name="Steam Brewing Limited", + names=("CASH SALE - Steam Brewing Steve Kermode", "Steam Brewing Limited"), + expected_rows=2, + evidence="production jobs CASH SALE - Steam Brewing Steve Kermode=1, Steam Brewing Limited=0; shared email domain steambrewing.co.nz", + ), + CompanyMergeDecision( + canonical_name="Stebbing Recording Centre Ltd", + names=("Robert Stebbing", "Stebbing Recording Centre Ltd"), + expected_rows=2, + evidence="production jobs Robert Stebbing=0, Stebbing Recording Centre Ltd=0; shared email domain stebbing.co.nz; shared email robert@stebbing.co.nz", + ), + CompanyMergeDecision( + canonical_name="Steel Windows and Doors", + names=("Steel Profiles", "Steel Windows and Doors"), + expected_rows=2, + evidence="production jobs Steel Profiles=0, Steel Windows and Doors=0; shared email domain steelwindowsanddoors.co.nz", + ), + CompanyMergeDecision( + canonical_name="Stephen (Steve) Carr", + names=( + "CASH SALE - STEPHEN CARR", + "CASH SALE - Steve Carr", + "Stephen (Steve) Carr", + ), + expected_rows=3, + evidence="production jobs CASH SALE - STEPHEN CARR=1, CASH SALE - Steve Carr=2, Stephen (Steve) Carr=2; shared contact stephen carr; shared email daimlersteve@gmail.com; shared phone +64275120443", + ), + CompanyMergeDecision( + canonical_name="Steve Kirby", + names=("CASH SALE - Steve Kirby", "Steve Kirby"), + expected_rows=2, + evidence="production jobs CASH SALE - Steve Kirby=0, Steve Kirby=2; shared email kirby.nz@gmail.com; shared phone +6421354532", + ), + CompanyMergeDecision( + canonical_name="Straightline Builders", + names=("Bryson Rangi", "Straightline Builders"), + expected_rows=2, + evidence="production jobs Bryson Rangi=0, Straightline Builders=0; shared email bryson@slb.co.nz; shared email domain slb.co.nz", + ), + CompanyMergeDecision( + canonical_name="Testing client name 3", + names=("Testing client name 3", "Testing Corrin 3"), + expected_rows=2, + evidence="production jobs Testing client name 3=0, Testing Corrin 3=0; shared email lakeland+testing3@gmail.com", + ), + CompanyMergeDecision( + canonical_name="Textile Products 1971 Limited", + names=( + "CASH SALE - Matt O'Connor", + "Kevin Sanderson", + "Textile Products 1971 Limited", + ), + expected_rows=3, + evidence="production jobs CASH SALE - Matt O'Connor=1, Kevin Sanderson=0, Textile Products 1971 Limited=6; shared email domain textile.co.nz", + ), + CompanyMergeDecision( + canonical_name="The Learning Wave", + names=("The Learning Wave", "Yellowjelly Limited"), + expected_rows=2, + evidence="production jobs The Learning Wave=0, Yellowjelly Limited=0; shared email domain thelearningwave.com; shared email richardm@thelearningwave.com", + ), + CompanyMergeDecision( + canonical_name="Tim Cooper", + names=("CASH SALE - TIM COOPER", "Tim Cooper"), + expected_rows=2, + evidence="production jobs CASH SALE - TIM COOPER=1, Tim Cooper=0", + ), + CompanyMergeDecision( + canonical_name="Timeworx", + names=( + "CASH SALE - PAUL O'BRIEN", + "Da Vinci Trust", + "Paul O'Brien", + "Timeworx", + ), + expected_rows=4, + evidence="production jobs CASH SALE - PAUL O'BRIEN=3, Da Vinci Trust=0, Paul O'Brien=0, Timeworx=2; shared contact paul o brien; shared email domain timeworx.co.nz; shared email paul@timeworx.co.nz; shared email paulobrien575@gmail.com", + ), + CompanyMergeDecision( + canonical_name="TNJ and S A Morris", + names=("GLEN MARINE TRUST", "TNJ and S A Morris"), + expected_rows=2, + evidence="production jobs GLEN MARINE TRUST=0, TNJ and S A Morris=0; shared email tomshirl10@gmail.com", + ), + CompanyMergeDecision( + canonical_name="tom c", + names=("thomas", "tom .c", "tom c"), + expected_rows=3, + evidence="production jobs thomas=1, tom .c=0, tom c=0; shared phone +64220446045", + ), + CompanyMergeDecision( + canonical_name="Total Home Renovation", + names=("Ross Collins", "Total Home Renovation"), + expected_rows=2, + evidence="production jobs Ross Collins=0, Total Home Renovation=0; shared email domain thr.nz; shared email ross@thr.nz", + ), + CompanyMergeDecision( + canonical_name="TR Group Ltd", + names=("Sam Davies", "TR Group Ltd"), + expected_rows=2, + evidence="production jobs Sam Davies=0, TR Group Ltd=0; shared email domain trgroup.co.nz", + ), + CompanyMergeDecision( + canonical_name="Twist Prop 1 Limited", + names=("Mark Boucher 021922804", "Twist Prop 1 Limited"), + expected_rows=2, + evidence="production jobs Mark Boucher 021922804=0, Twist Prop 1 Limited=0; shared email boucher@actrix.co.nz; shared email domain actrix.co.nz", + ), + CompanyMergeDecision( + canonical_name="Ultralon Foam International", + names=("Peter Manhire", "Ultralon Foam International"), + expected_rows=2, + evidence="production jobs Peter Manhire=1, Ultralon Foam International=1; shared contact peter manhire; shared phone +6421642273", + ), + CompanyMergeDecision( + canonical_name="Vat Cleaning Services", + names=("Kevin Gardiner", "Vat Cleaning Services"), + expected_rows=2, + evidence="production jobs Kevin Gardiner=0, Vat Cleaning Services=0; shared email kevin.vatclean@xtra.co.nz", + ), + CompanyMergeDecision( + canonical_name="Vern Patel", + names=("CASH SALE - Vern Patel", "Vern Patel"), + expected_rows=2, + evidence="production jobs CASH SALE - Vern Patel=1, Vern Patel=0; shared email vern.patel@hotmail.com", + ), + CompanyMergeDecision( + canonical_name="Vulcan Steel Limited", + names=( + "Vulcan Steel Limited", + "Vulcan Ullrich Aluminium - do not use 2763789 onehunga", + ), + expected_rows=2, + evidence="production jobs Vulcan Steel Limited=0, Vulcan Ullrich Aluminium - do not use 2763789 onehunga=0; shared email accounts.nzl@vulcan.co; shared email domain vulcan.co", + ), + CompanyMergeDecision( + canonical_name="Wakefield Metals", + names=("CASH SALE - WAKEFIELDS METALS", "Wakefield Metals"), + expected_rows=2, + evidence="production jobs CASH SALE - WAKEFIELDS METALS=0, Wakefield Metals=2; shared email domain wmetals.co.nz; shared email matthew.macdonald@wmetals.co.nz; shared phone +64212426732", + ), + CompanyMergeDecision( + canonical_name="Wallace Investments Ltd", + names=("Cedric/Wallace Investments", "Wallace Investments Ltd"), + expected_rows=2, + evidence="production jobs Cedric/Wallace Investments=0, Wallace Investments Ltd=0; shared email domain wil.stevedores.co.nz", + ), + CompanyMergeDecision( + canonical_name="Walter and Wild Limited", + names=("Nick Pretscherer", "Nicklaus Pretscherer", "Walter and Wild Limited"), + expected_rows=3, + evidence="production jobs Nick Pretscherer=0, Nicklaus Pretscherer=0, Walter and Wild Limited=3; shared email domain walterandwild.com; shared email nicklausp@walterandwild.com", + ), + CompanyMergeDecision( + canonical_name="Watercone ltd (Neville)", + names=("The water cone", "Watercone ltd (Neville)"), + expected_rows=2, + evidence="production jobs The water cone=1, Watercone ltd (Neville)=2; shared contact neville; shared phone +6421922819", + ), + CompanyMergeDecision( + canonical_name="Wolfgang Schenk", + names=("CASH SALE - WOLFGANG", "Wolfgang Schenk"), + expected_rows=2, + evidence="production jobs CASH SALE - WOLFGANG=2, Wolfgang Schenk=1; shared contact wolfgang; shared phone +64211253754", + ), + CompanyMergeDecision( + canonical_name="Wright Panel and Paint", + names=("CASH SALE - WRIGHT PANEL AND PAINT", "Wright Panel and Paint"), + expected_rows=2, + evidence="production jobs CASH SALE - WRIGHT PANEL AND PAINT=1, Wright Panel and Paint=0; shared email wcpp@xtra.co.nz", + ), + CompanyMergeDecision( + canonical_name="Xero (NZ) Limited", + names=("Xero (NZ) Limited", "Xero NZ"), + expected_rows=2, + evidence="production jobs Xero (NZ) Limited=0, Xero NZ=0", + ), + CompanyMergeDecision( + canonical_name="Yoshiki Fujimoto", + names=("CASH SALE - YOSHIKI FUJIMOTO", "Yoshiki Fujimoto"), + expected_rows=2, + evidence="production jobs CASH SALE - YOSHIKI FUJIMOTO=0, Yoshiki Fujimoto=2", + ), + # KAN-278 drift refresh (human-reviewed 2026-07-15): duplicates that appeared + # in production after the original dataset was frozen. + CompanyMergeDecision( + canonical_name="Pinnacle Build", + names=("CASH SALE - PINNACLE BUILDS LTD - TOBY FOUNTAIN", "Pinnacle Build"), + expected_rows=2, + evidence="shared email toby@pinnaclebuildltd.co.nz (contact Toby Fountain); cash-sale alias of 'Pinnacle Build'", + ), + CompanyMergeDecision( + canonical_name="Limbrother Ltd", + names=("CASH SALE - YAN - 021809004", "Limbrother Ltd"), + expected_rows=2, + evidence="cash-sale alias for Yan Lim (yan@limbro.com, phone 021809004) of 'Limbrother Ltd'; analyzer flagged review, human-confirmed merge", + ), +) + +REVIEWED_PERSON_MERGES: tuple[PersonMergeDecision, ...] = ( + PersonMergeDecision( + canonical=PersonSelector("Aaron Williams", "aaron.w@dge.nz", "DGE Ltd"), + members=( + PersonSelector("Aaron Williams", None, "Auckland Airport Limited"), + PersonSelector("Aaron Williams", "aaron.w@dge.nz", "DGE Ltd"), + PersonSelector("Aarron Williams", None, "DGE Ltd"), + ), + expected_people=3, + evidence="production jobs Aaron Williams=1, Aaron Williams=24, Aarron Williams=1; shared phone +64274783139", + ), + PersonMergeDecision( + canonical=PersonSelector( + "ACE PUNIA", "ace.p@northlandroofs.com", "CASH SALE - Northland Roofs NZ" + ), + members=( + PersonSelector( + "ACE PUNIA", "ace.p@northlandroofs.com", "Northland Roofs NZ" + ), + PersonSelector( + "ACE PUNIA", + "ace.p@northlandroofs.com", + "CASH SALE - Northland Roofs NZ", + ), + ), + expected_people=2, + evidence="production jobs ACE PUNIA=1, ACE PUNIA=7; shared email ace.p@northlandroofs.com; shared phone +642108225902", + ), + PersonMergeDecision( + canonical=PersonSelector("Akshay", None, "PB Traffic"), + members=( + PersonSelector("Akshay", None, "PB Traffic"), + PersonSelector("Akshay", None, "PB traffic solutions"), + PersonSelector("AKSHAY GUPTE", "akshay@pbtraffic.co.nz", "PB Traffic"), + ), + expected_people=3, + evidence="production jobs Akshay=22, Akshay=1, AKSHAY GUPTE=17; shared phone +64211462453", + ), + PersonMergeDecision( + canonical=PersonSelector("Alan Kelway", None, "Electrical Importing Company"), + members=( + PersonSelector("Alan Kelway", None, "Electrical Importing Company"), + PersonSelector("Alan Kelway", "alan@eicnz.com", "CASH SALE - Alan"), + ), + expected_people=2, + evidence="production jobs Alan Kelway=4, Alan Kelway=0", + ), + PersonMergeDecision( + canonical=PersonSelector( + "Alvin Bonita", "AlvinBonita@rauland.co.nz", "CASH SALE - Rauland" + ), + members=( + PersonSelector( + "Alvin Bonita", "AlvinBonita@rauland.co.nz", "CASH SALE - Rauland" + ), + PersonSelector( + "Alvin Bonita", "AlvinBonita@rauland.co.nz", "Rauland NZ Limited" + ), + ), + expected_people=2, + evidence="production jobs Alvin Bonita=3, Alvin Bonita=0; shared email alvinbonita@rauland.co.nz", + ), + PersonMergeDecision( + canonical=PersonSelector( + "Andrew Winston", "pacbakery@yahoo.com", "pacific bakery Mangere" + ), + members=( + PersonSelector("Andrew", None, "Mangere town bakery"), + PersonSelector( + "Andrew Winston", "pacbakery@yahoo.com", "pacific bakery Mangere" + ), + PersonSelector( + "Andrew Winston", + "pacbakery@yahoo.com", + "CASH SALE-pacific bakery Mangere", + ), + ), + expected_people=3, + evidence="production jobs Andrew=1, Andrew Winston=3, Andrew Winston=1; shared email pacbakery@yahoo.com; shared phone +64220308090", + ), + PersonMergeDecision( + canonical=PersonSelector( + "Angela", "Angela.Malden@hynds.co.nz", "Hynds Pipe Systems Limited" + ), + members=( + PersonSelector( + "Angela", "Angela.Malden@hynds.co.nz", "Hynds Pipe Systems Limited" + ), + PersonSelector( + "Angela Madden", + "Angela.Malden@hynds.co.nz", + "Hynds Pipe Systems Limited", + ), + ), + expected_people=2, + evidence="production jobs Angela=1, Angela Madden=0; shared email angela.malden@hynds.co.nz", + ), + PersonMergeDecision( + canonical=PersonSelector( + "Arno Eramus", None, "HV Power Measurements and Protection Ltd" + ), + members=( + PersonSelector( + "Arno Eramus", None, "HV Power Measurements and Protection Ltd" + ), + PersonSelector( + "Arno Erasmus", + "arnoe@hvpowerautomation.com", + "HV Power Measurements and Protection Ltd", + ), + ), + expected_people=2, + evidence="production jobs Arno Eramus=2, Arno Erasmus=1; shared phone +64212429114", + ), + PersonMergeDecision( + canonical=PersonSelector( + "Arron Brown", "Arron@solutionzelectrical.co.nz", "CASH SALE - Arron Brown" + ), + members=( + PersonSelector( + "Aaron Brown", + "aaron@solutionzelectrical.co.nz", + "CASH SALE - Arron Brown", + ), + PersonSelector( + "Arron Brown", + "Arron@solutionzelectrical.co.nz", + "CASH SALE - Arron Brown", + ), + ), + expected_people=2, + evidence="production jobs Aaron Brown=0, Arron Brown=1; shared phone +6421572121", + ), + PersonMergeDecision( + canonical=PersonSelector( + "AZAM SHAH", "azampanelbeater@hotmail.copm", "CASH SALE - AZ PANELS REPAIRS" + ), + members=( + PersonSelector("AZAM", "azampanelbeater@hotmail.com", "Azam 021677855"), + PersonSelector( + "AZAM SHAH", + "azampanelbeater@hotmail.copm", + "CASH SALE - AZ PANELS REPAIRS", + ), + ), + expected_people=2, + evidence="production jobs AZAM=1, AZAM SHAH=1; shared phone +6421677855", + ), + PersonMergeDecision( + canonical=PersonSelector("Ben King", None, "Snowdon Consulting"), + members=( + PersonSelector("Ben King", None, "Snowdon Consulting"), + PersonSelector("Ben King", "ben@leapinnovation.nz", "Ben King"), + ), + expected_people=2, + evidence="production jobs Ben King=1, Ben King=1; shared phone +64275259552", + ), + PersonMergeDecision( + canonical=PersonSelector("Blayne Newton", None, "acryfab"), + members=( + PersonSelector("Blayne Newton", None, "acryfab"), + PersonSelector( + "BLAYNE NEWTON", "BLAYNE@ACRYFAB.CO.NZ", "CASH SALE - BLAYNE NEWTON" + ), + ), + expected_people=2, + evidence="production jobs Blayne Newton=1, BLAYNE NEWTON=1", + ), + PersonMergeDecision( + canonical=PersonSelector( + "Bob Lopesi", "spares@elsnz.co.nz", "CASH SALE - ELS New Zealand" + ), + members=( + PersonSelector( + "Bob Lopesi", "Bob.Lopesi@elsnz.co.nz", "ELS New Zealand Limited" + ), + PersonSelector( + "Bob Lopesi", "Bob@elsnz.co.nz", "CASH SALE - Econic Laundry Solutions" + ), + PersonSelector( + "Bob Lopesi", "spares@elsnz.co.nz", "CASH SALE - ELS New Zealand" + ), + ), + expected_people=3, + evidence="production jobs Bob Lopesi=0, Bob Lopesi=1, Bob Lopesi=1", + ), + PersonMergeDecision( + canonical=PersonSelector("Bobby", None, "Blackstone Project Management"), + members=( + PersonSelector("Bobby", None, "Blackstone Project Management"), + PersonSelector("Bobby", None, "MSM (Shop)"), + PersonSelector("Bobby prajitno", None, "Blackstone Project Management"), + ), + expected_people=3, + evidence="production jobs Bobby=2, Bobby=1, Bobby prajitno=1; shared phone +642108817930", + ), + PersonMergeDecision( + canonical=PersonSelector( + "BRETT JOHNSON", "brett.johnson@nzstarch.co.nz", "NZ STARCH" + ), + members=( + PersonSelector( + "BRETT JOHNSON", "brett.johnson@nzstarch.co.nz", "NZ STARCH" + ), + PersonSelector( + "BRETT JOHNSON", "brett@nzstarch.co.nz", "NEW ZEALAND STARCH" + ), + ), + expected_people=2, + evidence="production jobs BRETT JOHNSON=1, BRETT JOHNSON=1; shared phone +64273005151", + ), + PersonMergeDecision( + canonical=PersonSelector( + "BRONNY JACKSON", + "bronwyn.jackson@xtra.co.nz", + "CASH SALE - BRONWYN JACKSON", + ), + members=( + PersonSelector("Bronnie", None, "Bronnie 02102255430"), + PersonSelector( + "BRONNY JACKSON", + "bronwyn.jackson@xtra.co.nz", + "CASH SALE - BRONWYN JACKSON", + ), + ), + expected_people=2, + evidence="production jobs Bronnie=1, BRONNY JACKSON=2; shared phone +642102255430", + ), + PersonMergeDecision( + canonical=PersonSelector( + "BYRON CRAWFORD", + "ByronC@mcguinness.co.nz", + "LT McGuinness Auckland Limited", + ), + members=( + PersonSelector( + "BYRON", "ByronC@mcguinness.co.nz", "CASH SALE - LT MCGUINESS" + ), + PersonSelector( + "BYRON CRAWFORD", + "ByronC@mcguinness.co.nz", + "LT McGuinness Auckland Limited", + ), + ), + expected_people=2, + evidence="production jobs BYRON=0, BYRON CRAWFORD=4; shared email byronc@mcguinness.co.nz; shared phone +6421527295", + ), + PersonMergeDecision( + canonical=PersonSelector("Charlie Thomlinson", None, "Snowdon Consulting"), + members=( + PersonSelector("Charlie", None, "Snowdon Consulting"), + PersonSelector("Charlie Thomlinson", None, "Snowdon Consulting"), + PersonSelector("Charlie Thomlinson", None, "Snowden"), + ), + expected_people=3, + evidence="production jobs Charlie=1, Charlie Thomlinson=6, Charlie Thomlinson=1; shared phone +642108658458", + ), + PersonMergeDecision( + canonical=PersonSelector( + "Chris Baybut", + "Chris.Baybut@cushwake.com", + "Cushman and Wakefield New Zealand Limited", + ), + members=( + PersonSelector("CHRIS", None, "Cushman and Wakefield New Zealand Limited"), + PersonSelector( + "Chris Baybut", + "Chris.Baybut@cushwake.com", + "Cushman and Wakefield New Zealand Limited", + ), + PersonSelector( + "Cushman wakefield Chris", + None, + "Cushman and Wakefield New Zealand Limited", + ), + ), + expected_people=3, + evidence="production jobs CHRIS=1, Chris Baybut=6, Cushman wakefield Chris=1; shared phone +64274558373", + ), + PersonMergeDecision( + canonical=PersonSelector( + "CHRISTOPHER WATT", "christopher@interiordb.co.nz", "INTERIOR DB LIMITED" + ), + members=( + PersonSelector( + "Chris Watt", + "christopher@interiordb.co.nz", + "CASH SALE - CHRISTOPHER WATT", + ), + PersonSelector( + "CHRISTOPHER WATT", + "christopher@interiordb.co.nz", + "INTERIOR DB LIMITED", + ), + ), + expected_people=2, + evidence="production jobs Chris Watt=0, CHRISTOPHER WATT=3; shared email christopher@interiordb.co.nz; shared phone +64275382037", + ), + PersonMergeDecision( + canonical=PersonSelector( + "CRAIG BOYD", "dexterdut@gmail.com", "CASH SALE - CRAIG BOYD" + ), + members=( + PersonSelector( + "CRAIG BOYD", "dexterdut@gmail.com", "CASH SALE - CRAIG BOYD" + ), + PersonSelector( + "CRAIR BOYD", "dexterdut@gmail.com", "CASH SALE - CRAIG BOYD" + ), + ), + expected_people=2, + evidence="production jobs CRAIG BOYD=0, CRAIR BOYD=1; shared email dexterdut@gmail.com", + ), + PersonMergeDecision( + canonical=PersonSelector("Dane Charman", None, "Direct Control"), + members=( + PersonSelector("DANE CHARMAN", None, "Direct Control"), + PersonSelector("Dane Charman", None, "Direct Control"), + ), + expected_people=2, + evidence="production jobs DANE CHARMAN=0, Dane Charman=2", + ), + PersonMergeDecision( + canonical=PersonSelector( + "Danny Newberry", "djnewberry1@gmail.com", "NZ Crane Hire Limited" + ), + members=( + PersonSelector( + "Danny Newberry", "djnewberry1@gmail.com", "NZ Crane Hire Limited" + ), + PersonSelector( + "DANNY NEWBERRY", "djnewberry1@gmail.com", "Danny - NZ Crane" + ), + ), + expected_people=2, + evidence="production jobs Danny Newberry=3, DANNY NEWBERRY=1; shared phone +6421747065", + ), + PersonMergeDecision( + canonical=PersonSelector("Dave", None, "CASH SALE - Dave Booth"), + members=( + PersonSelector("Dave", None, "CASH SALE - Dave Booth"), + PersonSelector( + "DAVID BOOTH", "CARPENTERDAVEBOOTH@GMAIL.COM", "CASH SALE - DAVID BOOTH" + ), + ), + expected_people=2, + evidence="production jobs Dave=1, DAVID BOOTH=1; shared phone +642102631588", + ), + PersonMergeDecision( + canonical=PersonSelector("Dave", None, "Guardsman Security Services Limited"), + members=( + PersonSelector("Dave", None, "Guardsman Security Services Limited"), + PersonSelector("DAVE YANALA", None, "Kiwi Alarms Ltd"), + PersonSelector("DEV YANDA", None, "Guardsman Security Services Limited"), + ), + expected_people=3, + evidence="production jobs Dave=9, DAVE YANALA=0, DEV YANDA=1; shared phone +64225084642", + ), + PersonMergeDecision( + canonical=PersonSelector( + "Dave Allan", "iplmaintenance@yahoo.com", "IPL Maintenance" + ), + members=( + PersonSelector( + "Dave Allan", "iplmaintenance@yahoo.com", "CASH SALE - IPL maintenance" + ), + PersonSelector("Dave Allan", "iplmaintenance@yahoo.com", "IPL Maintenance"), + ), + expected_people=2, + evidence="production jobs Dave Allan=1, Dave Allan=6; shared email iplmaintenance@yahoo.com; shared phone +64274884866", + ), + PersonMergeDecision( + canonical=PersonSelector( + "David White", "David.White@fisherpaykel.com", "Fisher and Paykel" + ), + members=( + PersonSelector( + "david white", "David.White@fisherpaykel.com", "Fisher and Paykel" + ), + PersonSelector( + "David White", "David.White@fisherpaykel.com", "Fisher and Paykel" + ), + ), + expected_people=2, + evidence="production jobs david white=0, David White=13; shared email david.white@fisherpaykel.com", + ), + PersonMergeDecision( + canonical=PersonSelector( + "Dion smit .", "dion.smit@dunninghams.co.nz", "D M Dunningham Limited" + ), + members=( + PersonSelector( + "DION SMIT", "dion.smit@dunninghams.co.nz", "Dion - Dunninghams" + ), + PersonSelector( + "Dion smit .", "dion.smit@dunninghams.co.nz", "D M Dunningham Limited" + ), + ), + expected_people=2, + evidence="production jobs DION SMIT=0, Dion smit .=2; shared email dion.smit@dunninghams.co.nz; shared phone +64275556091", + ), + PersonMergeDecision( + canonical=PersonSelector( + "ED MACDONALD", "EMacDonald@htsgroup.co.nz", "HTS Group LTD - Ed Macdonald" + ), + members=( + PersonSelector( + "ED MACDONALD", + "EMacDonald@htsgroup.co.nz", + "HTS Group LTD - Ed Macdonald", + ), + PersonSelector( + "Ed Macdonald", "emacdonald@htsgroup.co.nz", "HTS Group Ltd" + ), + ), + expected_people=2, + evidence="production jobs ED MACDONALD=1, Ed Macdonald=0; shared email emacdonald@htsgroup.co.nz; shared phone +64272412956", + ), + PersonMergeDecision( + canonical=PersonSelector( + "Edwin Rysenberry", "erysenbry@mac.com", "CASH SALE - Edwin Rysenberry" + ), + members=( + PersonSelector( + "Edin Rysenberry", "erysenbry@mac.com", "CASH SALE - Edwin Rysenberry" + ), + PersonSelector( + "Edwin Rysenberry", "erysenbry@mac.com", "CASH SALE - Edwin Rysenberry" + ), + ), + expected_people=2, + evidence="production jobs Edin Rysenberry=0, Edwin Rysenberry=1; shared email erysenbry@mac.com; shared phone +6421731454", + ), + PersonMergeDecision( + canonical=PersonSelector( + "ELIJAH HARIDAS", + "ELIJAHRAULHARIDAS@GMAIL.COM", + "CASH SALE - ELIJAH HARIDAS", + ), + members=( + PersonSelector( + "ELIJAH HARIDAS", + "ELIJAHRAULHARIDAS@GMAIL.COM", + "CASH SALE - ELIJAH HARIDAS", + ), + ), + expected_people=2, + evidence="production jobs ELIJAH HARIDAS=0, ELIJAH HARIDAS=1; shared email elijahraulharidas@gmail.com; shared phone +642904302129", + ), + PersonMergeDecision( + canonical=PersonSelector("EMILIO RUEDA", None, "PB Traffic"), + members=( + PersonSelector("EMILIO RUEDA", None, "PB Traffic"), + PersonSelector("EMILIO RUEDA", None, "PB traffic solutions"), + ), + expected_people=2, + evidence="production jobs EMILIO RUEDA=1, EMILIO RUEDA=0; shared phone +64272097842", + ), + PersonMergeDecision( + canonical=PersonSelector("Ernesto", None, "CASH SALE#edenpark panel and paint"), + members=( + PersonSelector("Ernesto", None, "CASH SALE#edenpark panel and paint"), + PersonSelector("Ernesto", None, "Eden park panel and paint"), + ), + expected_people=2, + evidence="production jobs Ernesto=1, Ernesto=0; shared phone +6421629559", + ), + PersonMergeDecision( + canonical=PersonSelector( + "Gareth Taripo", "gareth@gast.co.nz", "CASH SALE - Gaast Automotive" + ), + members=( + PersonSelector("Gareth", None, "CASH SALE - Gaast Automotive"), + PersonSelector( + "Gareth Taripo", "gareth@gast.co.nz", "CASH SALE - Gaast Automotive" + ), + ), + expected_people=2, + evidence="production jobs Gareth=0, Gareth Taripo=1; shared phone +64212616893", + ), + PersonMergeDecision( + canonical=PersonSelector("Gary Lawrence", None, "CASH SALE - GARY LAWRENCE"), + members=( + PersonSelector("Gary Lawrence", None, "CASH SALE - GARY LAWRENCE"), + PersonSelector("Gary Lawrence", None, "Garry Lawrence Roofing"), + ), + expected_people=2, + evidence="production jobs Gary Lawrence=1, Gary Lawrence=1; shared phone +6421969601", + ), + PersonMergeDecision( + canonical=PersonSelector("Gary McNabb", "gary@onsiteteam.co.nz", "Onsite Team"), + members=( + PersonSelector( + "Gary Mcnabb", "gary@onsiteteam.co.nz", "Onsite Engineering Ltd" + ), + PersonSelector("Gary McNabb", "gary@onsiteteam.co.nz", "Onsite Team"), + ), + expected_people=2, + evidence="production jobs Gary Mcnabb=0, Gary McNabb=18; shared email gary@onsiteteam.co.nz; shared phone +6421848568", + ), + PersonMergeDecision( + canonical=PersonSelector("Geoff", None, "Geoff Bates"), + members=( + PersonSelector("Geoff", None, "Geoff Bates"), + PersonSelector("Geoff Bates", None, "Geoff Bates"), + ), + expected_people=2, + evidence="production jobs Geoff=1, Geoff Bates=1; shared phone +64204762687", + ), + PersonMergeDecision( + canonical=PersonSelector( + "GINA GASCOIGNE", + "gina.gascoigne@aucklandcouncil.govt.nz", + "Auckland Council", + ), + members=( + PersonSelector( + "GINA GASCOIGNE", + "gina.gascoigne@aucklandcouncil.govt.nz", + "Auckland Council", + ), + PersonSelector( + "Gina Gascoigne", + "gina.gascoigne@aucklandcouncil.govt.nz", + "Auckland Council", + ), + ), + expected_people=2, + evidence="production jobs GINA GASCOIGNE=1, Gina Gascoigne=0; shared email gina.gascoigne@aucklandcouncil.govt.nz", + ), + PersonMergeDecision( + canonical=PersonSelector( + "Glen Barratt", "fury@xtra.co.nz", "CASH SALE - Glen Barratt" + ), + members=( + PersonSelector( + "Glen Barratt", "fury@xtra.co.nz", "CASH SALE - Glen Barratt" + ), + PersonSelector( + "GLENN BARRATT", + "fury@xtra.co.nz", + "CASH SALE - SUPERCITY COMMERCIAL INTERIORS LTD", + ), + ), + expected_people=2, + evidence="production jobs Glen Barratt=1, GLENN BARRATT=1; shared phone +64274888111", + ), + PersonMergeDecision( + canonical=PersonSelector( + "Graeme", "onehungacarclinic@xtra.co.nz", "Onehunga Car Services Limited" + ), + members=( + PersonSelector( + "Graeme", + "onehungacarclinic@xtra.co.nz", + "Onehunga Car Services Limited", + ), + PersonSelector( + "Graeme Mills", + "onehunga.carclinic@xtra.co.nz", + "Onehunga Car Services Limited", + ), + ), + expected_people=2, + evidence="production jobs Graeme=3, Graeme Mills=2; shared phone +6496342207", + ), + PersonMergeDecision( + canonical=PersonSelector( + "GUS KANJI", "guskanji@icloud.com", "CASH SALE - GUS KANJI" + ), + members=( + PersonSelector("GUS KANJI", "guskanji@icloud.com", "CASH SALE - GUS KANJI"), + PersonSelector("Gus Kanji", "guskanji@icloud.com", "Gus Kanji"), + ), + expected_people=2, + evidence="production jobs GUS KANJI=4, Gus Kanji=2; shared email guskanji@icloud.com; shared phone +64274500727", + ), + PersonMergeDecision( + canonical=PersonSelector( + "HANDRO BIEWENGA", None, "MATERIAL HANDLING SOLUTIONS" + ), + members=( + PersonSelector("Handro", None, "Handro"), + PersonSelector("HANDRO BIEWENGA", None, "MATERIAL HANDLING SOLUTIONS"), + ), + expected_people=2, + evidence="production jobs Handro=1, HANDRO BIEWENGA=1; shared phone +64212892852", + ), + PersonMergeDecision( + canonical=PersonSelector("HARSH", None, "Kiwi Alarms Ltd"), + members=( + PersonSelector("HARSH", None, "Kiwi Alarms Ltd"), + PersonSelector("HARSH", None, "Guardsman Security Services Limited"), + ), + expected_people=2, + evidence="production jobs HARSH=0, HARSH=0", + ), + PersonMergeDecision( + canonical=PersonSelector("Henry", None, "Orams Marine Ltd"), + members=( + PersonSelector("Henry", None, "Orams Marine Ltd"), + PersonSelector("Henry.", None, "Orams Marine Ltd"), + ), + expected_people=2, + evidence="production jobs Henry=3, Henry.=1", + ), + PersonMergeDecision( + canonical=PersonSelector( + "IAIN MATTHEW", + "ANNETTEMATTHEW1970@GMAIL.COM", + "CASH SALE - NEW GENERATION OPERATIONS", + ), + members=( + PersonSelector( + "IAIN MATTHEW", + "ANNETTEMATTHEW1970@GMAIL.COM", + "CASH SALE - NEW GENERATION OPERATIONS", + ), + PersonSelector( + "IAN MATTHEW", "annettematthew1970@gmail.com", "Ian Matthew" + ), + ), + expected_people=2, + evidence="production jobs IAIN MATTHEW=1, IAN MATTHEW=1; shared phone +64274754864", + ), + PersonMergeDecision( + canonical=PersonSelector( + "IRVING PATCHETT", "herbpatch20@gmail.com", "CASH SALE - IRVING PATCHETT" + ), + members=( + PersonSelector( + "IRVING PATCHETT", + "herbpatch20@gmail.com", + "CASH SALE - IRVING PATCHETT", + ), + PersonSelector("Irving", None, "CASH SALE -Irving"), + ), + expected_people=2, + evidence="production: 'IRVING'@'CASH SALE - IRVING' since renamed to 'IRVING PATCHETT'@'CASH SALE - IRVING PATCHETT' with email herbpatch20@gmail.com; shared phone +6421378335", + ), + PersonMergeDecision( + canonical=PersonSelector("Jason Hu", None, "Free Co Flooring"), + members=( + PersonSelector("Jason Hu", None, "Free Co Flooring"), + PersonSelector( + "jason Hu", "jason@freecoflooring.co.nz", "CASH SALE - Jason Hu" + ), + ), + expected_people=2, + evidence="production jobs Jason Hu=4, jason Hu=0; shared phone +642108588883", + ), + PersonMergeDecision( + canonical=PersonSelector("Jenny Stevens", None, "Jenny Stevens"), + members=( + PersonSelector("Jenny Stevens", None, "Jenny Stevens"), + PersonSelector( + "Jenny Stevens", "jennifersteven@gmail.com", "CASH SALE - Jenny stevens" + ), + ), + expected_people=2, + evidence="production jobs Jenny Stevens=1, Jenny Stevens=1; shared phone +64210583499", + ), + PersonMergeDecision( + canonical=PersonSelector("Jerry Friar", None, "Jerry Friar"), + members=( + PersonSelector("Jerry Friar", None, "Jerry Friar"), + PersonSelector( + "JERRY FRIAR", + "jerry.friar@jameshardie.co.nz", + "CASH SALE - Jerry Friar", + ), + ), + expected_people=2, + evidence="production jobs Jerry Friar=1, JERRY FRIAR=1; shared phone +6421563894", + ), + PersonMergeDecision( + canonical=PersonSelector( + "John Blacklock", "john@originbuild.co.nz", "Origin Build" + ), + members=( + PersonSelector("John Blacklock", "john@originbuild.co.nz", "Origin Build"), + PersonSelector("John Laycock", None, "Origin Build"), + ), + expected_people=2, + evidence="production jobs John Blacklock=2, John Laycock=1; shared phone +64212461388", + ), + PersonMergeDecision( + canonical=PersonSelector("Joseph Haydock", None, "Joseph Haydock"), + members=( + PersonSelector("Joesph Haydock", None, "Joesph Haydock"), + PersonSelector("Joseph Haydock", None, "Joseph Haydock"), + ), + expected_people=2, + evidence="production jobs Joesph Haydock=1, Joseph Haydock=1; shared phone +642040142511", + ), + PersonMergeDecision( + canonical=PersonSelector( + "Josh Loughnan", "josh@wekatravel.com", "CASH SALE WEKA TRAVEL" + ), + members=( + PersonSelector( + "Josh Loughnan", "josh@wekatravel.com", "CASH SALE WEKA TRAVEL" + ), + PersonSelector( + "Josh Loughnan", "josh@wekatravel.com", "Loughnan Construction" + ), + ), + expected_people=2, + evidence="production jobs Josh Loughnan=1, Josh Loughnan=1; shared email josh@wekatravel.com; shared phone +64220966480", + ), + PersonMergeDecision( + canonical=PersonSelector("Lars", None, "Davison Construction"), + members=( + PersonSelector("Lard", None, "Davison Construction"), + PersonSelector("Lars", None, "Davison Construction"), + PersonSelector( + "LARS DAVISON", "lars@davison.kiwi", "CASH SALE - LARS DAVISON" + ), + ), + expected_people=3, + evidence="production jobs Lard=1, Lars=3, LARS DAVISON=2; shared phone +64274052692", + ), + PersonMergeDecision( + canonical=PersonSelector("Lee / Jeanette", None, "Lee & Jeanette Sutton"), + members=( + PersonSelector("Lee / Jeanette", None, "Lee & Jeanette Sutton"), + PersonSelector( + "Lee and Jeanette,cut mild steel plate with holes", + None, + "Lee and Jeanette Sutton", + ), + ), + expected_people=2, + evidence="production jobs Lee / Jeanette=1, Lee and Jeanette,cut mild steel plate with holes=1; shared phone +6421904199", + ), + PersonMergeDecision( + canonical=PersonSelector( + "Madi Johns", "MADIJ@WATERFORD.CO.NZ", "Waterford Security Limited" + ), + members=( + PersonSelector("MADI 2", None, "Waterford Security Limited"), + PersonSelector( + "Madi Johns", "MADIJ@WATERFORD.CO.NZ", "Waterford Security Limited" + ), + ), + expected_people=2, + evidence="production jobs MADI 2=0, Madi Johns=4; shared phone +64211901935", + ), + PersonMergeDecision( + canonical=PersonSelector( + "Mark Homan", "markhoman41@gmail.com", "Ripout NZ Ltd" + ), + members=( + PersonSelector("Mark Homan", None, "CASH SALE - Mark Homan"), + PersonSelector("Mark Homan", "markhoman41@gmail.com", "Ripout NZ Ltd"), + ), + expected_people=2, + evidence="production jobs Mark Homan=0, Mark Homan=4; shared phone +6421744827", + ), + PersonMergeDecision( + canonical=PersonSelector("Mark Langdon", "mark@langdon.nz", "Mark Langdon"), + members=( + PersonSelector("MARK LANGDON", "mark@langdon.nz", "Mark Langdon"), + PersonSelector("Mark Langdon", "mark@langdon.nz", "Mark Langdon"), + ), + expected_people=2, + evidence="production jobs MARK LANGDON=1, Mark Langdon=1; shared phone +6421726479", + ), + PersonMergeDecision( + canonical=PersonSelector( + "MARK SEARS", "msears@customcontrols.co.nz", "Custom Controls Ltd" + ), + members=( + PersonSelector( + "MARK SEARS", "msears@customcontrols.co.nz", "Custom Controls Ltd" + ), + PersonSelector( + "MARK SEARS", "msears@customcontrols.co.nz", "CASH SALE - MARK SEARS" + ), + ), + expected_people=2, + evidence="production jobs MARK SEARS=1, MARK SEARS=0; shared email msears@customcontrols.co.nz; shared phone +6421758031", + ), + PersonMergeDecision( + canonical=PersonSelector("MARSH COOPER", None, "CASH SALE - MARSH COOPER"), + members=( + PersonSelector("Marsh", None, "Marsh"), + PersonSelector("MARSH COOPER", None, "CASH SALE - MARSH COOPER"), + ), + expected_people=2, + evidence="production jobs Marsh=1, MARSH COOPER=1; shared phone +6421920315", + ), + PersonMergeDecision( + canonical=PersonSelector("Matt green", None, "Penrose Paper Engineering"), + members=( + PersonSelector( + "Matt -Kindactics", "matt@kidantics.co.nz", "CASH SALE - Kindactics" + ), + PersonSelector("Matt green", None, "Penrose Paper Engineering"), + PersonSelector("Matt Green", "matt@kidantics.co.nz", "Matt Green"), + ), + expected_people=3, + evidence="production jobs Matt -Kindactics=1, Matt green=1, Matt Green=1; shared email matt@kidantics.co.nz; shared phone +64212242900", + ), + PersonMergeDecision( + canonical=PersonSelector( + "MATTHEW MACDONALD", "Matthew.Macdonald@wmetals.co.nz", "Wakefield Metals" + ), + members=( + PersonSelector( + "MATHEW MCDONALD", + "matthew.macdonald@wmetals.co.nz", + "CASH SALE - WAKEFIELDS METALS", + ), + PersonSelector( + "MATTHEW MACDONALD", + "Matthew.Macdonald@wmetals.co.nz", + "Wakefield Metals", + ), + ), + expected_people=2, + evidence="production jobs MATHEW MCDONALD=0, MATTHEW MACDONALD=1; shared email matthew.macdonald@wmetals.co.nz; shared phone +64212426732", + ), + PersonMergeDecision( + canonical=PersonSelector( + "Michael Chee", "Michael@highmark.co.nz", "High Mark Foods" + ), + members=( + PersonSelector("??", "Michael@highmark.co.nz", "High Mark Foods"), + PersonSelector("Michael Chee", "Michael@highmark.co.nz", "High Mark Foods"), + PersonSelector( + "MICHAEL CHEE", "michael@highmark.co.nz", "CASH SALE - MICHAEL CHEE" + ), + PersonSelector("Mike", None, "High Mark Foods"), + ), + expected_people=4, + evidence="production jobs ??=0, Michael Chee=3, MICHAEL CHEE=2, Mike=0; shared email michael@highmark.co.nz; shared phone +6421673290", + ), + PersonMergeDecision( + canonical=PersonSelector("Mike", None, "Mike (Michael) Loomb"), + members=( + PersonSelector("Mike", None, "Mike (Michael) Loomb"), + PersonSelector("Mike Loomb", None, "CASH SALE - Mike Loomb"), + ), + expected_people=2, + evidence="production jobs Mike=1, Mike Loomb=0; shared phone +64211951166", + ), + PersonMergeDecision( + canonical=PersonSelector( + "Mike Lum", "mikelum@email.com", "CASH SALE - Jack Lum and Co." + ), + members=( + PersonSelector("Mike Lum", "mikelum@email.com", "Jack Lum and Co Limited"), + PersonSelector( + "Mike Lum", "mikelum@email.com", "CASH SALE - Jack Lum and Co." + ), + ), + expected_people=2, + evidence="production jobs Mike Lum=0, Mike Lum=1; shared email mikelum@email.com; shared phone +6421301188", + ), + PersonMergeDecision( + canonical=PersonSelector( + "Nav", + "navneet.singh1@cushwake.com", + "Cushman and Wakefield New Zealand Limited", + ), + members=( + PersonSelector( + "Cushman Wakefield ,Nave", + None, + "Cushman and Wakefield New Zealand Limited", + ), + PersonSelector( + "Nav", + "navneet.singh1@cushwake.com", + "Cushman and Wakefield New Zealand Limited", + ), + PersonSelector("Nave", None, "Cushman and Wakefield New Zealand Limited"), + PersonSelector( + "Nave - Botany Woolworths", + None, + "Cushman and Wakefield New Zealand Limited", + ), + ), + expected_people=4, + evidence="production jobs Cushman Wakefield ,Nave=3, Nav=9, Nave=1, Nave - Botany Woolworths=1; shared phone +64212434604", + ), + PersonMergeDecision( + canonical=PersonSelector("Neville", None, "Watercone ltd (Neville)"), + members=( + PersonSelector("Neville", None, "The water cone"), + PersonSelector("Neville", None, "Watercone ltd (Neville)"), + ), + expected_people=2, + evidence="production jobs Neville=1, Neville=2; shared phone +6421922819", + ), + PersonMergeDecision( + canonical=PersonSelector( + "NICK DUFFY", "nick@hunkineng.co.nz", "Hunkin Engineering" + ), + members=( + PersonSelector("Nick Duffy", "nick@hunkineng.co.nz", "Hunkin Engineering"), + PersonSelector("NICK DUFFY", "nick@hunkineng.co.nz", "Hunkin Engineering"), + ), + expected_people=2, + evidence="production jobs Nick Duffy=0, NICK DUFFY=4; shared email nick@hunkineng.co.nz", + ), + PersonMergeDecision( + canonical=PersonSelector( + "Nick Haines", + "nhaines@nepgroup.com", + "NEP Broadcast Services New Zealand Limited", + ), + members=( + PersonSelector("Nick Haines", None, "CASH SALE - Nick Haines"), + PersonSelector( + "Nick Haines", + "nhaines@nepgroup.com", + "NEP Broadcast Services New Zealand Limited", + ), + ), + expected_people=2, + evidence="production jobs Nick Haines=0, Nick Haines=1", + ), + PersonMergeDecision( + canonical=PersonSelector( + "Nick Hansen", "nickhprogresse@hotmail.co.nz", "CASH SALE Nick Hansen" + ), + members=( + PersonSelector("Nick Hansen", None, "Nick Hansen"), + PersonSelector( + "Nick Hansen", "nickhprogresse@hotmail.co.nz", "CASH SALE Nick Hansen" + ), + ), + expected_people=2, + evidence="production jobs Nick Hansen=1, Nick Hansen=3; shared phone +64272968753", + ), + PersonMergeDecision( + canonical=PersonSelector("Nigel", None, "MSM (Shop)"), + members=( + PersonSelector("Nigel", None, "MSM (Shop)"), + PersonSelector("Nigel", None, "MSM"), + ), + expected_people=2, + evidence="production jobs Nigel=3, Nigel=1", + ), + PersonMergeDecision( + canonical=PersonSelector( + "Onker Goundar", + "church.st.panelbeaters@gmail.com", + "CASH SALE - Church St Motors", + ), + members=( + PersonSelector( + "ONKAR GOUNDAR", + "church.st.panelbeaters@gmail.com", + "CASH SALE - CHURCH ST PANELBEATERS", + ), + PersonSelector( + "onkar goundar", + "church.st.panelbeaters@gmail.com", + "CASH SALE - Church Street Panelbeaters", + ), + PersonSelector( + "Onker Goundar", + "church.st.panelbeaters@gmail.com", + "CASH SALE - Church St Motors", + ), + ), + expected_people=3, + evidence="production jobs ONKAR GOUNDAR=0, onkar goundar=1, Onker Goundar=3; shared email church.st.panelbeaters@gmail.com; shared phone +64275670142", + ), + PersonMergeDecision( + canonical=PersonSelector( + "Paul O'Brien", "paulobrien575@gmail.com", "CASH SALE - PAUL O'BRIEN" + ), + members=( + PersonSelector("PAUL O'BRIEN", "paul@timeworx.co.nz", "Timeworx"), + PersonSelector( + "Paul O'Brien", "paulobrien575@gmail.com", "CASH SALE - PAUL O'BRIEN" + ), + ), + expected_people=2, + evidence="production jobs PAUL O'BRIEN=2, Paul O'Brien=3; shared phone +64212837447", + ), + PersonMergeDecision( + canonical=PersonSelector("Pete Pedersen", None, "CASH SALE#Pete Pederson"), + members=( + PersonSelector("Pete Pedersen", None, "CASH SALE#Pete Pederson"), + PersonSelector( + "Pete Pederson", "prodognz@gmail.com", "CASH SALE - Pete Pederson" + ), + ), + expected_people=2, + evidence="production jobs Pete Pedersen=1, Pete Pederson=1; shared phone +64274300224", + ), + PersonMergeDecision( + canonical=PersonSelector( + "Peter", None, "Cushman and Wakefield New Zealand Limited" + ), + members=( + PersonSelector("Peter", None, "Cushman and Wakefield New Zealand Limited"), + PersonSelector( + "Pieter Badenhorst", + "pieter.badenhorst@cushwake.com", + "Cushman and Wakefield New Zealand Limited", + ), + ), + expected_people=2, + evidence="production jobs Peter=5, Pieter Badenhorst=3; shared phone +6421466755", + ), + PersonMergeDecision( + canonical=PersonSelector( + "PETER MANHIRE", "peter.manhire@skellerupgroup.com", "Peter Manhire" + ), + members=( + PersonSelector("Peter Manhire", None, "Ultralon Foam International"), + PersonSelector( + "PETER MANHIRE", "peter.manhire@skellerupgroup.com", "Peter Manhire" + ), + ), + expected_people=2, + evidence="production jobs Peter Manhire=1, PETER MANHIRE=1", + ), + PersonMergeDecision( + canonical=PersonSelector("Phil", None, "Phil"), + members=( + PersonSelector("Phil", None, "Phil"), + PersonSelector("Phil", None, "CASH SALE - VIKING BUILDERS LTD - PHIL"), + PersonSelector("Phil", None, "CASH SALE - Phil"), + PersonSelector("Philip", None, "Phil"), + ), + expected_people=4, + evidence="production jobs Phil=1, Phil=1, Phil=0, Philip=1; shared phone +642108568997", + ), + PersonMergeDecision( + canonical=PersonSelector( + "Philip Whitam", "phil.point@outlook.com", "Point Maintenance" + ), + members=( + PersonSelector("Philip", None, "cash sale phillip"), + PersonSelector("Philip", "phil.point@outlook.com", "Phillip Whitham"), + PersonSelector( + "Philip Whitam", "phil.point@outlook.com", "Point Maintenance" + ), + ), + expected_people=3, + evidence="production jobs Philip=1, Philip=1, Philip Whitam=4; shared email phil.point@outlook.com; shared phone +64277033500", + ), + PersonMergeDecision( + canonical=PersonSelector( + "Ray Zhang", "ray.zhang@hellofresh.co.nz", "Hello Fresh New Zealand" + ), + members=( + PersonSelector( + "Ray Zhang", "ray.zhang@hellofresh.co.nz", "Hello Fresh New Zealand" + ), + PersonSelector( + "RAY ZHANG", "ray.zhang@hellofresh.co.nz", "Hello Fresh New Zealand" + ), + ), + expected_people=2, + evidence="production jobs Ray Zhang=9, RAY ZHANG=0; shared email ray.zhang@hellofresh.co.nz", + ), + PersonMergeDecision( + canonical=PersonSelector( + "REM WADEH", "REM.WADEH@HOTMAIL.COM", "CASH SALE - REM WADEH" + ), + members=( + PersonSelector( + "REM WADEH", "REM.WADEH@HOTMAIL.COM", "CASH SALE - REM WADEH" + ), + PersonSelector( + "REM WADEH", "rem_wadeh@hotmail.com", "CASH - SALE - RERM WADEH" + ), + ), + expected_people=2, + evidence="production jobs REM WADEH=2, REM WADEH=0; shared phone +64211523239", + ), + PersonMergeDecision( + canonical=PersonSelector( + "Richard Mills", None, "Cushman and Wakefield New Zealand Limited" + ), + members=( + PersonSelector( + "Richard Mills", None, "Cushman and Wakefield New Zealand Limited" + ), + PersonSelector( + "Richard Mills", + "graememills15@gmail.com", + "Onehunga Car Services Limited", + ), + ), + expected_people=2, + evidence="production jobs Richard Mills=10, Richard Mills=1", + ), + PersonMergeDecision( + canonical=PersonSelector( + "Rick Van Swet", "rickvanswet@gmail.com", "CASH SALE - Rick Van Swet" + ), + members=( + PersonSelector("Rick", None, "Rick vanderset"), + PersonSelector( + "Rick Van Swet", "rickvanswet@gmail.com", "CASH SALE - Rick Van Swet" + ), + ), + expected_people=2, + evidence="production jobs Rick=1, Rick Van Swet=1; shared phone +6421400711", + ), + PersonMergeDecision( + canonical=PersonSelector("Rob", None, "Expac Machining Centre 2024 Ltd"), + members=( + PersonSelector( + "Expac cut and fold brackets as per drawing", + None, + "Expac Machining Centre 2024 Ltd", + ), + PersonSelector("Rob", None, "Expac Machining Centre 2024 Ltd"), + ), + expected_people=2, + evidence="production jobs Expac cut and fold brackets as per drawing=1, Rob=5; shared phone +64274448238", + ), + PersonMergeDecision( + canonical=PersonSelector("Robbie", None, "Robbie Grant"), + members=( + PersonSelector("Robbie", None, "Robbie Grant"), + PersonSelector("Robert Grant", None, "Robert Grant"), + ), + expected_people=2, + evidence="production jobs Robbie=1, Robert Grant=1; shared phone +6421502802", + ), + PersonMergeDecision( + canonical=PersonSelector( + "Robert", None, "Cushman and Wakefield New Zealand Limited" + ), + members=( + PersonSelector("Robert", None, "Cushman and Wakefield New Zealand Limited"), + PersonSelector("ROBERT", None, "cash sale - ROBERT - CUSHMAN WAKEFIELD"), + ), + expected_people=2, + evidence="production jobs Robert=15, ROBERT=2; shared phone +64212450109", + ), + PersonMergeDecision( + canonical=PersonSelector( + "Rusty Tagicakibau", + "Rusty.Tagicakibau@cushwake.com", + "Cushman and Wakefield New Zealand Limited", + ), + members=( + PersonSelector("Rusty", None, "Rusty Tagicakibau"), + PersonSelector( + "Rusty Tagicakibau", + "Rusty.Tagicakibau@cushwake.com", + "Cushman and Wakefield New Zealand Limited", + ), + ), + expected_people=2, + evidence="production jobs Rusty=1, Rusty Tagicakibau=6; shared phone +64274050629", + ), + PersonMergeDecision( + canonical=PersonSelector( + "Sam Loughnan", "sam@loughnanconstruction.co.nz", "Loughnan Construction" + ), + members=( + PersonSelector("Sam", None, "Loughnan Construction"), + PersonSelector( + "Sam Loughnan", + "sam@loughnanconstruction.co.nz", + "Loughnan Construction", + ), + ), + expected_people=2, + evidence="production jobs Sam=4, Sam Loughnan=5; shared phone +6421782110", + ), + PersonMergeDecision( + canonical=PersonSelector("Sarah newman", None, "CGO (Corporate) Limited"), + members=( + PersonSelector("Sarah newman", None, "CGO (Corporate) Limited"), + PersonSelector( + "Sarah Newman", "accounts@mammothbrands.nz", "CGO Corporate Ltd" + ), + PersonSelector( + "Sarah Newman", "sarah@mammothbrands.nz", "CASH SALE - Mammoth Brands" + ), + ), + expected_people=3, + evidence="production jobs Sarah newman=4, Sarah Newman=1, Sarah Newman=0; shared phone +64212727246", + ), + PersonMergeDecision( + canonical=PersonSelector("Simon", None, "Simon Cope"), + members=( + PersonSelector("Simon", None, "Simon Cope"), + PersonSelector("Simon Cope", None, "Simon Cope"), + ), + expected_people=2, + evidence="production jobs Simon=1, Simon Cope=1; shared phone +6421968103", + ), + PersonMergeDecision( + canonical=PersonSelector("Simon Rose", "simon.rose@proclimb.co.nz", "Proclimb"), + members=( + PersonSelector("Simon Rose", "simon.rose@proclimb.co.nz", "Proclimb"), + PersonSelector( + "SIMON ROSE", "simon.rose@proclimb.co.nz", "CASH SALE - SIMON ROSE" + ), + # KAN-278 drift refresh (human-reviewed 2026-07-15): third row at + # Proclimb with a transposed phone (+64276442307) appeared after freeze. + PersonSelector("SIMON ROSE", "simon.rose@proclimb.co.nz", "Proclimb"), + ), + expected_people=3, + evidence="production jobs Simon Rose=1, SIMON ROSE=1; shared email simon.rose@proclimb.co.nz; shared phone +64276642307 (third row +64276442307, transposed)", + ), + PersonMergeDecision( + canonical=PersonSelector( + "STEPHEN CARR", "daimlersteve@gmail.com", "CASH SALE - STEPHEN CARR" + ), + members=( + PersonSelector( + "STEPHEN CARR", "daimlersteve@gmail.com", "Stephen (Steve) Carr" + ), + PersonSelector( + "STEPHEN CARR", "daimlersteve@gmail.com", "CASH SALE - STEPHEN CARR" + ), + PersonSelector( + "Steve Carr", "daimlersteve@gmail.com", "CASH SALE - Steve Carr" + ), + ), + expected_people=3, + evidence="production jobs STEPHEN CARR=1, STEPHEN CARR=1, Steve Carr=1; shared email daimlersteve@gmail.com; shared phone +64275120443", + ), + PersonMergeDecision( + canonical=PersonSelector( + "Steve Kirby", "kirby.nz@gmail.com", "CASH SALE - Steve Kirby" + ), + members=( + PersonSelector("Steve", "Kirby.nz@gmail.com", "Steve Kirby"), + PersonSelector( + "Steve Kirby", "kirby.nz@gmail.com", "CASH SALE - Steve Kirby" + ), + ), + expected_people=2, + evidence="production jobs Steve=1, Steve Kirby=1; shared email kirby.nz@gmail.com", + ), + PersonMergeDecision( + canonical=PersonSelector( + "Stuart Watts", "stuartwatts07@gmail.com", "CASH SALE- Penrose motors" + ), + members=( + PersonSelector( + "Stuart Watts", "stuartwatts07@gmail.com", "CASH SALE- Penrose motors" + ), + PersonSelector( + "STUART WATTS", "stuartwatts07@gmail.com", "CASH SALE - STUART WATTS" + ), + ), + expected_people=2, + evidence="production jobs Stuart Watts=1, STUART WATTS=1; shared phone +64278172546", + ), + PersonMergeDecision( + canonical=PersonSelector( + "SUZANNE PENTECOST", + "spentecost@adinahotels.co.nz", + "CASH SALE - ADINA APARTMENT HOTEL AUCKLAND BRITOMART", + ), + members=( + PersonSelector( + "SUZANNE PENTECOST", + "spentecost@adinahotels.co.nz", + "Dominion Constructors Limited", + ), + PersonSelector( + "SUZANNE PENTECOST", + "spentecost@adinahotels.co.nz", + "CASH SALE - ADINA APARTMENT HOTEL AUCKLAND BRITOMART", + ), + ), + expected_people=2, + evidence="production jobs SUZANNE PENTECOST=0, SUZANNE PENTECOST=2; shared email spentecost@adinahotels.co.nz; shared phone +6493938200", + ), + PersonMergeDecision( + canonical=PersonSelector("Thomas", "redhawk@xtra.co.nz", "thomas"), + members=( + PersonSelector("Thomas", "redhawk@xtra.co.nz", "thomas"), + PersonSelector("tom", "redhawk@xtra.co", "thomas"), + ), + expected_people=2, + evidence="production jobs Thomas=1, tom=0; shared phone +64220446045", + ), + # KAN-278: 'TIM MOORE'@'CASH SALE - MCALPINE HUSSMANN' duplicate already + # resolved in production — only a single 'Timothy Moore'@'MCALPINE HUSSMANN' + # remains, so there is nothing left to merge. + PersonMergeDecision( + canonical=PersonSelector( + "Toby Andrews", "tobias.andrews@ventia.com", "Ventia NZ Limited" + ), + members=( + PersonSelector( + "Tobias Andrews", "tobias.andrews@ventia.com", "Ventia NZ Limited" + ), + PersonSelector( + "Toby Andrews", "tobias.andrews@ventia.com", "Ventia NZ Limited" + ), + ), + expected_people=2, + evidence="production jobs Tobias Andrews=1, Toby Andrews=6; shared phone +64273594960", + ), + PersonMergeDecision( + canonical=PersonSelector( + "Vanya Kroon", "Vanyakroon@gmail.com", "CASH SALE - Vanya Kroon" + ), + members=( + PersonSelector( + "VANYA KROON", "VANYAKROON@GMIAL.COM", "CASH SALE - Vanya Kroon" + ), + PersonSelector( + "Vanya Kroon", "Vanyakroon@gmail.com", "CASH SALE - Vanya Kroon" + ), + ), + expected_people=2, + evidence="production jobs VANYA KROON=1, Vanya Kroon=3; shared phone +64212046863", + ), + PersonMergeDecision( + canonical=PersonSelector( + "Vimlesh Prasad", + "Vimlesh.Prasad@fultonhogan.com", + "Fulton Hogan Auckland Limited", + ), + members=( + PersonSelector( + "Vimlesh Prasad", + "Vimlesh.Prasad@fultonhogan.com", + "Fulton Hogan Auckland Limited", + ), + PersonSelector( + "Vimlesh Prasadd", + "vimlesh.prasad@fultonhogan.com", + "Fulton Hogan Auckland Limited", + ), + ), + expected_people=2, + evidence="production jobs Vimlesh Prasad=17, Vimlesh Prasadd=1; shared email vimlesh.prasad@fultonhogan.com; shared phone +64278365351", + ), + PersonMergeDecision( + canonical=PersonSelector("WOLFGANG", None, "CASH SALE - WOLFGANG"), + members=( + PersonSelector("Wolfgang", None, "Wolfgang Schenk"), + PersonSelector("WOLFGANG", None, "CASH SALE - WOLFGANG"), + ), + expected_people=2, + evidence="production jobs Wolfgang=1, WOLFGANG=2; shared phone +64211253754", + ), + PersonMergeDecision( + canonical=PersonSelector( + "ZACH LINDSAY", + "mike@lindsaybuildingservices.co.nz", + "CASH SALE - Lindsay Building Services", + ), + members=( + PersonSelector( + "ZACH", + "ZACH@LINDSAYBUILDINGSERVICES.CO.NZ", + "Lindsay Building Services", + ), + PersonSelector( + "ZACH LINDSAY", + "mike@lindsaybuildingservices.co.nz", + "CASH SALE - Lindsay Building Services", + ), + ), + expected_people=2, + evidence="production jobs ZACH=1, ZACH LINDSAY=1; shared phone +64212216699", + ), + # KAN-278 drift refresh (human-reviewed 2026-07-15): duplicate that appeared + # in production after the original dataset was frozen. + PersonMergeDecision( + canonical=PersonSelector("YAN LIM", "yan@limbro.com", "Limbrother Ltd"), + members=( + PersonSelector("YAN LIM", "yan@limbro.com", "Limbrother Ltd"), + PersonSelector("Yan", "yan@limbro.com", "CASH SALE - YAN - 021809004"), + ), + expected_people=2, + evidence="shared yan@limbro.com; 'Yan' is the cash-sale alias of 'YAN LIM' at Limbrother Ltd", + ), +) + + +# These are genuine separate businesses despite a weak shared signal. This is +# the complete defence ledger for the broad production candidate review. +USER_APPROVED_RETAINED_COMPANY_DECISIONS: tuple[RetainedDecision, ...] = () + +AGENT_RETAINED_COMPANY_DECISIONS: tuple[RetainedDecision, ...] = ( + RetainedDecision( + ("Auckland Airport Limited", "DGE Ltd"), + "Aaron Williams has real jobs recorded for both organisations; one Person is linked to both.", + ), + RetainedDecision( + ( + "Auckland Bead Blasting Services", + "Westgate Welding Limited", + "Galvanising Services 2018 Limited", + ), + "Only a Vodafone-hosted email domain is shared; the names and job histories are unrelated.", + ), + RetainedDecision( + ("Auckland Council", "CASH SALE - Renee Bevan", "Kim Sinclair", "Mike Munley"), + "Only the public me.com email domain is shared.", + ), + RetainedDecision( + ("Body Corporate 322257", "Crockers Body Corp BC 312605"), + "The legal body-corporate numbers are different.", + ), + RetainedDecision( + ("Carter Holt Harvey - Infotech", "Carter Holt Harvey Paperbag"), + "Named operating divisions with separate purchasing histories.", + ), + RetainedDecision( + ("Carters Panmure", "Carters Onehunga - account 3079980"), + "Named branches; branch identity is required for delivery and billing.", + ), + RetainedDecision( + ("CASH SALE - FRESH CHOICE ONEHUNGA", "Fresh Choice Mangere Bridge"), + "Different supermarket branches.", + ), + RetainedDecision( + ("CASH SALE - New World Orakei", "New World Green Bay", "New World New Lynn"), + "Different supermarket branches.", + ), + RetainedDecision( + ( + "CASH SALE - Rana Property Maintenance", + "CASH SALE - Matt Heywood", + "Garry Lawrence Roofing", + "Reece Taituha", + "Super Cheap Tyres", + ), + "Only the public live.com email domain is shared.", + ), + RetainedDecision( + ("Corrin Lakeland (corrin.lakeland@cmeconnect.com)", "Gerry Westenberg"), + "Two named coworkers using the CME corporate domain.", + ), + RetainedDecision( + ( + "Craig Burrowes", + "Electrical Importing Company", + "Tom Morris", + "Watchman Developments Ltd", + ), + "Only the legacy ihug ISP domain is shared.", + ), + RetainedDecision( + ("Expac Engineering Ltd (new account)", "Expac Machining Centre 2024 Ltd"), + "Separate incorporated machining business: seven machining jobs for Rob and Steve. Only the old Expac contact details overlap.", + ), + RetainedDecision( + ("Galeeco Automotive", "CASH SALE - MULTISERVE"), + "Different businesses and named contacts; a family/office phone is shared.", + ), + RetainedDecision( + ("George Western Foods Limited - Tip Top Bread Auckland", "Mauri"), + "Separate operating divisions sharing accounts-payable infrastructure.", + ), + RetainedDecision( + ("Global Fire Limited", "Global Linings Ltd"), + "Sister legal companies with shared administration.", + ), + RetainedDecision( + ( + "CASH SALE - IDEAL ELECTRICAL ROSEDALE BRANCH", + "Ideal Electrical (Penrose) - Cash sale purchases", + ), + "Different electrical-supply branches.", + ), + RetainedDecision( + ("Kinect Holdings Ltd", "Brickell Technology"), + "Only the ieee.org membership domain is shared.", + ), + RetainedDecision( + ("KSG Food Packers Ltd", "Green Valley Foods"), + "Related but separately named and billed food businesses.", + ), + RetainedDecision( + ("Leap Innovation NZ", "Snowdon Consulting"), + "Ben has genuine job history at both companies.", + ), + RetainedDecision( + ( + "Liquorland Onehunga", + "Liquorland Stonefield", + "Premium Liquor - Liquorland Onehunga", + ), + "Different retail branches/accounts.", + ), + RetainedDecision( + ("CASH SALE WEKA TRAVEL", "Loughnan Construction"), + "Josh Loughnan has genuine jobs at both; the companies have unrelated work histories.", + ), + RetainedDecision( + ( + "Bloomin Luvley", + "Mike Gillard Family Trust", + "Onehunga Auto and Marine Upholstery", + "Tony Warren Engineering", + ), + "Only the legacy Orcon ISP domain is shared.", + ), + RetainedDecision( + ("MSM (Billable - Customer TBD)", "Cash Sales"), + "Deliberate internal workflow accounts with different accounting purposes.", + ), + RetainedDecision( + ( + "MSM (Shop)", + "MSM (Billable - Customer TBD)", + "ABC Carpet Cleaning TEST IGNORE", + "Lakeland Consulting", + ), + "Deliberate internal/test accounts; the abc/lakeland address is test data, not customer identity.", + ), + RetainedDecision( + ( + "Oji Fibre Solutions - Packaging NZ", + "Oji Fibre Solutions - Paper Bag", + "Oji Fibre Solutions - Penrose Mill", + "Penrose Paper Engineering", + ), + "Named Oji divisions are separate; Penrose Paper Engineering is a different current business despite Matt's obsolete Oji address.", + ), + RetainedDecision( + ("Onehunga Car Services Limited", "Cushman and Wakefield New Zealand Limited"), + "Richard Mills has real jobs under both companies and is represented by one Person linked to both.", + ), + RetainedDecision( + ("Pauanui Menzshed", "Timeworx"), + "Paul O'Brien's personal Gmail is the only overlap; the organisations are unrelated.", + ), + RetainedDecision( + ("Peter Diamond", "Impact Tints"), "Only the public mail.com domain is shared." + ), + RetainedDecision( + ("Ray Baker", "The Dunollie Group"), + "Only the legacy Slingshot ISP domain is shared.", + ), + RetainedDecision( + ("Regan Marshall (WWB)", "Richard Pryde - Winstones"), + "Different GIB coworkers represented by person-labelled records.", + ), + RetainedDecision( + ("Ripout NZ Ltd", "Workdek"), + "Raw values are '0800 RIPOUT' and '0800nofall'; both were incorrectly normalised to +64800.", + ), + RetainedDecision( + ("Road Science", "Downer Group"), + "Subsidiary and parent are separately billed legal entities.", + ), + RetainedDecision( + ("Ultralon Foam International", "Nexus Performance Foams Limited"), + "Separate Skellerup subsidiaries; Peter's group contact is shared.", + ), + RetainedDecision( + ("University of Auckland - Support Services", "Total Works Ltd"), + "Separate organisations; only an aucklanduni contact address overlaps.", + ), + RetainedDecision( + ( + "Valmont Coatings/CSP Galvanising", + "Locker Group Limited/Webforge Locker (Valmont)", + "Webforge (NZ) Ltd", + ), + "Separate operating divisions with distinct work histories.", + ), + RetainedDecision( + ("Work Investment Ltd T/A Epic Beer", "Dominion Constructors Limited"), + "Simon Mowday is a construction contact for Epic; the legal companies are unrelated.", + ), + RetainedDecision( + ("Yellow Bites LImited", "JJ Wafer Biscuits Limited"), + "Separate Limited billing entities under shared group accounts; Yellow Bites alone has eleven jobs.", + ), +) + + +# Shared office numbers identify coworkers, not duplicate People. The two +# cross-company entries are also supported by real job history at both firms. +USER_APPROVED_RETAINED_PERSON_DECISIONS: tuple[RetainedDecision, ...] = () + +AGENT_RETAINED_PERSON_DECISIONS: tuple[RetainedDecision, ...] = ( + RetainedDecision( + ("BRENT", "Brian", "Nathan"), + "Three Stainless Welding people share the office number; Brent has 94 jobs and Nathan has two.", + ), + RetainedDecision( + ("David", "Mike"), + "Equipment Engineering coworkers with different corporate emails and seven/six jobs.", + ), + RetainedDecision( + ("Graeme", "Richard Mills"), + "Different Onehunga Car Services people sharing the office number.", + ), + RetainedDecision( + ("Hamish Taylor", "TOM TAULA"), + "Matassa coworkers with different corporate emails and two jobs each.", + ), + RetainedDecision( + ("JENNINE MARSHALL", "TYLER BROWN"), + "GFL coworkers with different corporate emails and one job each.", + ), + RetainedDecision( + ("Josh N.", "Raj"), "Different companies; abc@gmail.com is placeholder data." + ), + RetainedDecision( + ("Keith Kariyawasam", "SURANGA KARIYAWASAM"), + "Different Galeeco/Multiserve people sharing a family or office phone.", + ), + RetainedDecision( + ("Mike", "Raj"), + "Different Boss Motorbodies/AI Technology contacts sharing an office number.", + ), + RetainedDecision( + ("Tamara Gogava", "Varinder Kumar"), + "HV coworkers with different corporate emails and jobs.", + ), + RetainedDecision( + ("tony", "ZACH LINDSAY"), + "Different Lindsay Building Services contacts sharing the company number.", + ), +) + + +INVALID_LINKS: tuple[InvalidLinkDecision, ...] = ( + InvalidLinkDecision( + "MSM (Shop)", + "Bobby", + None, + "Bobby's real Blackstone history proves the MSM Shop association is erroneous.", + ), + InvalidLinkDecision( + "Dominion Constructors Limited", + "SUZANNE PENTECOST", + "spentecost@adinahotels.co.nz", + "Suzanne is Adina's contact; Dominion has no supporting job history for this link.", + ), +) + +MATT_GREEN_REPAIR = PersonSelector( + "Matthew Green", "j.green@northlandroofs.com", "CASH SALE-Kidantics" +) +MATT_WRONG_METHOD_VALUES = {"j.green@northlandroofs.com", "+64277003221"} + + +def _company_activity(company: Company) -> int: + from apps.accounting.models import Bill, CreditNote, Invoice, Quote + from apps.job.models import Job + from apps.purchasing.models import PurchaseOrder + + return ( + Job.objects.filter(company=company).count() + + Invoice.objects.filter(company=company).count() + + Bill.objects.filter(company=company).count() + + CreditNote.objects.filter(company=company).count() + + Quote.objects.filter(company=company).count() + + PurchaseOrder.objects.filter(supplier=company).count() + + company.contacts.count() + + company.contact_methods.count() + ) + + +def _person_activity(person: Person) -> int: + from apps.crm.models import PhoneCallRecord + from apps.job.models import Job + + return ( + Job.objects.filter(person=person).count() + + PhoneCallRecord.objects.filter(person=person).count() + + person.company_links.count() + + person.contact_methods.count() + ) + + +def _resolve_company_decisions() -> list[tuple[Company, list[Company]]]: + resolved: list[tuple[Company, list[Company]]] = [] + used_ids: set[str] = set() + for decision in REVIEWED_COMPANY_MERGES: + companies = list(Company.objects.filter(name__in=decision.names)) + if len(companies) != decision.expected_rows: + raise RuntimeError( + f"KAN-278 Company evidence changed for {decision.names}: " + f"expected {decision.expected_rows}, found {len(companies)}" + ) + canonical_candidates = [ + company for company in companies if company.name == decision.canonical_name + ] + if not canonical_candidates: + raise RuntimeError( + f"KAN-278 canonical Company is missing: {decision.canonical_name}" + ) + canonical = max(canonical_candidates, key=_company_activity) + sources = [company for company in companies if company.id != canonical.id] + company_ids = {str(company.id) for company in companies} + overlap = used_ids.intersection(company_ids) + if overlap: + raise RuntimeError(f"KAN-278 Company decisions overlap: {decision.names}") + used_ids.update(company_ids) + resolved.append((canonical, sources)) + return resolved + + +def _person_query(selector: PersonSelector) -> Q: + query = Q(name=selector.name) + if selector.email is None: + query &= Q(email__isnull=True) + else: + query &= Q(email__iexact=selector.email) + if selector.company_name is None: + query &= Q(company_links__isnull=True) + else: + query &= Q(company_links__company__name=selector.company_name) + return query + + +def _people_for_selector(selector: PersonSelector) -> list[Person]: + return list(Person.objects.filter(_person_query(selector)).distinct()) + + +def _resolve_person_decisions() -> list[tuple[Person, list[Person]]]: + resolved: list[tuple[Person, list[Person]]] = [] + used_ids: set[str] = set() + for decision in REVIEWED_PERSON_MERGES: + people_by_id = { + person.id: person + for selector in decision.members + for person in _people_for_selector(selector) + } + people = list(people_by_id.values()) + if len(people) != decision.expected_people: + raise RuntimeError( + f"KAN-278 Person evidence changed for {decision.members}: " + f"expected {decision.expected_people}, found {len(people)}" + ) + canonical_candidates = _people_for_selector(decision.canonical) + canonical_candidates = [ + person for person in canonical_candidates if person.id in people_by_id + ] + if not canonical_candidates: + raise RuntimeError( + f"KAN-278 canonical Person is missing: {decision.canonical}" + ) + canonical = max(canonical_candidates, key=_person_activity) + sources = [person for person in people if person.id != canonical.id] + person_ids = {str(person.id) for person in people} + overlap = used_ids.intersection(person_ids) + if overlap: + raise RuntimeError(f"KAN-278 Person decisions overlap: {decision.members}") + used_ids.update(person_ids) + resolved.append((canonical, sources)) + return resolved + + +def _resolve_invalid_links() -> list[CompanyPersonLink]: + links: list[CompanyPersonLink] = [] + for decision in INVALID_LINKS: + queryset = CompanyPersonLink.objects.filter( + company__name=decision.company_name, + person__name=decision.person_name, + ) + if decision.person_email is None: + queryset = queryset.filter(person__email__isnull=True) + else: + queryset = queryset.filter(person__email__iexact=decision.person_email) + matches = list(queryset) + if len(matches) != 1: + raise RuntimeError( + f"KAN-278 invalid-link evidence changed for {decision}: " + f"found {len(matches)}" + ) + links.append(matches[0]) + return links + + +def _repair_matt_green() -> None: + matches = _people_for_selector(MATT_GREEN_REPAIR) + if len(matches) != 1: + raise RuntimeError(f"KAN-278 Matt Green evidence changed: found {len(matches)}") + person = matches[0] + ContactMethod.objects.filter( + person=person, + normalized_value__in=MATT_WRONG_METHOD_VALUES, + ).delete() + person.email = None + person.save(update_fields=["email", "updated_at"]) + + +def _normalised_names(names: list[str]) -> frozenset[str]: + return frozenset(" ".join(name.casefold().split()) for name in names) + + +def _assert_residuals_are_defended() -> None: + report = DuplicateIdentityReportService().get_report() + automatic = [ + group + for group in report["company_groups"] + report["person_groups"] + if group["recommendation"] == "merge" + ] + if automatic: + raise RuntimeError( + f"KAN-278 cleanup left {len(automatic)} unmerged duplicate groups" + ) + + retained_companies = { + _normalised_names(list(decision.names)) + for decision in ( + USER_APPROVED_RETAINED_COMPANY_DECISIONS + AGENT_RETAINED_COMPANY_DECISIONS + ) + } + for company_group in report["company_groups"]: + names = _normalised_names( + [member["name"] for member in company_group["members"]] + ) + if not any(names <= retained for retained in retained_companies): + raise RuntimeError( + f"KAN-278 has an undefended retained Company group: {sorted(names)}" + ) + + retained_people = { + _normalised_names(list(decision.names)) + for decision in ( + USER_APPROVED_RETAINED_PERSON_DECISIONS + AGENT_RETAINED_PERSON_DECISIONS + ) + } + for person_group in report["person_groups"]: + names = _normalised_names( + [member["name"] for member in person_group["members"]] + ) + if not any(names <= retained for retained in retained_people): + raise RuntimeError( + f"KAN-278 has an undefended retained Person group: {sorted(names)}" + ) + + +def _flatten_existing_company_merges(staff: Staff) -> None: + company_rows = list(Company.objects.values_list("id", "merged_into_id")) + company_ids = {company_id for company_id, _destination_id in company_rows} + destinations = { + company_id: destination_id + for company_id, destination_id in company_rows + if destination_id is not None + } + + for destination_id in destinations.values(): + if destination_id not in company_ids: + raise RuntimeError( + f"Merged Company destination {destination_id} does not exist" + ) + + terminal_destinations: dict[UUID, UUID] = {} + for source_id, direct_destination_id in destinations.items(): + visited = {source_id} + terminal_id = direct_destination_id + while terminal_id in destinations: + if terminal_id in visited: + raise RuntimeError(f"Company merge cycle includes {terminal_id}") + visited.add(terminal_id) + terminal_id = destinations[terminal_id] + terminal_destinations[source_id] = terminal_id + + for source_id, terminal_id in terminal_destinations.items(): + if destinations[source_id] != terminal_id: + Company.objects.filter(id=source_id).update(merged_into_id=terminal_id) + merge_companies(source_id, terminal_id, staff) + + if Company.objects.filter(merged_into__merged_into__isnull=False).exists(): + raise RuntimeError("KAN-278 cleanup left a multi-hop Company merge") + + +def apply_reviewed_duplicate_cleanup() -> tuple[int, int]: + """Apply the finite reviewed ledger and reject every unlisted residual.""" + if not REVIEWED_COMPANY_MERGES: + return 0, 0 + production_sentinel = REVIEWED_COMPANY_MERGES[0] + if not Company.objects.filter(name__in=production_sentinel.names).exists(): + return 0, 0 + + # Resolve every human selector before touching data. A changed production + # snapshot therefore fails with no partial cleanup. + company_groups = _resolve_company_decisions() + person_groups = _resolve_person_decisions() + invalid_links = _resolve_invalid_links() + + staff = Staff.get_automation_user() + company_count = 0 + person_count = 0 + with transaction.atomic(): + for link in invalid_links: + link.delete() + _repair_matt_green() + + _flatten_existing_company_merges(staff) + for canonical_company, source_companies in company_groups: + for source_company in source_companies: + merge_companies(source_company.id, canonical_company.id, staff) + company_count += 1 + _flatten_existing_company_merges(staff) + + for canonical_person, source_people in person_groups: + for source_person in source_people: + merge_people(source_person.id, canonical_person.id, staff) + person_count += 1 + + _assert_residuals_are_defended() + + return company_count, person_count diff --git a/apps/company/services/person_merge_service.py b/apps/company/services/person_merge_service.py new file mode 100644 index 000000000..b08a7e77f --- /dev/null +++ b/apps/company/services/person_merge_service.py @@ -0,0 +1,175 @@ +"""Atomic reassignment of one duplicate Person into a canonical Person.""" + +from typing import TypedDict +from uuid import UUID + +from django.db import transaction +from django.utils import timezone + +from apps.accounts.models import Staff +from apps.company.models import CompanyPersonLink, ContactMethod, Person +from apps.workflow.exceptions import AlreadyLoggedException +from apps.workflow.services.error_persistence import persist_app_error + + +class PersonMergeCounts(TypedDict): + jobs: int + phone_calls: int + links_moved: int + links_collapsed: int + contact_methods_moved: int + contact_methods_collapsed: int + + +def _merge_links(source: Person, destination: Person) -> tuple[int, int]: + destination_links = { + link.company_id: link + for link in CompanyPersonLink.objects.filter(person=destination) + } + moved = 0 + collapsed = 0 + for source_link in CompanyPersonLink.objects.filter(person=source).order_by("id"): + destination_link = destination_links.get(source_link.company_id) + if destination_link is None: + source_link.person = destination + source_link.save(update_fields=["person", "updated_at"]) + destination_links[source_link.company_id] = source_link + moved += 1 + continue + + update_fields = ["updated_at"] + if not destination_link.position and source_link.position: + destination_link.position = source_link.position + update_fields.append("position") + if not destination_link.notes and source_link.notes: + destination_link.notes = source_link.notes + update_fields.append("notes") + if source_link.is_active and not destination_link.is_active: + destination_link.is_active = True + update_fields.append("is_active") + if source_link.is_primary and not destination_link.is_primary: + destination_link.is_primary = True + update_fields.append("is_primary") + destination_link.save(update_fields=update_fields) + source_link.delete() + collapsed += 1 + return moved, collapsed + + +def _merge_contact_methods(source: Person, destination: Person) -> tuple[int, int]: + destination_methods = { + (method.method_type, method.normalized_value): method + for method in ContactMethod.objects.filter(person=destination) + } + destination_primary_types = set( + ContactMethod.objects.filter(person=destination, is_primary=True).values_list( + "method_type", flat=True + ) + ) + moved = 0 + collapsed = 0 + now = timezone.now() + for source_method in ContactMethod.objects.filter(person=source).order_by("id"): + key = (source_method.method_type, source_method.normalized_value) + destination_method = destination_methods.get(key) + if destination_method is not None: + update_fields = ["updated_at"] + if not destination_method.label and source_method.label: + destination_method.label = source_method.label + update_fields.append("label") + if ( + source_method.is_primary + and source_method.method_type not in destination_primary_types + ): + destination_method.is_primary = True + destination_primary_types.add(source_method.method_type) + update_fields.append("is_primary") + destination_method.save(update_fields=update_fields) + source_method.delete() + collapsed += 1 + continue + + moving_primary = source_method.is_primary + if source_method.method_type in destination_primary_types: + moving_primary = False + elif moving_primary: + destination_primary_types.add(source_method.method_type) + + # The ownership change is the repair itself. Bypass save()'s assignment + # guard so grandfathered phone data cannot prevent its documented remedy. + ContactMethod.objects.filter(pk=source_method.pk).update( + person=destination, + is_primary=moving_primary, + updated_at=now, + ) + destination_methods[key] = source_method + moved += 1 + return moved, collapsed + + +def merge_people( + source_id: UUID, + destination_id: UUID, + staff: Staff, +) -> PersonMergeCounts: + """Merge ``source_id`` into ``destination_id`` and delete the source row.""" + if source_id == destination_id: + raise ValueError("Source and destination Person must be different") + + from apps.crm.models import PhoneCallRecord + from apps.job.models import Job + + try: + with transaction.atomic(): + people = { + person.id: person + for person in Person.objects.select_for_update() + .filter(id__in=[source_id, destination_id]) + .order_by("id") + } + source = people.get(source_id) + if source is None: + raise ValueError(f"Source Person {source_id} does not exist") + destination = people.get(destination_id) + if destination is None: + raise ValueError(f"Destination Person {destination_id} does not exist") + + update_fields = ["updated_at"] + if not destination.email and source.email: + destination.email = source.email + update_fields.append("email") + if source.is_active and not destination.is_active: + destination.is_active = True + update_fields.append("is_active") + destination.save(update_fields=update_fields) + + links_moved, links_collapsed = _merge_links(source, destination) + methods_moved, methods_collapsed = _merge_contact_methods( + source, destination + ) + + jobs_moved = 0 + for job in Job.objects.filter(person=source).order_by("id"): + job.person = destination + job.save(staff=staff, update_fields=["person"]) + jobs_moved += 1 + + calls_moved = PhoneCallRecord.objects.filter(person=source).update( + person=destination + ) + source.delete() + return { + "jobs": jobs_moved, + "phone_calls": calls_moved, + "links_moved": links_moved, + "links_collapsed": links_collapsed, + "contact_methods_moved": methods_moved, + "contact_methods_collapsed": methods_collapsed, + } + except AlreadyLoggedException: + raise + except ValueError: + raise + except Exception as exc: + error = persist_app_error(exc) + raise AlreadyLoggedException(exc, error.id) from exc diff --git a/apps/company/services/person_service.py b/apps/company/services/person_service.py new file mode 100644 index 000000000..61d759d8c --- /dev/null +++ b/apps/company/services/person_service.py @@ -0,0 +1,377 @@ +"""First-class Person directory and company-relationship operations.""" + +from typing import NotRequired, TypedDict +from uuid import UUID + +from django.core.exceptions import ValidationError as DjangoValidationError +from django.db import transaction +from django.db.models import Prefetch, Q, QuerySet + +from apps.company.models import Company, CompanyPersonLink, ContactMethod, Person +from apps.workflow.services.error_persistence import persist_app_error + + +class PersonCompanyLinkData(TypedDict): + company_id: str + company_name: str + position: str | None + is_primary: bool + notes: str | None + is_active: bool + + +class PhonePersonMatch(TypedDict): + person_id: str + person_name: str + person_email: str | None + company_links: list[PersonCompanyLinkData] + + +class PhoneCompanyOwner(TypedDict): + company_id: str + company_name: str + + +class PhoneOwnershipResult(TypedDict): + status: str + normalized_phone: str + can_create_person: bool + people: list[PhonePersonMatch] + companies: list[PhoneCompanyOwner] + + +class NewPersonData(TypedDict): + name: str + email: NotRequired[str | None] + phone: NotRequired[str | None] + position: NotRequired[str | None] + notes: NotRequired[str | None] + is_primary: NotRequired[bool] + + +class CompanyLinkData(TypedDict): + position: str | None + notes: str | None + is_primary: bool + + +class PersonPhoneConflictError(Exception): + def __init__(self, ownership: PhoneOwnershipResult) -> None: + self.ownership = ownership + super().__init__("Phone number is already owned by another CRM record") + + +class PersonDirectoryService: + @staticmethod + def search(query: str, *, include_archived: bool = False) -> QuerySet[Person]: + base = ( + Person.objects.all() + if include_archived + else Person.objects.filter(is_active=True) + ) + people = base.annotate( + primary_phone=ContactMethod.primary_phone_annotation( + owner="person", outer_ref="pk" + ) + ).prefetch_related( + Prefetch( + "company_links", + queryset=CompanyPersonLink.objects.filter( + is_active=True + ).select_related("company"), + ) + ) + search = query.strip() + if not search: + return people.order_by("name", "id") + + normalized_phone = ContactMethod.normalize_phone(search) + filters = ( + Q(name__icontains=search) + | Q(email__icontains=search) + | Q( + company_links__is_active=True, + company_links__company__name__icontains=search, + ) + ) + if normalized_phone: + filters |= Q( + contact_methods__method_type=ContactMethod.MethodType.PHONE, + contact_methods__normalized_value__contains=normalized_phone.lstrip( + "+" + ), + ) + return people.filter(filters).distinct().order_by("name", "id") + + @staticmethod + def company_links(person: Person) -> list[PersonCompanyLinkData]: + prefetched = getattr(person, "_prefetched_objects_cache", {}).get( + "company_links" + ) + if prefetched is None: + links = list( + CompanyPersonLink.objects.filter(person=person).select_related( + "company" + ) + ) + else: + links = list(person.company_links.all()) + links.sort( + key=lambda link: ( + not link.is_active, + not link.is_primary, + link.company.name, + ) + ) + return [ + { + "company_id": str(link.company_id), + "company_name": link.company.name, + "position": link.position, + "is_primary": link.is_primary, + "notes": link.notes, + "is_active": link.is_active, + } + for link in links + ] + + +def classify_phone_ownership( + *, company: Company, raw_phone: str +) -> PhoneOwnershipResult: + normalized = ContactMethod.normalize_phone(raw_phone) + if not normalized: + raise ValueError("Phone number must contain at least one digit") + + from apps.crm.models import PhoneEndpoint + + if PhoneEndpoint.objects.filter( + normalized_number=normalized, is_active=True + ).exists(): + return { + "status": "internal", + "normalized_phone": normalized, + "can_create_person": False, + "people": [], + "companies": [], + } + + methods = list( + ContactMethod.objects.filter( + method_type=ContactMethod.MethodType.PHONE, + normalized_value=normalized, + ) + .select_related("company", "person") + .prefetch_related( + Prefetch( + "person__company_links", + queryset=CompanyPersonLink.objects.select_related("company"), + ) + ) + .order_by("id") + ) + people_by_id: dict[UUID, PhonePersonMatch] = {} + companies_by_id: dict[UUID, PhoneCompanyOwner] = {} + for method in methods: + if method.person_id is not None: + person = method.person + if person is None: + continue + people_by_id.setdefault( + person.id, + { + "person_id": str(person.id), + "person_name": person.name, + "person_email": person.email, + "company_links": PersonDirectoryService.company_links(person), + }, + ) + elif method.company_id is not None: + owner = method.company + if owner is None: + raise RuntimeError(f"Contact method {method.id} has no company") + companies_by_id.setdefault( + owner.id, + {"company_id": str(owner.id), "company_name": owner.name}, + ) + else: + raise RuntimeError(f"Contact method {method.id} has no owner") + + conflict = ContactMethod.conflicting_company(normalized, {company.id}) + people = sorted(people_by_id.values(), key=lambda row: row["person_name"]) + companies = sorted(companies_by_id.values(), key=lambda row: row["company_name"]) + if people: + status = "people" + elif conflict is not None: + status = "company" + else: + status = "available" + return { + "status": status, + "normalized_phone": normalized, + "can_create_person": conflict is None, + "people": people, + "companies": companies, + } + + +def _person_phone_numbers(person: Person) -> list[str]: + return list( + person.contact_methods.filter( + method_type=ContactMethod.MethodType.PHONE + ).values_list("normalized_value", flat=True) + ) + + +def _schedule_person_phone_rematch(person: Person) -> None: + numbers = sorted(set(_person_phone_numbers(person))) + if not numbers: + return + from apps.crm.tasks import rematch_phone_calls_task + + transaction.on_commit(lambda: rematch_phone_calls_task.delay(numbers)) + + +def _create_person_link( + *, company: Company, data: NewPersonData, raw_phone: str | None +) -> CompanyPersonLink: + with transaction.atomic(): + Company.objects.select_for_update().only("id").get(pk=company.pk) + person = Person.objects.create( + name=data["name"].strip(), + email=data.get("email"), + is_active=True, + ) + has_active_people = CompanyPersonLink.objects.filter( + company=company, is_active=True + ).exists() + link = CompanyPersonLink.objects.create( + company=company, + person=person, + position=data.get("position"), + notes=data.get("notes"), + is_primary=data.get("is_primary", False) or not has_active_people, + is_active=True, + ) + if raw_phone is not None: + from apps.company.serializers import set_primary_phone + + set_primary_phone(person, raw_phone) + return link + + +def create_person_for_company( + *, company: Company, data: NewPersonData +) -> CompanyPersonLink: + raw_phone = data.get("phone") + if not raw_phone: + return _create_person_link(company=company, data=data, raw_phone=None) + + ownership = classify_phone_ownership(company=company, raw_phone=raw_phone) + if not ownership["can_create_person"]: + raise PersonPhoneConflictError(ownership) + + try: + return _create_person_link(company=company, data=data, raw_phone=raw_phone) + except DjangoValidationError as exc: + persist_app_error(exc) + ownership = classify_phone_ownership(company=company, raw_phone=raw_phone) + raise PersonPhoneConflictError(ownership) from exc + + +def put_company_link( + *, person: Person, company: Company, data: CompanyLinkData +) -> CompanyPersonLink: + with transaction.atomic(): + Company.objects.select_for_update().only("id").get(pk=company.pk) + existing = ( + CompanyPersonLink.objects.select_for_update() + .filter(person=person, company=company) + .first() + ) + other_active_exists = ( + CompanyPersonLink.objects.filter(company=company, is_active=True) + .exclude(person=person) + .exists() + ) + is_primary = data["is_primary"] or not other_active_exists + if existing is None: + link = CompanyPersonLink.objects.create( + person=person, + company=company, + position=data["position"], + notes=data["notes"], + is_primary=is_primary, + is_active=True, + ) + else: + existing.position = data["position"] + existing.notes = data["notes"] + existing.is_primary = is_primary + existing.is_active = True + existing.save( + update_fields=[ + "position", + "notes", + "is_primary", + "is_active", + "updated_at", + ] + ) + link = existing + if not person.is_active: + person.is_active = True + person.save(update_fields=["is_active", "updated_at"]) + _schedule_person_phone_rematch(person) + return link + + +def archive_person(*, person: Person) -> None: + """Retire a person everywhere: deactivate all active links, then archive.""" + with transaction.atomic(): + locked = Person.objects.select_for_update().get(pk=person.pk) + CompanyPersonLink.objects.filter(person=locked, is_active=True).update( + is_active=False, is_primary=False + ) + if locked.is_active: + locked.is_active = False + locked.save(update_fields=["is_active", "updated_at"]) + _schedule_person_phone_rematch(locked) + + +def remove_company_link(*, person: Person, company: Company) -> None: + with transaction.atomic(): + link = ( + CompanyPersonLink.objects.select_for_update() + .filter(person=person, company=company, is_active=True) + .first() + ) + if link is None: + raise ValueError("Active company link not found") + projected_company_ids = set( + person.company_links.filter(is_active=True) + .exclude(company=company) + .values_list("company_id", flat=True) + ) + phones = person.contact_methods.filter( + method_type=ContactMethod.MethodType.PHONE + ) + for method in phones: + conflict = ContactMethod.conflicting_company( + method.normalized_value, + projected_company_ids, + exclude_id=method.id, + ) + if conflict is not None: + raise ValueError( + f"Removing this link would make {method.value} conflict with " + f"{conflict.owner_display_name()}" + ) + link.is_active = False + link.is_primary = False + link.save(update_fields=["is_active", "is_primary", "updated_at"]) + if not projected_company_ids and person.is_active: + # Removing the person's last active company link retires them. + person.is_active = False + person.save(update_fields=["is_active", "updated_at"]) + _schedule_person_phone_rematch(person) 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..f7d5271d5 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,70 +234,70 @@ 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( event_type=SearchTelemetryEvent.EventType.SEARCH, - domain=SearchTelemetryEvent.Domain.CLIENT, + domain=SearchTelemetryEvent.Domain.COMPANY, ) 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 62% rename from apps/client/tests/test_client_invoice_summary.py rename to apps/company/tests/test_company_invoice_summary.py index 04a4d1e78..e684610ea 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, ClientContactMethod -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, ContactMethod +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,33 +72,33 @@ 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() .annotate( - phone=ClientContactMethod.primary_phone_annotation( - owner="client", outer_ref="pk" + phone=ContactMethod.primary_phone_annotation( + owner="company", outer_ref="pk" ) ) .filter(id__in=[with_invoices.id, without_invoices.id]) @@ -106,7 +106,7 @@ def test_formatting_annotated_clients_does_not_query_invoice_metrics(db): ) 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 == [ @@ -139,12 +139,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() @@ -153,13 +153,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, @@ -169,18 +169,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"} @@ -191,17 +191,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 59% rename from apps/client/tests/test_client_merge_service.py rename to apps/company/tests/test_company_merge_service.py index 4035412de..4a098811c 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,11 @@ 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 Company, CompanyPersonLink, ContactMethod, Person +from apps.company.services.company_merge_service import ( + merge_companies, + reassign_company_fk_records, +) from apps.crm.models import PhoneCallRecord from apps.job.models import Job, JobEvent from apps.purchasing.models import PurchaseOrder @@ -28,32 +31,40 @@ # --------------------------------------------------------------------------- -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(), ) +def make_link(company: Company, name: str) -> CompanyPersonLink: + person = Person.objects.create(name=name) + return CompanyPersonLink.objects.create( + company=company, + person=person, + ) + + _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 +75,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 +112,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 +124,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, *, person: Person | None = None ) -> PhoneCallRecord: call_datetime = timezone.now() return PhoneCallRecord.objects.create( @@ -129,8 +140,8 @@ def make_phone_call( call_time=call_datetime.time(), origin="+6421555123", destination="+6496365131", - client=client, - contact=contact, + company=company, + person=person, raw_json={}, ) @@ -144,20 +155,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 +176,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 +189,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 +202,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 +215,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 +228,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 +242,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 +255,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 +268,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 +294,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 +308,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 +339,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") - method = ClientContactMethod.objects.create( - contact=contact, - method_type=ClientContactMethod.MethodType.PHONE, + contact = make_link(self.source, "Jane Smith") + method = ContactMethod.objects.create( + person=contact.person, + method_type=ContactMethod.MethodType.PHONE, value="021 555 123", ) - client_method = ClientContactMethod.objects.create( - client=self.source, - method_type=ClientContactMethod.MethodType.PHONE, + company_method = ContactMethod.objects.create( + company=self.source, + method_type=ContactMethod.MethodType.PHONE, value="021 555 124", ) - contact_call = make_phone_call(self.source, contact=contact) - client_call = make_phone_call(self.source) + contact_call = make_phone_call(self.source, person=contact.person) + 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,37 +361,31 @@ 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() - - self.assertEqual(contact.client_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(contact_call.contact_id, contact.id) - self.assertEqual(client_call.client_id, self.destination.id) + company_call.refresh_from_db() + + self.assertEqual(contact.company_id, self.destination.id) + self.assertEqual(method.person_id, contact.person_id) + self.assertEqual(company_method.company_id, self.destination.id) + self.assertEqual(contact_call.company_id, self.destination.id) + self.assertEqual(contact_call.person_id, contact.person_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, - name="Jane Smith", - ) - destination_contact = ClientContact.objects.create( - client=self.destination, - name="Jane Smith", - ) - method = ClientContactMethod.objects.create( - contact=source_contact, - method_type=ClientContactMethod.MethodType.PHONE, + source_contact = make_link(self.source, "Jane Smith") + make_link(self.destination, "Jane Smith") + method = ContactMethod.objects.create( + person=source_contact.person, + method_type=ContactMethod.MethodType.PHONE, value="021 555 123", ) - call = make_phone_call(self.source, contact=source_contact) + call = make_phone_call(self.source, person=source_contact.person) - counts = reassign_client_fk_records( + counts = reassign_company_fk_records( self.source, self.destination, self.test_staff, @@ -389,17 +394,18 @@ def test_exact_name_contact_conflict_merges_methods_and_calls(self) -> None: method.refresh_from_db() call.refresh_from_db() - 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.contact_id, destination_contact.id) + source_contact.refresh_from_db() + self.assertEqual(source_contact.company_id, self.destination.id) + self.assertEqual(method.person_id, source_contact.person_id) + self.assertEqual(call.company_id, self.destination.id) + self.assertEqual(call.person_id, source_contact.person_id) self.assertEqual(counts["contacts"], 1) - self.assertEqual(counts["contact_methods"], 1) + self.assertEqual(counts["contact_methods"], 0) self.assertEqual(counts["phone_calls"], 1) 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,82 +413,80 @@ 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) -> ContactMethod: + """Insert a cross-company duplicate bypassing the save() guard, exactly as pre-guard legacy rows (and migration 0023 twins) exist.""" - legacy = ClientContactMethod( - client=client, - method_type=ClientContactMethod.MethodType.PHONE, + legacy = ContactMethod( + company=company, + method_type=ContactMethod.MethodType.PHONE, value=self.NUMBER, ) - legacy.normalized_value = ClientContactMethod.normalize_phone(self.NUMBER) - ClientContactMethod.objects.bulk_create([legacy]) + legacy.normalized_value = ContactMethod.normalize_phone(self.NUMBER) + ContactMethod.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, - method_type=ClientContactMethod.MethodType.PHONE, + contact = make_link(self.source, "Jane Smith") + company_method = ContactMethod.objects.create( + company=self.source, + method_type=ContactMethod.MethodType.PHONE, value=self.NUMBER, ) - contact_method = ClientContactMethod.objects.create( - contact=contact, - method_type=ClientContactMethod.MethodType.PHONE, + contact_method = ContactMethod.objects.create( + person=contact.person, + method_type=ContactMethod.MethodType.PHONE, 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(contact_method.contact_id, contact.id) + self.assertEqual(company_method.company_id, self.destination.id) + self.assertEqual(contact.company_id, self.destination.id) + self.assertEqual(contact_method.person_id, contact.person_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, - method_type=ClientContactMethod.MethodType.PHONE, + source_method = ContactMethod.objects.create( + company=self.source, + method_type=ContactMethod.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, - method_type=ClientContactMethod.MethodType.PHONE, + destination_method = ContactMethod.objects.create( + company=self.destination, + method_type=ContactMethod.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 ) - self.assertFalse( - ClientContactMethod.objects.filter(id=source_method.id).exists() - ) - remaining = ClientContactMethod.objects.filter( - normalized_value=ClientContactMethod.normalize_phone(self.NUMBER) + self.assertFalse(ContactMethod.objects.filter(id=source_method.id).exists()) + remaining = ContactMethod.objects.filter( + normalized_value=ContactMethod.normalize_phone(self.NUMBER) ) self.assertEqual(remaining.count(), 1) self.assertEqual(remaining.get().id, destination_method.id) @@ -491,24 +495,24 @@ def test_duplicate_of_destination_number_is_dropped_not_duplicated(self) -> None 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, - method_type=ClientContactMethod.MethodType.PHONE, + destination_primary = ContactMethod.objects.create( + company=self.destination, + method_type=ContactMethod.MethodType.PHONE, value="021 555 999", is_primary=True, ) - source_primary = ClientContactMethod.objects.create( - client=self.source, - method_type=ClientContactMethod.MethodType.PHONE, + source_primary = ContactMethod.objects.create( + company=self.source, + method_type=ContactMethod.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 +524,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 +535,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,22 +549,22 @@ 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() self.assertEqual( events_after, events_before + 1, - "Job reassignment should create a client_changed JobEvent", + "Job reassignment should create a company_changed JobEvent", ) 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.event_type, "company_changed") + self.assertEqual(latest.delta_after["company_id"], str(self.destination.id)) # --------------------------------------------------------------------------- @@ -570,45 +574,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 +648,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 +659,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 +672,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( @@ -688,3 +692,60 @@ def test_zero_records_returns_all_zero_counts(self) -> None: "scrape_jobs": 0, }, ) + + +class MergeCompaniesTests(ReassignFKBaseCase): + def test_preserves_source_classification_and_blank_destination_details( + self, + ) -> None: + self.source.is_account_customer = True + self.source.is_supplier = True + self.source.email = "accounts@example.com" + self.source.address = "1 Example Street" + self.source.save( + update_fields=["is_account_customer", "is_supplier", "email", "address"] + ) + + merge_companies(self.source.id, self.destination.id, self.test_staff) + + self.destination.refresh_from_db() + self.assertTrue(self.destination.is_account_customer) + self.assertTrue(self.destination.is_supplier) + self.assertEqual(self.destination.email, "accounts@example.com") + self.assertEqual(self.destination.address, "1 Example Street") + + def test_retains_disabled_xero_tombstone_and_moves_history(self) -> None: + self.source.xero_contact_id = "source-xero-id" + self.source.save(update_fields=["xero_contact_id"]) + job = make_job(self.source, self.test_staff) + + counts = merge_companies( + self.source.id, + self.destination.id, + self.test_staff, + ) + + self.source.refresh_from_db() + job.refresh_from_db() + self.assertEqual(self.source.merged_into_id, self.destination.id) + self.assertFalse(self.source.allow_jobs) + self.assertEqual(self.source.xero_contact_id, "source-xero-id") + self.assertEqual(job.company_id, self.destination.id) + self.assertEqual(counts["jobs"], 1) + + def test_rolls_back_tombstone_when_reassignment_fails(self) -> None: + with patch( + "apps.company.services.company_merge_service." + "reassign_company_fk_records", + side_effect=RuntimeError("failed"), + ): + with self.assertRaisesRegex(RuntimeError, "failed"): + merge_companies( + self.source.id, + self.destination.id, + self.test_staff, + ) + + self.source.refresh_from_db() + self.assertIsNone(self.source.merged_into_id) + self.assertTrue(self.source.allow_jobs) diff --git a/apps/company/tests/test_contact_methods.py b/apps/company/tests/test_contact_methods.py new file mode 100644 index 000000000..f1f159c4c --- /dev/null +++ b/apps/company/tests/test_contact_methods.py @@ -0,0 +1,1218 @@ +import uuid +from typing import TYPE_CHECKING +from unittest.mock import MagicMock, patch + +from django.conf import settings +from django.core.exceptions import ValidationError +from django.db import connection +from django.test import TestCase +from django.test.utils import CaptureQueriesContext +from django.utils import timezone +from nplusone.core.profiler import Profiler +from nplusone.ext.django.patch import apply_patches + +if TYPE_CHECKING: + from apps.job.models import Job + +from apps.company.models import Company, CompanyPersonLink, ContactMethod, Person +from apps.company.services.company_rest_service import CompanyRestService +from apps.crm.models import PhoneCallRecord +from apps.crm.services.phone_call_service import rematch_calls_for_numbers +from apps.testing import BaseAPITestCase, BaseTestCase +from apps.workflow.accounting.types import ContactResult +from apps.workflow.exceptions import AlreadyLoggedException +from apps.workflow.models import AppError + + +def _link(company: Company, name: str, email: str | None = None) -> CompanyPersonLink: + person = Person.objects.create(name=name, email=email) + return CompanyPersonLink.objects.create( + company=company, + person=person, + ) + + +class ContactMethodTests(TestCase): + 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.""" + self.assertEqual( + ContactMethod.normalize_phone("+64 9 636 5131"), + "+6496365131", + ) + self.assertEqual( + ContactMethod.normalize_phone("09 636 5131"), + "+6496365131", + ) + + 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 = ContactMethod.objects.create( + company=company, + method_type=ContactMethod.MethodType.PHONE, + value="09 111 1111", + is_primary=True, + ) + second = ContactMethod.objects.create( + company=company, + method_type=ContactMethod.MethodType.PHONE, + value="09 222 2222", + is_primary=True, + ) + + first.refresh_from_db() + second.refresh_from_db() + + self.assertFalse(first.is_primary) + self.assertTrue(second.is_primary) + + 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 = _link(company, "Jane Smith") + on_company = ContactMethod.objects.create( + company=company, + method_type=ContactMethod.MethodType.PHONE, + value="021 111 111", + ) + on_contact = ContactMethod.objects.create( + person=contact.person, + method_type=ContactMethod.MethodType.PHONE, + value="021 111 111", + ) + + 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_company(self) -> None: + """Two contacts of one company can share a number (one effective company).""" + company = self._company() + first_contact = _link(company, "A") + second_contact = _link(company, "B") + ContactMethod.objects.create( + person=first_contact.person, + method_type=ContactMethod.MethodType.PHONE, + value="021 111 111", + ) + on_second = ContactMethod.objects.create( + person=second_contact.person, + method_type=ContactMethod.MethodType.PHONE, + value="021 111 111", + ) + + self.assertIsNotNone(on_second.pk) + + 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") + ContactMethod.objects.create( + company=company_a, + method_type=ContactMethod.MethodType.PHONE, + value="021 111 111", + ) + with self.assertRaises(ValidationError): + ContactMethod.objects.create( + company=company_b, + method_type=ContactMethod.MethodType.PHONE, + value="021 111 111", + ) + + 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 = _link(company_a, "A") + contact_b = _link(company_b, "B") + ContactMethod.objects.create( + person=contact_a.person, + method_type=ContactMethod.MethodType.PHONE, + value="021 111 111", + ) + with self.assertRaises(ValidationError): + ContactMethod.objects.create( + person=contact_b.person, + method_type=ContactMethod.MethodType.PHONE, + value="021 111 111", + ) + + 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") + ContactMethod.objects.create( + company=company_a, + method_type=ContactMethod.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 = ContactMethod( + company=company_b, + method_type=ContactMethod.MethodType.PHONE, + value="021 111 111", + ) + legacy.normalized_value = ContactMethod.normalize_phone("021 111 111") + ContactMethod.objects.bulk_create([legacy]) + + legacy.refresh_from_db() + legacy.label = "Mobile" + legacy.save() # association unchanged -> grandfathered, must not raise + + legacy.refresh_from_db() + self.assertEqual(legacy.label, "Mobile") + + 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") + ContactMethod.objects.create( + company=company_a, + method_type=ContactMethod.MethodType.PHONE, + value="021 111 111", + ) + moving = ContactMethod.objects.create( + company=company_b, + method_type=ContactMethod.MethodType.PHONE, + value="021 222 222", + ) + + 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.""" + company = self._company() + contact = _link(company, "Jane Smith") + first = ContactMethod.objects.create( + person=contact.person, + method_type=ContactMethod.MethodType.PHONE, + value="021 111 111", + is_primary=True, + ) + second = ContactMethod.objects.create( + person=contact.person, + method_type=ContactMethod.MethodType.PHONE, + value="021 222 222", + is_primary=True, + ) + + first.refresh_from_db() + second.refresh_from_db() + + self.assertFalse(first.is_primary) + self.assertTrue(second.is_primary) + + 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.""" + company = self._company("Acme Ltd") + method = ContactMethod.objects.create( + company=company, + method_type=ContactMethod.MethodType.PHONE, + value="021 111 111", + ) + + method.value = "021 222 222" + method.save(update_fields=["value"]) + + method.refresh_from_db() + self.assertEqual( + method.normalized_value, + ContactMethod.normalize_phone("021 222 222"), + ) + + +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: + company = Company.objects.create( + name="Acme Ltd", xero_last_modified=timezone.now() + ) + ContactMethod.objects.create( + company=company, + method_type=ContactMethod.MethodType.PHONE, + value="09 111 1111", + ) + ContactMethod.objects.create( + company=company, + method_type=ContactMethod.MethodType.PHONE, + value="09 222 2222", + is_primary=True, + ) + + self.assertEqual(company.primary_phone_value(), "09 222 2222") + + def test_returns_empty_string_when_no_phone_methods(self) -> None: + company = Company.objects.create( + name="Phoneless Ltd", xero_last_modified=timezone.now() + ) + ContactMethod.objects.create( + company=company, + method_type=ContactMethod.MethodType.EMAIL, + value="office@example.com", + ) + + self.assertEqual(company.primary_phone_value(), "") + + +class PrimaryPhoneAnnotationTests(TestCase): + """Guards the shared queryset annotation every phone-bearing payload uses.""" + + def _company(self, name: str = "Acme Ltd") -> Company: + return Company.objects.create(name=name, xero_last_modified=timezone.now()) + + def test_company_annotation_prefers_primary_over_label_order(self) -> None: + company = self._company() + ContactMethod.objects.create( + company=company, + method_type=ContactMethod.MethodType.PHONE, + value="09 111 1111", + label="AAA sorts first", + ) + ContactMethod.objects.create( + company=company, + method_type=ContactMethod.MethodType.PHONE, + value="09 222 2222", + label="ZZZ sorts last", + is_primary=True, + ) + + annotated = Company.objects.annotate( + phone=ContactMethod.primary_phone_annotation( + owner="company", outer_ref="pk" + ) + ).get(pk=company.pk) + + self.assertEqual(annotated.phone, "09 222 2222") + + def test_company_annotation_is_empty_string_without_phones(self) -> None: + company = self._company("Phoneless Ltd") + ContactMethod.objects.create( + company=company, + method_type=ContactMethod.MethodType.EMAIL, + value="office@example.com", + ) + + annotated = Company.objects.annotate( + phone=ContactMethod.primary_phone_annotation( + owner="company", outer_ref="pk" + ) + ).get(pk=company.pk) + + self.assertEqual(annotated.phone, "") + + def test_contact_annotation_returns_contact_primary_phone(self) -> None: + company = self._company() + contact = _link(company, "Jane Smith") + ContactMethod.objects.create( + person=contact.person, + method_type=ContactMethod.MethodType.PHONE, + value="021 111 111", + ) + ContactMethod.objects.create( + person=contact.person, + method_type=ContactMethod.MethodType.PHONE, + value="021 222 222", + is_primary=True, + ) + + annotated = CompanyPersonLink.objects.annotate( + phone=ContactMethod.primary_phone_for_link_annotation() + ).get(pk=contact.pk) + + self.assertEqual(annotated.phone, "021 222 222") + + +class UpdateJobContactTests(BaseTestCase): + """Guards that reassigning a job's contact persists to the job record.""" + + def _job_with_contact(self) -> "tuple[Job, Company, CompanyPersonLink]": + from apps.job.models import Job + from apps.workflow.models import XeroPayItem + + company = Company.objects.create( + name="Acme Ltd", xero_last_modified=timezone.now() + ) + contact = _link(company, "Jane Smith") + job: Job = Job.objects.create( + name="Contact Assignment Job", + company=company, + person=contact.person, + created_by=self.test_staff, + default_xero_pay_item=XeroPayItem.get_ordinary_time(), + staff=self.test_staff, + ) + return job, company, contact + + def test_update_job_person_persists_new_person(self) -> None: + job, company, _ = self._job_with_contact() + new_contact = _link(company, "Bob Brown") + + CompanyRestService.update_job_person( + job.id, {"id": str(new_contact.person_id)}, self.test_staff + ) + + job.refresh_from_db() + self.assertEqual(job.person_id, new_contact.person_id) + + def test_update_job_person_missing_person_is_business_error(self) -> None: + job, _company, _ = self._job_with_contact() + app_error_count = AppError.objects.count() + + with self.assertRaises(ValueError): + CompanyRestService.update_job_person( + job.id, {"id": str(uuid.uuid4())}, self.test_staff + ) + + self.assertEqual(AppError.objects.count(), app_error_count) + + +class CompanyListPhoneTests(TestCase): + """Guards the Phone column of the companies list (restored after the + ContactMethod migration dropped it).""" + + def _company_with_phone(self, name: str, phone: str) -> Company: + company = Company.objects.create(name=name, xero_last_modified=timezone.now()) + ContactMethod.objects.create( + company=company, + method_type=ContactMethod.MethodType.PHONE, + value=phone, + is_primary=True, + ) + return company + + def _lazy_phone_queries(self, captured: CaptureQueriesContext) -> list[str]: + return [ + q["sql"] + for q in captured.captured_queries + if q["sql"].startswith('SELECT "company_contactmethod"') + ] + + def test_list_companies_rows_include_phone(self) -> None: + self._company_with_phone("Acme Ltd", "09 111 1111") + Company.objects.create(name="Phoneless Ltd", xero_last_modified=timezone.now()) + + with CaptureQueriesContext(connection) as captured: + result = CompanyRestService.list_companies(page=1, page_size=10) + + phones = {row["name"]: row["phone"] for row in result["results"]} + self.assertEqual(phones["Acme Ltd"], "09 111 1111") + self.assertEqual(phones["Phoneless Ltd"], "") + self.assertEqual(self._lazy_phone_queries(captured), []) + + def test_searched_clients_include_phone(self) -> None: + self._company_with_phone("Acme Ltd", "09 111 1111") + + result = CompanyRestService.list_companies(query="Acme", page=1, page_size=10) + + self.assertEqual(result["results"][0]["phone"], "09 111 1111") + + +class CompanyPersonLinkApiPhoneTests(BaseAPITestCase): + """Guards company People and Person contact-method API integration.""" + + def setUp(self) -> None: + super().setUp() + self.test_staff.is_office_staff = True + self.test_staff.save(update_fields=["is_office_staff"]) + self.client.force_authenticate(user=self.test_staff) + self.job_client = Company.objects.create( + name="Acme Ltd", xero_last_modified=timezone.now() + ) + + def _contact( + self, name: str = "Jane Smith", phone: str | None = None + ) -> CompanyPersonLink: + contact = _link(self.job_client, name) + if phone is not None: + ContactMethod.objects.create( + person=contact.person, + method_type=ContactMethod.MethodType.PHONE, + value=phone, + is_primary=True, + ) + return contact + + def test_list_includes_contact_phone_without_lazy_queries(self) -> None: + self._contact("Jane Smith", phone="021 111 111") + self._contact("No Phone") + + with CaptureQueriesContext(connection) as captured: + response = self.client.get(f"/api/companies/{self.job_client.id}/people/") + + self.assertEqual(response.status_code, 200) + phones = {row["person_name"]: row["primary_phone"] for row in response.json()} + self.assertEqual(phones["Jane Smith"], "021 111 111") + self.assertEqual(phones["No Phone"], "") + lazy = [ + q["sql"] + for q in captured.captured_queries + if q["sql"].startswith('SELECT "company_contactmethod"') + ] + self.assertEqual(lazy, []) + + def test_create_contact_with_phone_creates_primary_method(self) -> None: + with patch("apps.crm.tasks.rematch_phone_calls_task.delay") as rematch: + with self.captureOnCommitCallbacks(execute=True): + response = self.client.post( + f"/api/companies/{self.job_client.id}/people/", + { + "name": "Bob Brown", + "phone": "021 222 222", + }, + format="json", + ) + + self.assertEqual(response.status_code, 201) + self.assertEqual(response.json()["primary_phone"], "021 222 222") + method = ContactMethod.objects.get(person__name="Bob Brown") + self.assertEqual(method.method_type, ContactMethod.MethodType.PHONE) + self.assertTrue(method.is_primary) + rematch.assert_called_once_with(["+6421222222"]) + + def test_create_contact_sets_person_fields_and_active_link(self) -> None: + response = self.client.post( + f"/api/companies/{self.job_client.id}/people/", + { + "name": "Bob Brown", + "email": "bob@example.com", + }, + format="json", + ) + + self.assertEqual(response.status_code, 201) + link = CompanyPersonLink.objects.select_related("person").get( + person_id=response.json()["person_id"], company=self.job_client + ) + self.assertEqual(link.person.name, "Bob Brown") + self.assertEqual(link.person.email, "bob@example.com") + self.assertTrue(link.person.is_active) + self.assertTrue(link.is_active) + + def test_update_contact_updates_person_name_and_email(self) -> None: + contact = self._contact("Jane Smith") + + response = self.client.patch( + f"/api/people/{contact.person_id}/", + { + "name": "Jane Brown", + "email": "jane@example.com", + }, + format="json", + ) + + self.assertEqual(response.status_code, 200) + contact.person.refresh_from_db() + self.assertEqual(contact.person.name, "Jane Brown") + self.assertEqual(contact.person.email, "jane@example.com") + + def test_update_phone_updates_existing_primary_method(self) -> None: + contact = self._contact("Jane Smith", phone="021 111 111") + method = contact.person.contact_methods.get() + + with patch("apps.crm.tasks.rematch_phone_calls_task.delay") as rematch: + with self.captureOnCommitCallbacks(execute=True): + response = self.client.patch( + f"/api/people/{contact.person_id}/contact-methods/{method.id}/", + {"value": "021 333 333"}, + format="json", + ) + + self.assertEqual(response.status_code, 200) + self.assertEqual(response.json()["value"], "021 333 333") + method.refresh_from_db() + self.assertEqual(method.value, "021 333 333") + self.assertEqual(contact.person.contact_methods.count(), 1) + rematch.assert_called_once_with(["+6421111111", "+6421333333"]) + + def test_update_secondary_phone_promotes_it(self) -> None: + contact = self._contact("Jane Smith", phone="021 111 111") + secondary = ContactMethod.objects.create( + person=contact.person, + method_type=ContactMethod.MethodType.PHONE, + value="021 444 444", + ) + + response = self.client.patch( + f"/api/people/{contact.person_id}/contact-methods/{secondary.id}/", + {"is_primary": True}, + format="json", + ) + + self.assertEqual(response.status_code, 200) + secondary.refresh_from_db() + self.assertTrue(secondary.is_primary) + self.assertEqual( + contact.person.contact_methods.filter(is_primary=True).count(), 1 + ) + + def test_relationship_update_leaves_phone_methods_untouched(self) -> None: + contact = self._contact("Jane Smith", phone="021 111 111") + + response = self.client.put( + f"/api/people/{contact.person_id}/company-links/{self.job_client.id}/", + {"position": "Manager", "notes": None, "is_primary": False}, + format="json", + ) + + self.assertEqual(response.status_code, 200) + self.assertEqual(contact.person.contact_methods.count(), 1) + + def test_conflicting_phone_returns_400_and_creates_nothing(self) -> None: + other_client = Company.objects.create( + name="Beta Ltd", xero_last_modified=timezone.now() + ) + ContactMethod.objects.create( + company=other_client, + method_type=ContactMethod.MethodType.PHONE, + value="021 555 555", + ) + contact = self._contact("Jane Smith") + + response = self.client.post( + f"/api/people/{contact.person_id}/contact-methods/", + { + "method_type": "phone", + "value": "021 555 555", + "is_primary": True, + }, + format="json", + ) + + self.assertEqual(response.status_code, 400) + self.assertEqual(contact.person.contact_methods.count(), 0) + + +class CompanyUpdatePhoneTests(BaseAPITestCase): + """Guards the phone edit restored on the Edit Company modal's update flow + (company detail's "phone" read via ContactMethod, written through + set_primary_phone).""" + + def setUp(self) -> None: + super().setUp() + self.client.force_authenticate(user=self.test_staff) + + def _company(self, name: str = "Acme Ltd") -> Company: + return Company.objects.create(name=name, xero_last_modified=timezone.now()) + + def _update_url(self, company_id: uuid.UUID) -> str: + return f"/api/companies/{company_id}/update/" + + def test_update_with_new_phone_creates_primary_method(self) -> None: + company = self._company() + + with patch("apps.crm.tasks.rematch_phone_calls_task.delay") as rematch: + with self.captureOnCommitCallbacks(execute=True): + response = self.client.patch( + self._update_url(company.id), + {"phone": "09 111 1111"}, + format="json", + ) + + self.assertEqual(response.status_code, 200) + self.assertEqual(response.json()["company"]["phone"], "09 111 1111") + method = ContactMethod.objects.get(company=company) + self.assertEqual(method.method_type, ContactMethod.MethodType.PHONE) + self.assertEqual(method.value, "09 111 1111") + self.assertTrue(method.is_primary) + rematch.assert_called_once_with([ContactMethod.normalize_phone("09 111 1111")]) + + def test_update_with_existing_secondary_number_promotes_it(self) -> None: + company = self._company() + primary = ContactMethod.objects.create( + company=company, + method_type=ContactMethod.MethodType.PHONE, + value="09 111 1111", + is_primary=True, + ) + secondary = ContactMethod.objects.create( + company=company, + method_type=ContactMethod.MethodType.PHONE, + value="09 222 2222", + ) + + response = self.client.patch( + self._update_url(company.id), {"phone": "09 222 2222"}, format="json" + ) + + self.assertEqual(response.status_code, 200) + primary.refresh_from_db() + secondary.refresh_from_db() + self.assertFalse(primary.is_primary) + self.assertTrue(secondary.is_primary) + self.assertEqual(company.contact_methods.count(), 2) + + def test_update_renumbers_current_primary_when_number_is_new(self) -> None: + """Matches set_primary_phone's contract: a genuinely new number reuses + (renumbers) the existing primary row instead of creating a second + one.""" + company = self._company() + primary = ContactMethod.objects.create( + company=company, + method_type=ContactMethod.MethodType.PHONE, + value="09 111 1111", + is_primary=True, + ) + + response = self.client.patch( + self._update_url(company.id), {"phone": "09 333 3333"}, format="json" + ) + + self.assertEqual(response.status_code, 200) + self.assertEqual(company.contact_methods.count(), 1) + primary.refresh_from_db() + self.assertEqual(primary.value, "09 333 3333") + self.assertTrue(primary.is_primary) + + def test_blank_phone_clears_primary_method(self) -> None: + company = self._company() + ContactMethod.objects.create( + company=company, + method_type=ContactMethod.MethodType.PHONE, + value="09 111 1111", + is_primary=True, + ) + + with patch( + "apps.company.services.company_rest_service.rematch_phone_calls_task.delay" + ) as rematch: + with self.captureOnCommitCallbacks(execute=True): + response = self.client.patch( + self._update_url(company.id), + {"phone": "", "name": "Acme Renamed"}, + format="json", + ) + + self.assertEqual(response.status_code, 200) + self.assertEqual(response.json()["company"]["phone"], "") + self.assertEqual(company.contact_methods.count(), 0) + company.refresh_from_db() + self.assertEqual(company.name, "Acme Renamed") + rematch.assert_called_once_with([ContactMethod.normalize_phone("09 111 1111")]) + + def test_omitted_phone_leaves_methods_untouched(self) -> None: + company = self._company() + ContactMethod.objects.create( + company=company, + method_type=ContactMethod.MethodType.PHONE, + value="09 111 1111", + is_primary=True, + ) + + with patch( + "apps.company.services.company_rest_service.rematch_phone_calls_task.delay" + ) as rematch: + with self.captureOnCommitCallbacks(execute=True): + response = self.client.patch( + self._update_url(company.id), + {"name": "Acme Renamed"}, + format="json", + ) + + self.assertEqual(response.status_code, 200) + self.assertEqual(response.json()["company"]["phone"], "09 111 1111") + self.assertEqual(company.contact_methods.count(), 1) + company.refresh_from_db() + self.assertEqual(company.name, "Acme Renamed") + rematch.assert_not_called() + + def test_conflicting_phone_returns_400_and_rolls_back_update(self) -> None: + """A conflict must not leave the update half-applied: neither the new + name nor a stray contact method should be persisted.""" + other = self._company("Beta Ltd") + ContactMethod.objects.create( + company=other, + method_type=ContactMethod.MethodType.PHONE, + value="09 555 5555", + ) + company = self._company("Acme Ltd") + + response = self.client.patch( + self._update_url(company.id), + {"phone": "09 555 5555", "name": "Acme Renamed"}, + format="json", + ) + + self.assertEqual(response.status_code, 400) + self.assertIn("phone", response.json()["error"].lower()) + company.refresh_from_db() + self.assertEqual(company.name, "Acme Ltd") + self.assertEqual(company.contact_methods.count(), 0) + + def test_get_company_detail_returns_primary_phone(self) -> None: + company = self._company() + ContactMethod.objects.create( + company=company, + method_type=ContactMethod.MethodType.PHONE, + value="09 111 1111", + is_primary=True, + ) + + response = self.client.get(f"/api/companies/{company.id}/") + + self.assertEqual(response.status_code, 200) + self.assertEqual(response.json()["phone"], "09 111 1111") + + def test_get_company_detail_returns_empty_string_without_phone(self) -> None: + company = self._company("Phoneless Ltd") + + response = self.client.get(f"/api/companies/{company.id}/") + + self.assertEqual(response.status_code, 200) + self.assertEqual(response.json()["phone"], "") + + def test_xero_synced_update_applies_phone_before_provider_push(self) -> None: + company = self._company() + company.xero_contact_id = "xero-contact-id" + company.save() + provider = MagicMock() + provider.provider_name = "Xero" + provider.get_valid_token.return_value = {"access_token": "token"} + provider.update_contact.return_value = ContactResult( + success=True, external_id=company.xero_contact_id, name=company.name + ) + + with patch( + "apps.company.services.company_rest_service.get_provider", + return_value=provider, + ): + with patch("apps.crm.tasks.rematch_phone_calls_task.delay") as rematch: + with self.captureOnCommitCallbacks(execute=True): + response = self.client.patch( + self._update_url(company.id), + {"phone": "09 444 4444"}, + format="json", + ) + + self.assertEqual(response.status_code, 200) + self.assertEqual(response.json()["company"]["phone"], "09 444 4444") + pushed_company = provider.update_contact.call_args.args[0] + pushed_contact = pushed_company.get_company_for_xero() + self.assertEqual(pushed_contact.phones[0].phone_number, "09 444 4444") + rematch.assert_called_once_with([ContactMethod.normalize_phone("09 444 4444")]) + + def test_xero_synced_blank_phone_clears_before_provider_push(self) -> None: + company = self._company() + company.xero_contact_id = "xero-contact-id" + company.save() + ContactMethod.objects.create( + company=company, + method_type=ContactMethod.MethodType.PHONE, + value="09 111 1111", + is_primary=True, + ) + provider = MagicMock() + provider.provider_name = "Xero" + provider.get_valid_token.return_value = {"access_token": "token"} + provider.update_contact.return_value = ContactResult( + success=True, external_id=company.xero_contact_id, name=company.name + ) + + with patch( + "apps.company.services.company_rest_service.get_provider", + return_value=provider, + ): + with patch( + "apps.company.services.company_rest_service." + "rematch_phone_calls_task.delay" + ) as rematch: + with self.captureOnCommitCallbacks(execute=True): + response = self.client.patch( + self._update_url(company.id), + {"phone": ""}, + format="json", + ) + + self.assertEqual(response.status_code, 200) + self.assertEqual(response.json()["company"]["phone"], "") + pushed_company = provider.update_contact.call_args.args[0] + pushed_contact = pushed_company.get_company_for_xero() + self.assertIsNone(pushed_contact.phones[0].phone_number) + rematch.assert_called_once_with([ContactMethod.normalize_phone("09 111 1111")]) + + def test_phone_rematch_waits_until_transaction_commit(self) -> None: + company = self._company() + + with patch("apps.crm.tasks.rematch_phone_calls_task.delay") as rematch: + with self.captureOnCommitCallbacks(execute=False) as callbacks: + response = self.client.patch( + self._update_url(company.id), + {"phone": "09 111 1111"}, + format="json", + ) + rematch.assert_not_called() + + self.assertEqual(response.status_code, 200) + self.assertEqual(len(callbacks), 1) + rematch.assert_not_called() + callbacks[0]() + rematch.assert_called_once_with( + [ContactMethod.normalize_phone("09 111 1111")] + ) + + def test_unknown_company_update_returns_404_without_app_error(self) -> None: + before = AppError.objects.count() + + response = self.client.patch( + self._update_url(uuid.uuid4()), + {"name": "Missing Company"}, + format="json", + ) + + self.assertEqual(response.status_code, 404) + self.assertIn("not found", response.json()["error"].lower()) + self.assertEqual(AppError.objects.count(), before) + + def test_validation_error_returns_400_without_app_error(self) -> None: + company = self._company() + before = AppError.objects.count() + + response = self.client.patch( + self._update_url(company.id), + {"email": "not-an-email"}, + format="json", + ) + + self.assertEqual(response.status_code, 400) + self.assertIn("invalid input data", response.json()["error"].lower()) + self.assertEqual(AppError.objects.count(), before) + + def test_xero_update_failure_persists_once_and_returns_500(self) -> None: + company = self._company() + company.xero_contact_id = "xero-contact-id" + company.save() + provider = MagicMock() + provider.provider_name = "Xero" + provider.get_valid_token.return_value = {"access_token": "token"} + provider.update_contact.return_value = ContactResult( + success=False, + error="RemoteDisconnected", + ) + before = AppError.objects.count() + + with patch( + "apps.company.services.company_rest_service.get_provider", + return_value=provider, + ): + response = self.client.patch( + self._update_url(company.id), + {"name": "Acme Renamed"}, + format="json", + ) + + self.assertEqual(response.status_code, 500) + payload = response.json() + self.assertEqual(payload["error"], "Error updating company") + self.assertIn("RemoteDisconnected", payload["details"]) + self.assertEqual(AppError.objects.count(), before + 1) + app_error = AppError.objects.latest("timestamp") + self.assertEqual(payload["error_id"], str(app_error.id)) + + def test_already_logged_update_failure_is_not_persisted_again(self) -> None: + company = self._company() + company.xero_contact_id = "xero-contact-id" + company.save() + before = AppError.objects.count() + app_error = AppError.objects.create( + message="upstream failure", + app="company", + file="company_rest_service.py", + function="_update_company_in_xero", + ) + + with patch( + "apps.company.services.company_rest_service.get_provider", + side_effect=AlreadyLoggedException( + RuntimeError("upstream failure"), + app_error.id, + ), + ): + response = self.client.patch( + self._update_url(company.id), + {"name": "Acme Renamed"}, + format="json", + ) + + self.assertEqual(response.status_code, 500) + self.assertEqual(AppError.objects.count(), before + 1) + self.assertEqual(response.json()["error_id"], str(app_error.id)) + + +class CompanyUpdateProviderFailureTests(BaseTestCase): + """Service-level guard for ADR 0019 on update_company: Xero/system + failures must persist to AppError and surface as AlreadyLoggedException + at the service boundary, never ride the user-facing ValueError (400) + path. Complements the HTTP-level 500/error_id tests above.""" + + def _xero_synced_company(self) -> Company: + return Company.objects.create( + name="Acme Ltd", + xero_contact_id="xero-contact-id", + xero_last_modified=timezone.now(), + ) + + def test_failed_provider_push_persists_app_error(self) -> None: + from apps.company.services.company_rest_service import CompanyRestService + + company = self._xero_synced_company() + provider = MagicMock() + provider.provider_name = "Xero" + provider.get_valid_token.return_value = {"access_token": "token"} + provider.update_contact.return_value = ContactResult( + success=False, error="rate limited" + ) + + with patch( + "apps.company.services.company_rest_service.get_provider", + return_value=provider, + ): + with self.assertRaises(AlreadyLoggedException) as ctx: + CompanyRestService.update_company(company.id, {"name": "Acme Renamed"}) + + self.assertIn("Failed to update company", str(ctx.exception)) + self.assertEqual(AppError.objects.count(), 1) + + def test_missing_provider_token_persists_app_error(self) -> None: + from apps.company.services.company_rest_service import CompanyRestService + + company = self._xero_synced_company() + provider = MagicMock() + provider.provider_name = "Xero" + provider.get_valid_token.return_value = None + + with patch( + "apps.company.services.company_rest_service.get_provider", + return_value=provider, + ): + with self.assertRaises(AlreadyLoggedException) as ctx: + CompanyRestService.update_company(company.id, {"name": "Acme Renamed"}) + + self.assertIn("authentication required", str(ctx.exception)) + self.assertEqual(AppError.objects.count(), 1) + + +class CompanyCreatePhoneTests(BaseTestCase): + """Guards the phone entry restored on the create-company modal.""" + + def _provider(self) -> MagicMock: + provider = MagicMock() + provider.provider_name = "Xero" + provider.get_valid_token.return_value = {"access_token": "token"} + provider.search_contact_by_name.return_value = None + provider.create_contact.return_value = ContactResult( + success=True, external_id="xero-contact-id", name="New Company" + ) + return provider + + def _create(self, provider: MagicMock, **payload: str) -> Company: + data: dict[str, str] = {"name": "New Company", "email": "", "address": ""} + data.update(payload) + with patch( + "apps.company.services.company_rest_service.get_provider", + return_value=provider, + ): + return CompanyRestService.create_company(data) + + def test_create_with_phone_creates_primary_company_method(self) -> None: + with patch("apps.crm.tasks.rematch_phone_calls_task.delay"): + with self.captureOnCommitCallbacks(execute=True): + company = self._create(self._provider(), phone="09 777 7777") + + method = ContactMethod.objects.get(company=company) + self.assertEqual(method.method_type, ContactMethod.MethodType.PHONE) + self.assertEqual(method.value, "09 777 7777") + self.assertTrue(method.is_primary) + + def test_create_without_phone_creates_no_methods(self) -> None: + company = self._create(self._provider()) + + self.assertEqual(ContactMethod.objects.filter(company=company).count(), 0) + + def test_create_with_conflicting_phone_rolls_back_company(self) -> None: + owner = Company.objects.create( + name="Owner Ltd", xero_last_modified=timezone.now() + ) + ContactMethod.objects.create( + company=owner, + method_type=ContactMethod.MethodType.PHONE, + value="09 777 7777", + ) + provider = self._provider() + + with self.assertRaises(AlreadyLoggedException) as ctx: + self._create(provider, phone="09 777 7777") + + self.assertIn("already belongs", str(ctx.exception)) + self.assertFalse(Company.objects.filter(name="New Company").exists()) + provider.create_contact.assert_not_called() + + +class ContactMethodApiTests(BaseAPITestCase): + 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) + company = self._company("Acme Ltd") + for index in range(3): + ContactMethod.objects.create( + company=company, + method_type=ContactMethod.MethodType.PHONE, + value=f"021 555 10{index}", + ) + + response = self.client.get( + "/api/companies/contact-methods/", + {"method_type": "phone", "page_size": "2"}, + ) + + self.assertEqual(response.status_code, 200) + payload = response.json() + self.assertEqual(payload["count"], 3) + self.assertEqual(payload["page"], 1) + self.assertEqual(payload["page_size"], 2) + self.assertEqual(payload["total_pages"], 2) + self.assertEqual(len(payload["results"]), 2) + + def test_list_serializes_person_owned_phone_methods_without_lazy_links( + self, + ) -> None: + """Catches CRM calls page 500s from person-owned phone numbers.""" + self.client.force_authenticate(user=self.test_staff) + company = self._company("Acme Ltd") + contact = _link(company, "Jane Smith") + ContactMethod.objects.create( + company=company, + method_type=ContactMethod.MethodType.PHONE, + value="021 555 100", + ) + ContactMethod.objects.create( + person=contact.person, + method_type=ContactMethod.MethodType.PHONE, + value="021 555 200", + ) + + apply_patches() + with Profiler(whitelist=settings.NPLUSONE_WHITELIST): + response = self.client.get( + "/api/companies/contact-methods/", + {"method_type": "phone", "page_size": "50"}, + ) + + self.assertEqual(response.status_code, 200) + payload = response.json() + person_method = next( + method + for method in payload["results"] + if method["person_name"] == "Jane Smith" + ) + self.assertEqual(person_method["owner_company"], str(company.id)) + self.assertEqual(person_method["company_name"], "Acme Ltd") + + def test_list_page_size_is_capped(self) -> None: + """Catches accidental oversized contact-method responses.""" + self.client.force_authenticate(user=self.test_staff) + company = self._company("Acme Ltd") + for index in range(101): + ContactMethod.objects.create( + company=company, + method_type=ContactMethod.MethodType.PHONE, + value=f"021 555 {index:03d}", + ) + + response = self.client.get( + "/api/companies/contact-methods/", + {"method_type": "phone", "page_size": "250"}, + ) + + self.assertEqual(response.status_code, 200) + payload = response.json() + self.assertEqual(payload["count"], 101) + self.assertEqual(payload["page_size"], 100) + self.assertEqual(len(payload["results"]), 100) + + 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) + company = self._company("Acme Ltd") + method = ContactMethod.objects.create( + company=company, + method_type=ContactMethod.MethodType.PHONE, + value="021 555 100", + ) + 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.company.views.contact_method_viewset." + "rematch_phone_calls_task.delay", + side_effect=rematch_calls_for_numbers, + ) as rematch: + response = self.client.patch( + f"/api/companies/contact-methods/{method.id}/", + {"value": "021 555 200"}, + format="json", + ) + + self.assertEqual(response.status_code, 200) + rematch.assert_called_once_with(["+6421555100", "+6421555200"]) + old_call.refresh_from_db() + new_call.refresh_from_db() + 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) + company = self._company("Acme Ltd") + method = ContactMethod.objects.create( + company=company, + method_type=ContactMethod.MethodType.PHONE, + value="021 555 100", + ) + call = self._call("deleted-number", origin="021 555 100", company=company) + + with patch( + "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/companies/contact-methods/{method.id}/" + ) + + self.assertEqual(response.status_code, 204) + rematch.assert_called_once_with(["+6421555100"]) + call.refresh_from_db() + self.assertIsNone(call.company) + + def _call( + self, + provider_id: str, + *, + origin: str, + company: Company | None, + ) -> PhoneCallRecord: + call_datetime = timezone.now() + return PhoneCallRecord.objects.create( + provider_call_id=f"account:{provider_id}", + account_code="account", + call_datetime=call_datetime, + call_date=timezone.localdate(), + call_time=call_datetime.time(), + origin=origin, + destination="+6496365131", + company=company, + raw_json={ + "id": provider_id, + "calldate": timezone.localdate().isoformat(), + "calltime": call_datetime.time().isoformat(timespec="seconds"), + }, + ) diff --git a/apps/company/tests/test_duplicate_identity_report.py b/apps/company/tests/test_duplicate_identity_report.py new file mode 100644 index 000000000..51b472d31 --- /dev/null +++ b/apps/company/tests/test_duplicate_identity_report.py @@ -0,0 +1,89 @@ +from django.utils import timezone + +from apps.company.models import Company, CompanyPersonLink, ContactMethod, Person +from apps.company.services.duplicate_identity_report import ( + DuplicateIdentityReportService, +) +from apps.testing import BaseTestCase + + +class DuplicateIdentityReportTests(BaseTestCase): + def _company(self, name: str) -> Company: + return Company.objects.create(name=name, xero_last_modified=timezone.now()) + + def _method(self, owner: Company | Person, method_type: str, value: str) -> None: + normalized = ( + value.strip().casefold() + if method_type == ContactMethod.MethodType.EMAIL + else ContactMethod.normalize_phone(value) + ) + method = ContactMethod( + method_type=method_type, + value=value, + normalized_value=normalized, + company=owner if isinstance(owner, Company) else None, + person=owner if isinstance(owner, Person) else None, + ) + ContactMethod.objects.bulk_create([method]) + + def test_exact_company_names_are_one_automatic_group(self) -> None: + self._company("Acme Limited") + self._company("CASH SALE - Acme Ltd") + + report = DuplicateIdentityReportService().get_report() + + self.assertEqual(report["summary"]["company_merge_groups"], 1) + group = report["company_groups"][0] + self.assertEqual(group["recommendation"], "merge") + self.assertEqual(len(group["members"]), 2) + + def test_unrelated_company_names_with_shared_email_and_phone_need_review( + self, + ) -> None: + first = self._company("Acme Engineering") + second = self._company("Jane Smith") + for company in (first, second): + self._method(company, ContactMethod.MethodType.EMAIL, "jane@acme.test") + self._method(company, ContactMethod.MethodType.PHONE, "021 555 0101") + + report = DuplicateIdentityReportService().get_report() + + self.assertEqual(report["summary"]["company_merge_groups"], 0) + self.assertEqual(report["summary"]["company_review_groups"], 1) + + def test_compatible_people_with_a_rare_phone_are_merged(self) -> None: + company = self._company("Acme") + first = Person.objects.create(name="Robert Jones") + second = Person.objects.create(name="Bob Jones") + CompanyPersonLink.objects.create(company=company, person=first) + CompanyPersonLink.objects.create(company=company, person=second) + self._method(first, ContactMethod.MethodType.PHONE, "021 555 0102") + self._method(second, ContactMethod.MethodType.PHONE, "021 555 0102") + + report = DuplicateIdentityReportService().get_report() + + self.assertEqual(report["summary"]["person_merge_groups"], 1) + + def test_conflicting_person_names_with_shared_contacts_need_review(self) -> None: + first = Person.objects.create(name="Alice Smith") + second = Person.objects.create(name="Bob Jones") + for person in (first, second): + self._method(person, ContactMethod.MethodType.EMAIL, "office@acme.test") + self._method(person, ContactMethod.MethodType.PHONE, "021 555 0103") + + report = DuplicateIdentityReportService().get_report() + + self.assertEqual(report["summary"]["person_merge_groups"], 0) + self.assertEqual(report["summary"]["person_review_groups"], 1) + + def test_cross_company_name_only_people_are_not_reported(self) -> None: + first_company = self._company("Acme") + second_company = self._company("Beta") + first = Person.objects.create(name="Chris Smith") + second = Person.objects.create(name="Chris Smith") + CompanyPersonLink.objects.create(company=first_company, person=first) + CompanyPersonLink.objects.create(company=second_company, person=second) + + report = DuplicateIdentityReportService().get_report() + + self.assertEqual(report["person_groups"], []) diff --git a/apps/company/tests/test_duplicate_person_report.py b/apps/company/tests/test_duplicate_person_report.py new file mode 100644 index 000000000..6b92ede3d --- /dev/null +++ b/apps/company/tests/test_duplicate_person_report.py @@ -0,0 +1,129 @@ +from django.test import TestCase +from django.utils import timezone + +from apps.company.models import Company, CompanyPersonLink, ContactMethod, Person +from apps.company.services.duplicate_person_report import ( + DuplicatePersonReportService, + person_names_compatible, +) + + +class DuplicatePersonReportTests(TestCase): + """Exact signals must find review candidates without splitting one Person.""" + + def _company(self, name: str) -> Company: + return Company.objects.create(name=name, xero_last_modified=timezone.now()) + + def _link(self, person: Person, company: Company) -> None: + CompanyPersonLink.objects.create(person=person, company=company) + + def _legacy_phone(self, person: Person, value: str) -> None: + method = ContactMethod( + person=person, + method_type=ContactMethod.MethodType.PHONE, + value=value, + normalized_value=ContactMethod.normalize_phone(value), + ) + ContactMethod.objects.bulk_create([method]) + + def test_combined_name_and_email_is_high_confidence(self) -> None: + """A report refactor must retain all evidence used for safe auto-selection.""" + first = Person.objects.create(name=" Jane Smith ", email="JANE@example.com") + second = Person.objects.create(name="jane smith", email="jane@example.com") + self._link(first, self._company("Acme")) + self._link(second, self._company("Beta")) + + report = DuplicatePersonReportService().get_report() + + self.assertEqual(len(report["duplicate_people"]), 1) + candidate = report["duplicate_people"][0] + self.assertEqual(candidate["confidence"], "high") + self.assertEqual( + {match["kind"] for match in candidate["matches"]}, {"name", "email"} + ) + self.assertEqual(report["summary"]["people_flagged"], 2) + + def test_phone_only_match_is_medium_confidence(self) -> None: + """Person-owned phones are useful candidates even when names differ.""" + first = Person.objects.create(name="Jane Smith") + second = Person.objects.create(name="J Smith") + self._legacy_phone(first, "021 555 123") + self._legacy_phone(second, "+64 21 555 123") + + candidate = DuplicatePersonReportService().get_report()["duplicate_people"][0] + + self.assertEqual(candidate["confidence"], "medium") + self.assertEqual(candidate["matches"][0]["kind"], "phone") + + def test_one_person_linked_to_multiple_companies_is_not_duplicate(self) -> None: + """The first-class model allows one human to have several company links.""" + person = Person.objects.create(name="Jane Smith", email="jane@example.com") + self._link(person, self._company("Acme")) + self._link(person, self._company("Beta")) + self._legacy_phone(person, "021 555 123") + + report = DuplicatePersonReportService().get_report() + + self.assertEqual(report["duplicate_people"], []) + self.assertEqual(report["summary"]["candidate_pairs"], 0) + + def test_name_only_match_is_low_confidence(self) -> None: + """Common exact names require review rather than automatic confidence.""" + Person.objects.create(name="Accounts") + Person.objects.create(name=" accounts ") + + candidate = DuplicatePersonReportService().get_report()["duplicate_people"][0] + + self.assertEqual(candidate["confidence"], "low") + + def test_nickname_and_phone_is_high_confidence(self) -> None: + first = Person.objects.create(name="Christopher Watt") + second = Person.objects.create(name="Chris Watt") + self._legacy_phone(first, "021 555 123") + self._legacy_phone(second, "+64 21 555 123") + + candidate = DuplicatePersonReportService().get_report()["duplicate_people"][0] + + self.assertEqual(candidate["confidence"], "high") + self.assertEqual( + {match["kind"] for match in candidate["matches"]}, {"name", "phone"} + ) + + def test_two_contact_signals_with_incompatible_names_is_medium(self) -> None: + first = Person.objects.create(name="Brian") + second = Person.objects.create(name="Brent") + for person in (first, second): + ContactMethod.objects.create( + person=person, + method_type=ContactMethod.MethodType.EMAIL, + value="office@example.com", + ) + self._legacy_phone(person, "021 555 123") + + candidate = DuplicatePersonReportService().get_report()["duplicate_people"][0] + + self.assertEqual(candidate["confidence"], "medium") + + def test_nickname_does_not_override_conflicting_surnames(self) -> None: + self.assertFalse(person_names_compatible("Robert Grant", "Rob Smith")) + + def test_custom_overlapping_alias_groups_preserve_any_canonical_match(self) -> None: + alias_groups = { + "robert": {"rob"}, + "robin": {"rob", "bob"}, + } + + self.assertTrue( + person_names_compatible( + "Rob Grant", + "Bob Grant", + alias_groups=alias_groups, + ) + ) + self.assertFalse( + person_names_compatible( + "Rob Grant", + "Bob Smith", + alias_groups=alias_groups, + ) + ) diff --git a/apps/company/tests/test_duplicate_phone_report.py b/apps/company/tests/test_duplicate_phone_report.py new file mode 100644 index 000000000..7805a31b5 --- /dev/null +++ b/apps/company/tests/test_duplicate_phone_report.py @@ -0,0 +1,99 @@ +from django.test import TestCase +from django.utils import timezone + +from apps.company.models import Company, CompanyPersonLink, ContactMethod, Person +from apps.company.services.duplicate_phone_report import DuplicatePhoneReportService +from apps.crm.models import PhoneEndpoint + + +class DuplicatePhoneReportTests(TestCase): + def _company(self, name: str) -> Company: + return Company.objects.create(name=name, xero_last_modified=timezone.now()) + + def _phone( + self, + value: str, + company: Company | None = None, + contact: CompanyPersonLink | None = None, + ) -> ContactMethod: + """Insert a phone method bypassing the save() guard (legacy-style data).""" + method = ContactMethod( + company=company, + person=contact.person if contact else None, + method_type=ContactMethod.MethodType.PHONE, + value=value, + ) + method.normalized_value = ContactMethod.normalize_phone(value) + ContactMethod.objects.bulk_create([method]) + return method + + def _link(self, company: Company, name: str) -> CompanyPersonLink: + person = Person.objects.create(name=name) + return CompanyPersonLink.objects.create( + company=company, + person=person, + ) + + 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() + + cross = [i for i in report["duplicate_phones"] if i["issue"] == "cross_company"] + self.assertEqual(len(cross), 1) + self.assertEqual( + cross[0]["normalized_value"], + ContactMethod.normalize_phone("021 111 111"), + ) + self.assertEqual(len(cross[0]["owners"]), 2) + self.assertEqual(report["summary"]["cross_company"], 1) + + def test_detects_internal_line_collision(self) -> None: + company = self._company("Acme Ltd") + contact = self._link(company, "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. + endpoint = PhoneEndpoint( + number="09 636 5131", + label="Main line", + endpoint_type=PhoneEndpoint.EndpointType.MAIN_LINE, + ) + endpoint.normalized_number = ContactMethod.normalize_phone("09 636 5131") + PhoneEndpoint.objects.bulk_create([endpoint]) + + report = DuplicatePhoneReportService().get_report() + + internal = [ + i for i in report["duplicate_phones"] if i["issue"] == "internal_line" + ] + self.assertEqual(len(internal), 1) + self.assertEqual(internal[0]["endpoint_label"], "Main line") + self.assertEqual(len(internal[0]["owners"]), 1) + self.assertEqual(report["summary"]["internal_line"], 1) + + def test_clean_data_returns_empty(self) -> None: + company = self._company("Acme Ltd") + self._phone("021 111 111", company=company) + + report = DuplicatePhoneReportService().get_report() + + self.assertEqual(report["duplicate_phones"], []) + self.assertEqual(report["summary"], {"cross_company": 0, "internal_line": 0}) + self.assertIn("checked_at", report) + + def test_one_person_linked_to_two_companies_is_one_phone_owner(self) -> None: + """A Person may span companies; unioning their company ids caused a false alert.""" + acme = self._company("Acme Ltd") + beta = self._company("Beta Ltd") + person = Person.objects.create(name="Jane Smith") + first_link = CompanyPersonLink.objects.create(company=acme, person=person) + CompanyPersonLink.objects.create(company=beta, person=person) + self._phone("021 111 111", contact=first_link) + + report = DuplicatePhoneReportService().get_report() + + self.assertEqual(report["duplicate_phones"], []) diff --git a/apps/client/tests/test_get_client_for_xero.py b/apps/company/tests/test_get_company_for_xero.py similarity index 73% rename from apps/client/tests/test_get_client_for_xero.py rename to apps/company/tests/test_get_company_for_xero.py index 01a7c96d7..214e36876 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 Company, ContactMethod -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, - method_type=ClientContactMethod.MethodType.PHONE, + ContactMethod.objects.create( + company=company, + method_type=ContactMethod.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/client/tests/test_job_contact_view.py b/apps/company/tests/test_job_contact_view.py similarity index 52% rename from apps/client/tests/test_job_contact_view.py rename to apps/company/tests/test_job_contact_view.py index 57a1868df..e4eb101e9 100644 --- a/apps/client/tests/test_job_contact_view.py +++ b/apps/company/tests/test_job_contact_view.py @@ -1,8 +1,8 @@ -"""Job contact endpoint tests. +"""Job person endpoint tests. -Guards the GET/PUT contract of the job-contact endpoint end-to-end (URL +Guards the GET/PUT contract of the job-person endpoint end-to-end (URL routing, permissions, and response-serializer validation), which the -job-settings tab uses to load and reassign a job's contact person. +job-settings tab uses to load and reassign a job's person. """ from typing import TYPE_CHECKING, Any @@ -13,28 +13,32 @@ if TYPE_CHECKING: from rest_framework.response import _MonkeyPatchedResponse -from apps.client.models import Client, ClientContact +from apps.company.models import Company, CompanyPersonLink, Person from apps.job.models import Job from apps.testing import BaseAPITestCase from apps.workflow.models import XeroPayItem -class JobContactViewTests(BaseAPITestCase): +class JobPersonViewTests(BaseAPITestCase): def setUp(self) -> None: super().setUp() self.client.force_authenticate(user=self.test_staff) - self.job_client = Client.objects.create( + self.job_company = Company.objects.create( name="Acme Ltd", xero_last_modified=timezone.now() ) - def _contact(self, name: str) -> ClientContact: - return ClientContact.objects.create(client=self.job_client, name=name) + def _link(self, name: str) -> CompanyPersonLink: + person = Person.objects.create(name=name) + return CompanyPersonLink.objects.create( + company=self.job_company, + person=person, + ) - def _job(self, contact: ClientContact) -> Job: + def _job(self, link: CompanyPersonLink) -> Job: job: Job = Job.objects.create( - name="Contact Job", - client=self.job_client, - contact=contact, + name="Person Job", + company=self.job_company, + person=link.person, created_by=self.test_staff, default_xero_pay_item=XeroPayItem.get_ordinary_time(), staff=self.test_staff, @@ -42,7 +46,7 @@ def _job(self, contact: ClientContact) -> Job: return job def _url(self, job: Job) -> str: - return reverse("clients:job_contact_rest", kwargs={"job_id": job.id}) + return reverse("companies:job_person_rest", kwargs={"job_id": job.id}) def _get(self, job: Job) -> "_MonkeyPatchedResponse": return self.client.get(self._url(job)) @@ -50,51 +54,48 @@ def _get(self, job: Job) -> "_MonkeyPatchedResponse": def _put(self, job: Job, payload: dict[str, Any]) -> "_MonkeyPatchedResponse": return self.client.put(self._url(job), payload, format="json") - def _put_payload(self, contact: ClientContact) -> dict[str, Any]: + def _put_payload(self, link: CompanyPersonLink) -> dict[str, Any]: return { - "id": str(contact.id), - "name": contact.name, - "email": contact.email, - "position": contact.position, - "is_primary": contact.is_primary, - "notes": contact.notes, + "id": str(link.person_id), + "name": link.person.name, + "email": link.person.email, } - def test_get_returns_job_contact(self) -> None: - contact = self._contact("Jane Smith") - job = self._job(contact) + def test_get_returns_job_person(self) -> None: + link = self._link("Jane Smith") + job = self._job(link) response = self._get(job) self.assertEqual(response.status_code, 200) body = response.json() - self.assertEqual(body["id"], str(contact.id)) + self.assertEqual(body["id"], str(link.person_id)) self.assertEqual(body["name"], "Jane Smith") - def test_put_response_reflects_new_contact(self) -> None: - job = self._job(self._contact("Jane Smith")) - replacement = self._contact("Bob Jones") + def test_put_response_reflects_new_person(self) -> None: + job = self._job(self._link("Jane Smith")) + replacement = self._link("Bob Jones") response = self._put(job, self._put_payload(replacement)) self.assertEqual(response.status_code, 200) - self.assertEqual(response.json()["id"], str(replacement.id)) + self.assertEqual(response.json()["id"], str(replacement.person_id)) job.refresh_from_db() - self.assertEqual(job.contact_id, replacement.id) + self.assertEqual(job.person_id, replacement.person_id) def test_put_ignores_unrecognized_field(self) -> None: - """Phones live in ClientContactMethod; the job contact update path - only reassigns the contact FK. An unexpected key (here the removed + """Phones live in ContactMethod; the job person update path + only reassigns the person FK. An unexpected key (here the removed phone field) must be ignored, not written or echoed back.""" - job = self._job(self._contact("Jane Smith")) - replacement = self._contact("Bob Jones") + job = self._job(self._link("Jane Smith")) + replacement = self._link("Bob Jones") payload = self._put_payload(replacement) | {"phone": "09 999 9999"} response = self._put(job, payload) self.assertEqual(response.status_code, 200) body = response.json() - self.assertEqual(body["id"], str(replacement.id)) + self.assertEqual(body["id"], str(replacement.person_id)) self.assertNotIn("phone", body) job.refresh_from_db() - self.assertEqual(job.contact_id, replacement.id) + self.assertEqual(job.person_id, replacement.person_id) diff --git a/apps/company/tests/test_kan278_duplicate_cleanup.py b/apps/company/tests/test_kan278_duplicate_cleanup.py new file mode 100644 index 000000000..c3df67d59 --- /dev/null +++ b/apps/company/tests/test_kan278_duplicate_cleanup.py @@ -0,0 +1,228 @@ +import re +from pathlib import Path +from unittest.mock import patch + +from django.utils import timezone + +from apps.company.models import Company, CompanyPersonLink, ContactMethod, Person +from apps.company.services import kan278_duplicate_cleanup +from apps.company.services.kan278_duplicate_cleanup import ( + CompanyMergeDecision, + PersonMergeDecision, + PersonSelector, + apply_reviewed_duplicate_cleanup, +) +from apps.job.models import Job +from apps.testing import BaseTestCase + + +class ReviewedDuplicateCleanupTests(BaseTestCase): + def test_flattens_existing_multi_hop_company_merges(self) -> None: + terminal = Company.objects.create( + name="Terminal Company", + xero_last_modified=timezone.now(), + ) + middle = Company.objects.create( + name="Middle Company", + xero_last_modified=timezone.now(), + merged_into=terminal, + ) + source = Company.objects.create( + name="Source Company", + xero_last_modified=timezone.now(), + merged_into=middle, + ) + job = Job( + company=source, + name="Stranded historical job", + job_number=998279, + ) + job.save(staff=self.test_staff) + + kan278_duplicate_cleanup._flatten_existing_company_merges(self.test_staff) + + source.refresh_from_db() + middle.refresh_from_db() + job.refresh_from_db() + self.assertEqual(source.merged_into_id, terminal.id) + self.assertEqual(middle.merged_into_id, terminal.id) + self.assertEqual(job.company_id, terminal.id) + self.assertFalse( + Company.objects.filter(merged_into__merged_into__isnull=False).exists() + ) + + def test_applies_named_company_then_person_decisions(self) -> None: + destination_company = Company.objects.create( + name="Northland Roofs NZ", + xero_last_modified=timezone.now(), + ) + source_company = Company.objects.create( + name="CASH SALE - Northland Roofs NZ", + xero_contact_id="source-xero-id", + xero_last_modified=timezone.now(), + ) + destination_person = Person.objects.create( + name="ACE PUNIA", email="ace@example.com" + ) + source_person = Person.objects.create(name="Ace Punia", email="ACE@example.com") + CompanyPersonLink.objects.create( + company=destination_company, + person=destination_person, + ) + CompanyPersonLink.objects.create( + company=source_company, + person=source_person, + ) + ContactMethod.objects.create( + person=source_person, + method_type=ContactMethod.MethodType.PHONE, + value="021 555 123", + ) + job = Job( + company=source_company, + person=source_person, + name="Northland test", + job_number=998278, + ) + job.save(staff=self.test_staff) + + company_decision = CompanyMergeDecision( + canonical_name="Northland Roofs NZ", + names=("CASH SALE - Northland Roofs NZ", "Northland Roofs NZ"), + expected_rows=2, + evidence="same production email, phone and Ace Punia", + ) + person_decision = PersonMergeDecision( + canonical=PersonSelector( + "ACE PUNIA", "ace@example.com", "Northland Roofs NZ" + ), + members=( + PersonSelector("ACE PUNIA", "ace@example.com", "Northland Roofs NZ"), + PersonSelector( + "Ace Punia", + "ACE@example.com", + "CASH SALE - Northland Roofs NZ", + ), + ), + expected_people=2, + evidence="same production email and phone", + ) + + with ( + patch.object( + kan278_duplicate_cleanup, + "REVIEWED_COMPANY_MERGES", + (company_decision,), + ), + patch.object( + kan278_duplicate_cleanup, + "REVIEWED_PERSON_MERGES", + (person_decision,), + ), + patch.object(kan278_duplicate_cleanup, "INVALID_LINKS", ()), + patch.object(kan278_duplicate_cleanup, "_repair_matt_green"), + patch.object( + kan278_duplicate_cleanup, + "_assert_residuals_are_defended", + ), + ): + company_count, person_count = apply_reviewed_duplicate_cleanup() + + source_company.refresh_from_db() + job.refresh_from_db() + self.assertEqual(company_count, 1) + self.assertEqual(person_count, 1) + self.assertEqual(source_company.merged_into_id, destination_company.id) + self.assertFalse(source_company.allow_jobs) + self.assertEqual(source_company.xero_contact_id, "source-xero-id") + self.assertEqual(job.company_id, destination_company.id) + self.assertEqual(job.person_id, destination_person.id) + self.assertFalse(Person.objects.filter(id=source_person.id).exists()) + + def test_flattens_chain_created_by_reviewed_company_decisions(self) -> None: + terminal = Company.objects.create( + name="Terminal Company", + xero_last_modified=timezone.now(), + ) + middle = Company.objects.create( + name="Middle Company", + xero_last_modified=timezone.now(), + ) + source = Company.objects.create( + name="Source Company", + xero_last_modified=timezone.now(), + merged_into=middle, + ) + decisions = ( + CompanyMergeDecision( + canonical_name="Terminal Company", + names=("Middle Company", "Terminal Company"), + expected_rows=2, + evidence="fixture reviewed merge", + ), + ) + + with ( + patch.object( + kan278_duplicate_cleanup, + "REVIEWED_COMPANY_MERGES", + decisions, + ), + patch.object( + kan278_duplicate_cleanup, + "REVIEWED_PERSON_MERGES", + (), + ), + patch.object(kan278_duplicate_cleanup, "INVALID_LINKS", ()), + patch.object(kan278_duplicate_cleanup, "_repair_matt_green"), + patch.object( + kan278_duplicate_cleanup, + "_assert_residuals_are_defended", + ), + ): + company_count, person_count = apply_reviewed_duplicate_cleanup() + + source.refresh_from_db() + middle.refresh_from_db() + self.assertEqual(company_count, 1) + self.assertEqual(person_count, 0) + self.assertEqual(source.merged_into_id, terminal.id) + self.assertEqual(middle.merged_into_id, terminal.id) + self.assertFalse( + Company.objects.filter(merged_into__merged_into__isnull=False).exists() + ) + + def test_changed_named_evidence_aborts_before_writes(self) -> None: + destination = Company.objects.create( + name="Destination", + xero_last_modified=timezone.now(), + ) + decision = CompanyMergeDecision( + canonical_name="Destination", + names=("Missing source", "Destination"), + expected_rows=2, + evidence="fixture evidence", + ) + + with ( + patch.object( + kan278_duplicate_cleanup, + "REVIEWED_COMPANY_MERGES", + (decision,), + ), + patch.object(kan278_duplicate_cleanup, "REVIEWED_PERSON_MERGES", ()), + patch.object(kan278_duplicate_cleanup, "INVALID_LINKS", ()), + ): + with self.assertRaisesRegex(RuntimeError, "Company evidence changed"): + apply_reviewed_duplicate_cleanup() + + destination.refresh_from_db() + self.assertIsNone(destination.merged_into_id) + + def test_cleanup_manifest_contains_no_uuid_literals(self) -> None: + source = Path(kan278_duplicate_cleanup.__file__).read_text() + uuid_literal = re.compile( + r"[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}", + re.IGNORECASE, + ) + self.assertIsNone(uuid_literal.search(source)) diff --git a/apps/company/tests/test_people_backfill_migration.py b/apps/company/tests/test_people_backfill_migration.py new file mode 100644 index 000000000..abef1eec8 --- /dev/null +++ b/apps/company/tests/test_people_backfill_migration.py @@ -0,0 +1,139 @@ +import uuid +from typing import ClassVar + +from django.db import connection, transaction +from django.db.migrations.executor import MigrationExecutor +from django.test import TransactionTestCase +from django.utils import timezone + + +class PeopleBackfillMigrationTests(TransactionTestCase): + migrate_from: ClassVar[tuple[tuple[str, str], ...]] = ( + ("company", "0004_person_link_structure"), + ("job", "0004_job_person_alter_job_contact"), + ("crm", "0005_phonecallrecord_person_alter_phonecallrecord_contact"), + ) + migrate_to: ClassVar[tuple[tuple[str, str], ...]] = ( + ("company", "0005_backfill_person"), + ) + + def setUp(self) -> None: + super().setUp() + self.executor = MigrationExecutor(connection) + self.executor.migrate(self.migrate_from) + self.old_apps = self.executor.loader.project_state(self.migrate_from).apps + + def tearDown(self) -> None: + self.executor.loader.build_graph() + self.executor.migrate(self.executor.loader.graph.leaf_nodes()) + super().tearDown() + + def test_backfill_and_reverse_restore_legacy_contact_ownership(self) -> None: + Company = self.old_apps.get_model("company", "Company") + CompanyPersonLink = self.old_apps.get_model("company", "CompanyPersonLink") + ContactMethod = self.old_apps.get_model("company", "ContactMethod") + Job = self.old_apps.get_model("job", "Job") + CostSet = self.old_apps.get_model("job", "CostSet") + PhoneCallRecord = self.old_apps.get_model("crm", "PhoneCallRecord") + XeroPayItem = self.old_apps.get_model("workflow", "XeroPayItem") + + company = Company.objects.create( + name="Acme", + xero_last_modified=timezone.now(), + ) + link = CompanyPersonLink.objects.create( + company=company, + name="Jane Smith", + email="jane@example.com", + is_primary=True, + ) + method = ContactMethod.objects.create( + contact=link, + method_type="phone", + value="021 123 456", + normalized_value="+6421123456", + is_primary=True, + ) + pay_item, _created = XeroPayItem.objects.get_or_create( + name="Ordinary Time", + uses_leave_api=False, + defaults={"multiplier": "1.00"}, + ) + estimate_id = uuid.uuid4() + quote_id = uuid.uuid4() + actual_id = uuid.uuid4() + with transaction.atomic(): + with connection.cursor() as cursor: + cursor.execute("SET CONSTRAINTS ALL DEFERRED") + job = Job.objects.create( + company=company, + contact=link, + name="Test job", + job_number=991001, + default_xero_pay_item=pay_item, + latest_estimate_id=estimate_id, + latest_quote_id=quote_id, + latest_actual_id=actual_id, + ) + CostSet.objects.create(id=estimate_id, job=job, kind="estimate", rev=1) + CostSet.objects.create(id=quote_id, job=job, kind="quote", rev=1) + CostSet.objects.create(id=actual_id, job=job, kind="actual", rev=1) + now = timezone.now() + call = PhoneCallRecord.objects.create( + provider_call_id="call-people-backfill", + account_code="acct", + call_datetime=now, + call_date=timezone.localdate(), + call_time=now.time(), + company=company, + contact=link, + raw_json={}, + ) + + self.executor.loader.build_graph() + self.executor.migrate(self.migrate_to) + new_apps = self.executor.loader.project_state(self.migrate_to).apps + Person = new_apps.get_model("company", "Person") + CompanyPersonLink = new_apps.get_model("company", "CompanyPersonLink") + ContactMethod = new_apps.get_model("company", "ContactMethod") + Job = new_apps.get_model("job", "Job") + PhoneCallRecord = new_apps.get_model("crm", "PhoneCallRecord") + + person = Person.objects.get(name="Jane Smith") + link = CompanyPersonLink.objects.get(pk=link.pk) + method = ContactMethod.objects.get(pk=method.pk) + job = Job.objects.get(pk=job.pk) + call = PhoneCallRecord.objects.get(pk=call.pk) + + self.assertEqual(link.person_id, person.pk) + self.assertEqual(link.xero_name, "Jane Smith") + self.assertEqual(person.email, "jane@example.com") + self.assertEqual(method.person_id, person.pk) + self.assertIsNone(method.contact_id) + self.assertEqual(job.person_id, person.pk) + self.assertEqual(call.person_id, person.pk) + + self.executor.loader.build_graph() + self.executor.migrate(self.migrate_from) + reversed_apps = self.executor.loader.project_state(self.migrate_from).apps + Person = reversed_apps.get_model("company", "Person") + CompanyPersonLink = reversed_apps.get_model("company", "CompanyPersonLink") + ContactMethod = reversed_apps.get_model("company", "ContactMethod") + Job = reversed_apps.get_model("job", "Job") + PhoneCallRecord = reversed_apps.get_model("crm", "PhoneCallRecord") + + link = CompanyPersonLink.objects.get(pk=link.pk) + method = ContactMethod.objects.get(pk=method.pk) + job = Job.objects.get(pk=job.pk) + call = PhoneCallRecord.objects.get(pk=call.pk) + + self.assertIsNone(link.person_id) + self.assertIsNone(link.xero_name) + self.assertEqual(link.name, "Jane Smith") + self.assertEqual(method.contact_id, link.pk) + self.assertIsNone(method.person_id) + self.assertEqual(job.contact_id, link.pk) + self.assertIsNone(job.person_id) + self.assertEqual(call.contact_id, link.pk) + self.assertIsNone(call.person_id) + self.assertEqual(Person.objects.count(), 0) diff --git a/apps/company/tests/test_person_api.py b/apps/company/tests/test_person_api.py new file mode 100644 index 000000000..f8c24300b --- /dev/null +++ b/apps/company/tests/test_person_api.py @@ -0,0 +1,409 @@ +from unittest.mock import patch + +from django.utils import timezone + +from apps.company.models import Company, CompanyPersonLink, ContactMethod, Person +from apps.testing import BaseAPITestCase + + +class PersonApiTests(BaseAPITestCase): + def setUp(self) -> None: + super().setUp() + self.test_staff.is_office_staff = True + self.test_staff.save(update_fields=["is_office_staff"]) + self.client.force_authenticate(user=self.test_staff) + self.company_a = Company.objects.create( + name="Acme Engineering", xero_last_modified=timezone.now() + ) + self.company_b = Company.objects.create( + name="Beta Fabrication", xero_last_modified=timezone.now() + ) + + def _person( + self, name: str = "Jane Smith", company: Company | None = None + ) -> Person: + person = Person.objects.create(name=name, email="jane@example.com") + if company is not None: + CompanyPersonLink.objects.create(company=company, person=person) + return person + + def test_directory_search_returns_one_person_for_multiple_matching_links( + self, + ) -> None: + """A join-based search must not duplicate a person who works at two companies.""" + person = self._person(company=self.company_a) + CompanyPersonLink.objects.create(company=self.company_b, person=person) + + response = self.client.get("/api/people/", {"q": "Fabrication"}) + + self.assertEqual(response.status_code, 200) + self.assertEqual(response.json()["count"], 1) + self.assertEqual(response.json()["results"][0]["name"], "Jane Smith") + + def test_directory_includes_person_without_company(self) -> None: + """Existing unaffiliated people must remain discoverable so they can be repaired.""" + self._person(name="Unaffiliated Person") + + response = self.client.get("/api/people/", {"q": "Unaffiliated"}) + + self.assertEqual(response.status_code, 200) + self.assertEqual(response.json()["results"][0]["companies"], []) + + def test_create_company_person_is_atomic_on_cross_company_phone_conflict( + self, + ) -> None: + """A duplicate-phone rejection must not leave an orphan Person behind.""" + existing = self._person(company=self.company_a) + ContactMethod.objects.create( + person=existing, + method_type=ContactMethod.MethodType.PHONE, + value="021 111 1111", + is_primary=True, + ) + people_before = Person.objects.count() + + response = self.client.post( + f"/api/companies/{self.company_b.id}/people/", + {"name": "Jane Duplicate", "phone": "0211111111"}, + format="json", + ) + + self.assertEqual(response.status_code, 409) + self.assertEqual(response.json()["status"], "people") + self.assertFalse(response.json()["can_create_person"]) + self.assertEqual(response.json()["people"][0]["person_name"], "Jane Smith") + self.assertEqual(Person.objects.count(), people_before) + + def test_same_company_shared_phone_can_create_another_person(self) -> None: + """Two employees may legitimately share their company's office phone.""" + existing = self._person(company=self.company_a) + ContactMethod.objects.create( + person=existing, + method_type=ContactMethod.MethodType.PHONE, + value="09 555 0000", + ) + + lookup = self.client.post( + f"/api/companies/{self.company_a.id}/people/phone-ownership/", + {"phone": "095550000"}, + format="json", + ) + created = self.client.post( + f"/api/companies/{self.company_a.id}/people/", + {"name": "John Smith", "phone": "095550000"}, + format="json", + ) + + self.assertEqual(lookup.status_code, 200) + self.assertTrue(lookup.json()["can_create_person"]) + self.assertEqual(created.status_code, 201) + self.assertEqual(created.json()["person_name"], "John Smith") + + def test_phone_ownership_rejects_a_value_without_digits(self) -> None: + response = self.client.post( + f"/api/companies/{self.company_a.id}/people/phone-ownership/", + {"phone": "not a phone"}, + format="json", + ) + + self.assertEqual(response.status_code, 400) + self.assertEqual( + response.json()["phone"], + ["Phone number must contain at least one digit"], + ) + + def test_contact_method_patch_cannot_change_its_owner(self) -> None: + person = self._person(company=self.company_a) + other_person = self._person(name="Other Person", company=self.company_a) + method = ContactMethod.objects.create( + person=person, + method_type=ContactMethod.MethodType.PHONE, + value="021 222 3333", + ) + + response = self.client.patch( + f"/api/people/{person.id}/contact-methods/{method.id}/", + { + "label": "Mobile", + "person": str(other_person.id), + "company": str(self.company_b.id), + }, + format="json", + ) + + self.assertEqual(response.status_code, 200) + method.refresh_from_db() + self.assertEqual(method.person_id, person.id) + self.assertIsNone(method.company_id) + self.assertEqual(method.label, "Mobile") + + def test_changing_phone_to_email_rematches_the_old_phone(self) -> None: + """Changing method type must clear calls matched to the former phone.""" + person = self._person(company=self.company_a) + method = ContactMethod.objects.create( + person=person, + method_type=ContactMethod.MethodType.PHONE, + value="021 222 3333", + ) + + with patch("apps.crm.tasks.rematch_phone_calls_task.delay") as rematch: + with self.captureOnCommitCallbacks(execute=True): + response = self.client.patch( + f"/api/people/{person.id}/contact-methods/{method.id}/", + { + "method_type": ContactMethod.MethodType.EMAIL, + "value": "jane@example.com", + }, + format="json", + ) + + self.assertEqual(response.status_code, 200) + rematch.assert_called_once_with(["+64212223333"]) + + def test_put_reactivates_existing_company_link_without_duplication(self) -> None: + """Restoring employment must reuse the soft-deleted unique link row.""" + person = self._person() + link = CompanyPersonLink.objects.create( + company=self.company_a, person=person, is_active=False + ) + + response = self.client.put( + f"/api/people/{person.id}/company-links/{self.company_a.id}/", + {"position": "Manager", "notes": "Restored", "is_primary": False}, + format="json", + ) + + self.assertEqual(response.status_code, 200) + link.refresh_from_db() + self.assertTrue(link.is_active) + self.assertTrue(link.is_primary) + self.assertEqual(link.position, "Manager") + self.assertEqual( + CompanyPersonLink.objects.filter( + company=self.company_a, person=person + ).count(), + 1, + ) + + def test_removing_link_preserves_person_and_other_company(self) -> None: + """Unlinking one employer must not delete the shared human identity.""" + person = self._person(company=self.company_a) + other = CompanyPersonLink.objects.create(company=self.company_b, person=person) + + with patch("apps.crm.tasks.rematch_phone_calls_task.delay"): + response = self.client.delete( + f"/api/people/{person.id}/company-links/{self.company_a.id}/" + ) + + self.assertEqual(response.status_code, 204) + self.assertTrue(Person.objects.filter(id=person.id).exists()) + person.refresh_from_db() + self.assertTrue(person.is_active) + other.refresh_from_db() + self.assertTrue(other.is_active) + + def test_removing_last_link_archives_person(self) -> None: + """Removing a person's only active company link retires (archives) them.""" + person = self._person(company=self.company_a) + + with patch("apps.crm.tasks.rematch_phone_calls_task.delay"): + response = self.client.delete( + f"/api/people/{person.id}/company-links/{self.company_a.id}/" + ) + + self.assertEqual(response.status_code, 204) + person.refresh_from_db() + self.assertFalse(person.is_active) + link = CompanyPersonLink.objects.get(person=person, company=self.company_a) + self.assertFalse(link.is_active) + + def test_restoring_a_link_unarchives_the_person(self) -> None: + """Adding/reactivating any company link brings an archived person back.""" + from apps.company.services.person_service import ( + put_company_link, + remove_company_link, + ) + + person = self._person(company=self.company_a) + with patch("apps.crm.tasks.rematch_phone_calls_task.delay"): + remove_company_link(person=person, company=self.company_a) + person.refresh_from_db() + self.assertFalse(person.is_active) + + with patch("apps.crm.tasks.rematch_phone_calls_task.delay"): + put_company_link( + person=person, + company=self.company_a, + data={"position": None, "notes": None, "is_primary": False}, + ) + person.refresh_from_db() + self.assertTrue(person.is_active) + + def test_removing_link_is_blocked_when_phone_would_cross_companies(self) -> None: + """Relationship edits must not create the duplicate-phone problem they manage.""" + person = self._person(company=self.company_a) + CompanyPersonLink.objects.create(company=self.company_b, person=person) + ContactMethod.objects.create( + company=self.company_a, + method_type=ContactMethod.MethodType.PHONE, + value="09 444 4444", + ) + ContactMethod.objects.create( + person=person, + method_type=ContactMethod.MethodType.PHONE, + value="09 444 4444", + ) + + response = self.client.delete( + f"/api/people/{person.id}/company-links/{self.company_a.id}/" + ) + + self.assertEqual(response.status_code, 400) + self.assertTrue( + CompanyPersonLink.objects.get( + person=person, company=self.company_a + ).is_active + ) + + def test_identity_patch_does_not_change_company_relationship(self) -> None: + """Editing canonical identity must not overwrite company-specific role data.""" + person = self._person(company=self.company_a) + link = CompanyPersonLink.objects.get(person=person, company=self.company_a) + link.position = "Estimator" + link.save(update_fields=["position"]) + + response = self.client.patch( + f"/api/people/{person.id}/", + {"name": "Jane Brown"}, + format="json", + ) + + self.assertEqual(response.status_code, 200) + link.refresh_from_db() + self.assertEqual(link.position, "Estimator") + + def test_old_person_links_collection_is_removed(self) -> None: + """A caller migration must not accidentally leave two person-link APIs active.""" + response = self.client.get("/api/companies/person-links/") + + self.assertEqual(response.status_code, 404) + + def test_detail_returns_an_archived_person(self) -> None: + person = self._person(company=self.company_a) + with patch("apps.crm.tasks.rematch_phone_calls_task.delay"): + self.client.delete( + f"/api/people/{person.id}/company-links/{self.company_a.id}/" + ) + + response = self.client.get(f"/api/people/{person.id}/") + self.assertEqual(response.status_code, 200) + self.assertFalse(response.json()["is_active"]) + + def test_restore_link_over_http_unarchives_archived_person(self) -> None: + person = self._person(company=self.company_a) + with patch("apps.crm.tasks.rematch_phone_calls_task.delay"): + self.client.delete( + f"/api/people/{person.id}/company-links/{self.company_a.id}/" + ) + person.refresh_from_db() + self.assertFalse(person.is_active) + + with patch("apps.crm.tasks.rematch_phone_calls_task.delay"): + response = self.client.put( + f"/api/people/{person.id}/company-links/{self.company_a.id}/", + data={"position": "", "notes": "", "is_primary": False}, + format="json", + ) + self.assertEqual(response.status_code, 200) + self.assertTrue(response.json()["is_active"]) + person.refresh_from_db() + self.assertTrue(person.is_active) + + def test_archive_person_endpoint_deactivates_links_and_archives(self) -> None: + person = self._person(company=self.company_a) + CompanyPersonLink.objects.create(company=self.company_b, person=person) + + with patch("apps.crm.tasks.rematch_phone_calls_task.delay"): + response = self.client.post(f"/api/people/{person.id}/archive/") + + self.assertEqual(response.status_code, 200) + self.assertFalse(response.json()["is_active"]) + person.refresh_from_db() + self.assertFalse(person.is_active) + self.assertFalse( + CompanyPersonLink.objects.filter(person=person, is_active=True).exists() + ) + + def test_directory_excludes_archived_by_default(self) -> None: + person = self._person(company=self.company_a) + with patch("apps.crm.tasks.rematch_phone_calls_task.delay"): + self.client.delete( + f"/api/people/{person.id}/company-links/{self.company_a.id}/" + ) + + response = self.client.get("/api/people/") + ids = [row["id"] for row in response.json()["results"]] + self.assertNotIn(str(person.id), ids) + + def test_directory_includes_archived_when_requested(self) -> None: + person = self._person(company=self.company_a) + with patch("apps.crm.tasks.rematch_phone_calls_task.delay"): + self.client.delete( + f"/api/people/{person.id}/company-links/{self.company_a.id}/" + ) + + response = self.client.get("/api/people/", {"include_archived": "true"}) + rows = {row["id"]: row for row in response.json()["results"]} + self.assertIn(str(person.id), rows) + self.assertFalse(rows[str(person.id)]["is_active"]) + + def test_contact_methods_are_reachable_for_an_archived_person(self) -> None: + """The PersonDetail page loads contact-methods alongside the person; a 404 + here fails the whole Promise.all and hides the restore-link button.""" + person = self._person(company=self.company_a) + with patch("apps.crm.tasks.rematch_phone_calls_task.delay"): + self.client.delete( + f"/api/people/{person.id}/company-links/{self.company_a.id}/" + ) + person.refresh_from_db() + self.assertFalse(person.is_active) + + response = self.client.get(f"/api/people/{person.id}/contact-methods/") + + self.assertEqual(response.status_code, 200) + + def test_phone_ownership_offers_restore_for_an_archived_person(self) -> None: + """An archived person who still owns a phone must come back as status + 'people' (not 'company') so the modal can offer to restore the link.""" + person = self._person(company=self.company_a) + ContactMethod.objects.create( + person=person, + method_type=ContactMethod.MethodType.PHONE, + value="021 222 2222", + is_primary=True, + ) + with patch("apps.crm.tasks.rematch_phone_calls_task.delay"): + self.client.delete( + f"/api/people/{person.id}/company-links/{self.company_a.id}/" + ) + person.refresh_from_db() + self.assertFalse(person.is_active) + + response = self.client.post( + f"/api/companies/{self.company_a.id}/people/phone-ownership/", + {"phone": "0212222222"}, + format="json", + ) + + self.assertEqual(response.status_code, 200) + body = response.json() + self.assertEqual(body["status"], "people") + self.assertEqual(body["people"][0]["person_id"], str(person.id)) + company_links = body["people"][0]["company_links"] + self.assertFalse( + next( + link + for link in company_links + if link["company_id"] == str(self.company_a.id) + )["is_active"] + ) diff --git a/apps/company/tests/test_person_cleanup_migration.py b/apps/company/tests/test_person_cleanup_migration.py new file mode 100644 index 000000000..dd40a8f2a --- /dev/null +++ b/apps/company/tests/test_person_cleanup_migration.py @@ -0,0 +1,105 @@ +import uuid +from typing import ClassVar + +from django.db import connection, transaction +from django.db.migrations.executor import MigrationExecutor +from django.test import TransactionTestCase +from django.utils import timezone + + +class PersonCleanupMigrationTests(TransactionTestCase): + """The cleanup removes only rows with no remaining business references.""" + + migrate_from: ClassVar[tuple[tuple[str, str], ...]] = ( + ("company", "0006_alter_companypersonlink_options_and_more"), + ("crm", "0006_remove_phonecallrecord_contact_and_more"), + ("job", "0006_rename_job_event_people_company_terms"), + ) + migrate_to: ClassVar[tuple[tuple[str, str], ...]] = ( + ("company", "0007_remove_xero_person_identity"), + ) + + def setUp(self) -> None: + super().setUp() + self.executor = MigrationExecutor(connection) + self.executor.migrate(self.migrate_from) + self.old_apps = self.executor.loader.project_state(self.migrate_from).apps + + def tearDown(self) -> None: + self.executor.loader.build_graph() + self.executor.migrate(self.executor.loader.graph.leaf_nodes()) + super().tearDown() + + def test_removes_unreferenced_person_and_preserves_every_reference_kind( + self, + ) -> None: + Company = self.old_apps.get_model("company", "Company") + Person = self.old_apps.get_model("company", "Person") + CompanyPersonLink = self.old_apps.get_model("company", "CompanyPersonLink") + ContactMethod = self.old_apps.get_model("company", "ContactMethod") + Job = self.old_apps.get_model("job", "Job") + CostSet = self.old_apps.get_model("job", "CostSet") + PhoneCallRecord = self.old_apps.get_model("crm", "PhoneCallRecord") + XeroPayItem = self.old_apps.get_model("workflow", "XeroPayItem") + + company = Company.objects.create(name="Acme", xero_last_modified=timezone.now()) + unreferenced = Person.objects.create(name="Unreferenced") + linked = Person.objects.create(name="Linked") + method_owner = Person.objects.create(name="Method Owner") + job_person = Person.objects.create(name="Job Person") + call_person = Person.objects.create(name="Call Person") + CompanyPersonLink.objects.create(company=company, person=linked) + ContactMethod.objects.create( + person=method_owner, + method_type="email", + value="method@example.com", + normalized_value="method@example.com", + ) + pay_item, _created = XeroPayItem.objects.get_or_create( + name="Ordinary Time", + uses_leave_api=False, + defaults={"multiplier": "1.00"}, + ) + estimate_id = uuid.uuid4() + quote_id = uuid.uuid4() + actual_id = uuid.uuid4() + with transaction.atomic(): + with connection.cursor() as cursor: + cursor.execute("SET CONSTRAINTS ALL DEFERRED") + job = Job.objects.create( + company=company, + person=job_person, + name="Migration Job", + job_number=998101, + default_xero_pay_item=pay_item, + latest_estimate_id=estimate_id, + latest_quote_id=quote_id, + latest_actual_id=actual_id, + ) + CostSet.objects.create(id=estimate_id, job=job, kind="estimate", rev=1) + CostSet.objects.create(id=quote_id, job=job, kind="quote", rev=1) + CostSet.objects.create(id=actual_id, job=job, kind="actual", rev=1) + now = timezone.now() + PhoneCallRecord.objects.create( + provider_call_id="person-cleanup-migration", + account_code="account", + call_datetime=now, + call_date=timezone.localdate(), + call_time=now.time(), + person=call_person, + raw_json={}, + ) + + self.executor.loader.build_graph() + self.executor.migrate(self.migrate_to) + new_apps = self.executor.loader.project_state(self.migrate_to).apps + Person = new_apps.get_model("company", "Person") + CompanyPersonLink = new_apps.get_model("company", "CompanyPersonLink") + + self.assertFalse(Person.objects.filter(id=unreferenced.id).exists()) + for person in (linked, method_owner, job_person, call_person): + self.assertTrue(Person.objects.filter(id=person.id).exists()) + self.assertNotIn( + "xero_name", + {field.name for field in CompanyPersonLink._meta.get_fields()}, + ) diff --git a/apps/company/tests/test_person_merge_service.py b/apps/company/tests/test_person_merge_service.py new file mode 100644 index 000000000..d90dead1f --- /dev/null +++ b/apps/company/tests/test_person_merge_service.py @@ -0,0 +1,156 @@ +import uuid +from unittest.mock import patch + +from django.utils import timezone + +from apps.company.models import Company, CompanyPersonLink, ContactMethod, Person +from apps.company.services.person_merge_service import merge_people +from apps.crm.models import PhoneCallRecord +from apps.job.models import Job +from apps.testing import BaseTestCase +from apps.workflow.exceptions import AlreadyLoggedException +from apps.workflow.models import AppError + + +class PersonMergeServiceTests(BaseTestCase): + """Merging must preserve references while retaining uniqueness invariants.""" + + def _company(self, name: str) -> Company: + return Company.objects.create(name=name, xero_last_modified=timezone.now()) + + def _call(self, person: Person) -> PhoneCallRecord: + now = timezone.now() + return PhoneCallRecord.objects.create( + provider_call_id=f"person-merge:{uuid.uuid4()}", + account_code="account", + call_datetime=now, + call_date=timezone.localdate(), + call_time=now.time(), + person=person, + raw_json={}, + ) + + def _job(self, person: Person, company: Company) -> Job: + job = Job( + name="Merge Person Job", + job_number=998001, + company=company, + person=person, + ) + job.save(staff=self.test_staff) + return job + + def test_moves_cross_company_relationships_and_references(self) -> None: + source = Person.objects.create(name="J Smith", email="old@example.com") + destination = Person.objects.create( + name="Jane Smith", email="canonical@example.com" + ) + source_company = self._company("Source Co") + destination_company = self._company("Destination Co") + CompanyPersonLink.objects.create(company=source_company, person=source) + CompanyPersonLink.objects.create( + company=destination_company, person=destination + ) + method = ContactMethod.objects.create( + person=source, + method_type=ContactMethod.MethodType.PHONE, + value="021 555 123", + ) + job = self._job(source, source_company) + call = self._call(source) + + counts = merge_people(source.id, destination.id, self.test_staff) + + destination.refresh_from_db() + method.refresh_from_db() + job.refresh_from_db() + call.refresh_from_db() + self.assertFalse(Person.objects.filter(id=source.id).exists()) + self.assertEqual(destination.name, "Jane Smith") + self.assertEqual(destination.email, "canonical@example.com") + self.assertEqual(destination.company_links.count(), 2) + self.assertEqual(method.person_id, destination.id) + self.assertEqual(job.person_id, destination.id) + self.assertEqual(call.person_id, destination.id) + self.assertEqual(counts["links_moved"], 1) + self.assertEqual(counts["jobs"], 1) + self.assertEqual(counts["phone_calls"], 1) + + def test_collapses_same_company_link_and_exact_method(self) -> None: + source = Person.objects.create(name="J Smith") + destination = Person.objects.create(name="Jane Smith") + company = self._company("Acme") + source_link = CompanyPersonLink.objects.create( + company=company, + person=source, + position="Foreman", + notes="Source notes", + is_primary=True, + ) + destination_link = CompanyPersonLink.objects.create( + company=company, + person=destination, + ) + source_method = ContactMethod.objects.create( + person=source, + method_type=ContactMethod.MethodType.PHONE, + value="021 555 123", + label="Mobile", + is_primary=True, + ) + destination_method = ContactMethod.objects.create( + person=destination, + method_type=ContactMethod.MethodType.PHONE, + value="+64 21 555 123", + ) + + counts = merge_people(source.id, destination.id, self.test_staff) + + destination_link.refresh_from_db() + destination_method.refresh_from_db() + self.assertFalse(CompanyPersonLink.objects.filter(id=source_link.id).exists()) + self.assertEqual(destination.company_links.count(), 1) + self.assertEqual(destination_link.position, "Foreman") + self.assertEqual(destination_link.notes, "Source notes") + self.assertTrue(destination_link.is_primary) + self.assertFalse(ContactMethod.objects.filter(id=source_method.id).exists()) + self.assertEqual(destination.contact_methods.count(), 1) + self.assertEqual(destination_method.label, "Mobile") + self.assertTrue(destination_method.is_primary) + self.assertEqual(counts["links_collapsed"], 1) + self.assertEqual(counts["contact_methods_collapsed"], 1) + + def test_preserves_source_scalar_fields_missing_from_destination(self) -> None: + source = Person.objects.create( + name="Jane Smith", + email="jane@example.com", + is_active=True, + ) + destination = Person.objects.create(name="J Smith", is_active=False) + + merge_people(source.id, destination.id, self.test_staff) + + destination.refresh_from_db() + self.assertEqual(destination.email, "jane@example.com") + self.assertTrue(destination.is_active) + + def test_unexpected_failure_rolls_back_and_persists_once(self) -> None: + """A mid-merge failure must not strand links or create duplicate AppErrors.""" + source = Person.objects.create(name="J Smith") + destination = Person.objects.create(name="Jane Smith") + source_link = CompanyPersonLink.objects.create( + company=self._company("Acme"), person=source + ) + before_errors = AppError.objects.count() + + with patch( + "apps.company.services.person_merge_service._merge_contact_methods", + side_effect=RuntimeError("merge failed"), + ): + with self.assertRaises(AlreadyLoggedException): + merge_people(source.id, destination.id, self.test_staff) + + source_link.refresh_from_db() + self.assertEqual(source_link.person_id, source.id) + self.assertTrue(Person.objects.filter(id=source.id).exists()) + self.assertEqual(AppError.objects.count(), before_errors + 1) diff --git a/apps/company/urls_people_rest.py b/apps/company/urls_people_rest.py new file mode 100644 index 000000000..e99982d6f --- /dev/null +++ b/apps/company/urls_people_rest.py @@ -0,0 +1,45 @@ +"""REST URLs for first-class People and their company relationships.""" + +from django.urls import path + +from apps.company.views.person_views import ( + PersonArchiveView, + PersonCompanyLinkDetailView, + PersonCompanyLinksView, + PersonContactMethodDetailView, + PersonContactMethodsView, + PersonDetailView, + PersonListView, +) + +app_name = "people_rest" + +urlpatterns = [ + path("", PersonListView.as_view(), name="person_list"), + path("/", PersonDetailView.as_view(), name="person_detail"), + path( + "/company-links/", + PersonCompanyLinksView.as_view(), + name="person_company_links", + ), + path( + "/company-links//", + PersonCompanyLinkDetailView.as_view(), + name="person_company_link_detail", + ), + path( + "/contact-methods/", + PersonContactMethodsView.as_view(), + name="person_contact_methods", + ), + path( + "/contact-methods//", + PersonContactMethodDetailView.as_view(), + name="person_contact_method_detail", + ), + path( + "/archive/", + PersonArchiveView.as_view(), + name="person_archive", + ), +] diff --git a/apps/company/urls_rest.py b/apps/company/urls_rest.py new file mode 100644 index 000000000..73be36ea4 --- /dev/null +++ b/apps/company/urls_rest.py @@ -0,0 +1,120 @@ +""" +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, + JobPersonRestView, +) +from apps.company.views.contact_method_viewset import ContactMethodViewSet +from apps.company.views.person_views import ( + CompanyPeopleView, + CompanyPersonPhoneOwnershipView, +) +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", + ContactMethodViewSet, + basename="contact-method", +) +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( + "/people/", + CompanyPeopleView.as_view(), + name="company_people_rest", + ), + path( + "/people/phone-ownership/", + CompanyPersonPhoneOwnershipView.as_view(), + name="company_person_phone_ownership_rest", + ), + path( + "/supplier-aliases/", + CompanySupplierAliasListCreateView.as_view(), + name="company_supplier_aliases_rest", + ), + path( + "supplier-aliases//", + SupplierAliasDetailView.as_view(), + name="supplier_alias_detail_rest", + ), + # Job person REST endpoint + path( + "jobs//person/", + JobPersonRestView.as_view(), + name="job_person_rest", + ), + # Address validation endpoint + path( + "addresses/validate/", + AddressValidateView.as_view(), + name="address_validate", + ), + # ViewSet routes (people/contact method 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/company/views/__init__.py b/apps/company/views/__init__.py new file mode 100644 index 000000000..8f75491e6 --- /dev/null +++ b/apps/company/views/__init__.py @@ -0,0 +1,62 @@ +# This file is autogenerated by update_init.py script + +from .address_views import AddressValidateView +from .company_rest_views import ( + CompanyCreateRestView, + CompanyJobsRestView, + CompanyListAllRestView, + CompanyRetrieveRestView, + CompanySearchRestView, + CompanyUpdateRestView, + JobPersonRestView, +) +from .person_views import ( + CompanyPeopleView, + CompanyPersonPhoneOwnershipView, + PersonArchiveView, + PersonCompanyLinkDetailView, + PersonCompanyLinksView, + PersonContactMethodDetailView, + PersonContactMethodsView, + PersonDetailView, + PersonListView, +) +from .supplier_search_alias_views import ( + CompanySupplierAliasListCreateView, + SupplierAliasDetailView, +) + +# Conditional imports (only when Django is ready) +try: + from django.apps import apps + + if apps.ready: + from .contact_method_viewset import ContactMethodViewSet + from .supplier_pickup_address_viewset import SupplierPickupAddressViewSet +except (ImportError, RuntimeError): + # Django not ready or circular import, skip conditional imports + pass + +__all__ = [ + "AddressValidateView", + "CompanyCreateRestView", + "CompanyJobsRestView", + "CompanyListAllRestView", + "CompanyPeopleView", + "CompanyPersonPhoneOwnershipView", + "CompanyRetrieveRestView", + "CompanySearchRestView", + "CompanySupplierAliasListCreateView", + "CompanyUpdateRestView", + "ContactMethodViewSet", + "JobPersonRestView", + "PersonArchiveView", + "PersonCompanyLinkDetailView", + "PersonCompanyLinksView", + "PersonContactMethodDetailView", + "PersonContactMethodsView", + "PersonDetailView", + "PersonListView", + "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 54% rename from apps/client/views/client_rest_views.py rename to apps/company/views/company_rest_views.py index caa4c0738..37d16f688 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 @@ -10,6 +10,7 @@ import logging from typing import Any, Dict +from uuid import UUID from drf_spectacular.utils import ( OpenApiParameter, @@ -23,23 +24,24 @@ from rest_framework.response import Response from rest_framework.views import APIView -from apps.client.models import Client, ClientContactMethod -from apps.client.serializers import ( - ClientCreateResponseSerializer, - ClientCreateSerializer, - ClientDetailResponseSerializer, - ClientDuplicateErrorResponseSerializer, - ClientErrorResponseSerializer, - ClientJobsResponseSerializer, - ClientListResponseSerializer, - ClientNameOnlySerializer, - ClientSearchResponseSerializer, - ClientUpdateResponseSerializer, - ClientUpdateSerializer, - JobContactResponseSerializer, - JobContactUpdateSerializer, +from apps.accounts.models import Staff +from apps.company.models import Company, ContactMethod +from apps.company.serializers import ( + CompanyCreateResponseSerializer, + CompanyCreateSerializer, + CompanyDetailResponseSerializer, + CompanyDuplicateErrorResponseSerializer, + CompanyErrorResponseSerializer, + CompanyJobsResponseSerializer, + CompanyListResponseSerializer, + CompanyNameOnlySerializer, + CompanySearchResponseSerializer, + CompanyUpdateResponseSerializer, + CompanyUpdateSerializer, + JobPersonResponseSerializer, + JobPersonUpdateSerializer, ) -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 +69,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 +148,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 +186,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 +194,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 +349,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,40 +435,40 @@ 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() + created_company = CompanyRestService.create_company(validated_data) + created_company = ( + Company.objects.with_invoice_summary() .annotate( - phone=ClientContactMethod.primary_phone_annotation( - owner="client", outer_ref="pk" + phone=ContactMethod.primary_phone_annotation( + owner="company", outer_ref="pk" ) ) - .get(id=created_client.id) + .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] @@ -469,7 +477,7 @@ def post(self, request: Request) -> Response: ), }, } - error_serializer = ClientDuplicateErrorResponseSerializer( + error_serializer = CompanyDuplicateErrorResponseSerializer( data=duplicate_error_data ) error_serializer.is_valid(raise_exception=True) @@ -479,26 +487,28 @@ 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 ) @extend_schema_view( get=extend_schema( - summary="Get job contact", - description="Retrieve contact information for a specific job.", + summary="Get job person", + description="Retrieve person information for a specific job.", parameters=[ OpenApiParameter( name="job_id", @@ -509,15 +519,15 @@ def post(self, request: Request) -> Response: ) ], responses={ - 200: JobContactResponseSerializer, - 404: ClientErrorResponseSerializer, - 500: ClientErrorResponseSerializer, + 200: JobPersonResponseSerializer, + 404: CompanyErrorResponseSerializer, + 500: CompanyErrorResponseSerializer, }, - tags=["Clients"], + tags=["Companies"], ), put=extend_schema( - summary="Update job contact", - description="Update the contact person associated with a specific job.", + summary="Update job person", + description="Update the person associated with a specific job.", parameters=[ OpenApiParameter( name="job_id", @@ -527,40 +537,40 @@ def post(self, request: Request) -> Response: type=OpenApiTypes.UUID, ) ], - request=JobContactUpdateSerializer, + request=JobPersonUpdateSerializer, responses={ - 200: JobContactResponseSerializer, - 400: ClientErrorResponseSerializer, - 404: ClientErrorResponseSerializer, - 500: ClientErrorResponseSerializer, + 200: JobPersonResponseSerializer, + 400: CompanyErrorResponseSerializer, + 404: CompanyErrorResponseSerializer, + 500: CompanyErrorResponseSerializer, }, - tags=["Clients"], - operation_id="clients_jobs_contact_update", + tags=["Companies"], + operation_id="companies_jobs_person_update", ), ) -class JobContactRestView(APIView): +class JobPersonRestView(APIView): """ - REST view for contact information operations for a job. - Handles both retrieving and updating the contact associated with a specific job. + REST view for person information operations for a job. + Handles both retrieving and updating the person associated with a specific job. """ permission_classes = [IsAuthenticated] - serializer_class = JobContactResponseSerializer + serializer_class = JobPersonResponseSerializer def get_serializer_class(self): """Return the appropriate serializer class based on the request method""" if self.request.method == "PUT": - return JobContactUpdateSerializer - return JobContactResponseSerializer + return JobPersonUpdateSerializer + return JobPersonResponseSerializer - def get(self, request: Request, job_id: str) -> Response: + def get(self, request: Request, job_id: UUID) -> Response: """ - Retrieves contact information for a specific job. + Retrieves person information for a specific job. """ 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) @@ -568,28 +578,28 @@ 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) - serializer = JobContactResponseSerializer(data=contact_data) + person_data = CompanyRestService.get_job_person(job_id) + serializer = JobPersonResponseSerializer(data=person_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: return _build_server_error_response( - message="Error retrieving job contact", exc=exc + message="Error retrieving job person", exc=exc ) - def put(self, request: Request, job_id: str) -> Response: + def put(self, request: Request, job_id: UUID) -> Response: """ - Updates the contact person for a specific job. + Updates the person for a specific job. """ 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) @@ -598,9 +608,9 @@ def put(self, request: Request, job_id: str) -> Response: ) # Validate input data - input_serializer = JobContactUpdateSerializer(data=request.data) + input_serializer = JobPersonUpdateSerializer(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) @@ -608,83 +618,91 @@ def put(self, request: Request, job_id: str) -> Response: error_serializer.data, status=status.HTTP_400_BAD_REQUEST ) - contact_data = input_serializer.validated_data - updated_contact = ClientRestService.update_job_contact( - job_id, contact_data, request.user + person_data = input_serializer.validated_data + if not isinstance(request.user, Staff): + error_serializer = CompanyErrorResponseSerializer( + data={"error": "Authentication required"} + ) + error_serializer.is_valid(raise_exception=True) + return Response( + error_serializer.data, status=status.HTTP_401_UNAUTHORIZED + ) + updated_person = CompanyRestService.update_job_person( + job_id, person_data, request.user ) - serializer = JobContactResponseSerializer(data=updated_contact) + serializer = JobPersonResponseSerializer(data=updated_person) 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: return _build_server_error_response( - message="Error updating job contact", exc=exc + message="Error updating job person", exc=exc ) @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 61% rename from apps/client/views/client_contact_method_viewset.py rename to apps/company/views/contact_method_viewset.py index ec30e4d8d..669e8d9b1 100644 --- a/apps/client/views/client_contact_method_viewset.py +++ b/apps/company/views/contact_method_viewset.py @@ -1,46 +1,47 @@ -"""Client/contact contact method ViewSet.""" +"""Company/person contact method ViewSet.""" from typing import cast +from django.db.models import Prefetch from drf_spectacular.types import OpenApiTypes from drf_spectacular.utils import OpenApiParameter, extend_schema 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 CompanyPersonLink, ContactMethod +from apps.company.serializers import ContactMethodSerializer from apps.crm.tasks import rematch_phone_calls_task from apps.workflow.api.pagination import PageSizePagination -def _phone_number_for_rematch(method: ClientContactMethod | None) -> str | None: +def _phone_number_for_rematch(method: ContactMethod | None) -> str | None: if method is None: return None - if method.method_type != ClientContactMethod.MethodType.PHONE: + if method.method_type != ContactMethod.MethodType.PHONE: return None normalized = method.normalized_value if normalized: return normalized - return ClientContactMethod.normalize_phone(method.value) + return ContactMethod.normalize_phone(method.value) -class ClientContactMethodViewSet(viewsets.ModelViewSet): - """CRUD API for canonical client/contact phone and email methods.""" +class ContactMethodViewSet(viewsets.ModelViewSet): + """CRUD API for canonical company/person phone and email methods.""" - serializer_class = ClientContactMethodSerializer + serializer_class = ContactMethodSerializer permission_classes = [permissions.IsAuthenticated] pagination_class = PageSizePagination @extend_schema( parameters=[ OpenApiParameter( - name="client_id", + name="company_id", type=OpenApiTypes.UUID, location=OpenApiParameter.QUERY, required=False, ), OpenApiParameter( - name="contact_id", + name="person_id", type=OpenApiTypes.UUID, location=OpenApiParameter.QUERY, required=False, @@ -69,20 +70,27 @@ def list(self, request, *args, **kwargs): return super().list(request, *args, **kwargs) def get_queryset(self): - queryset = ClientContactMethod.objects.select_related( - "client", - "contact", - "contact__client", + queryset = ContactMethod.objects.select_related( + "company", + "person", + ).prefetch_related( + Prefetch( + "person__company_links", + queryset=CompanyPersonLink.objects.filter( + is_active=True + ).select_related("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( + person__company_links__company_id=company_id, + person__company_links__is_active=True, ) - contact_id = self.request.query_params.get("contact_id") - if contact_id: - queryset = queryset.filter(contact_id=contact_id) + person_id = self.request.query_params.get("person_id") + if person_id: + queryset = queryset.filter(person_id=person_id) method_type = self.request.query_params.get("method_type") if method_type: @@ -90,14 +98,14 @@ def get_queryset(self): return queryset.distinct().order_by("method_type", "-is_primary", "value") - def perform_create(self, serializer: BaseSerializer[ClientContactMethod]) -> None: + def perform_create(self, serializer: BaseSerializer[ContactMethod]) -> None: method = serializer.save() phone_number = _phone_number_for_rematch(method) if phone_number: rematch_phone_calls_task.delay([phone_number]) - def perform_update(self, serializer: BaseSerializer[ClientContactMethod]) -> None: - old_method = cast(ClientContactMethod, self.get_object()) + def perform_update(self, serializer: BaseSerializer[ContactMethod]) -> None: + old_method = cast(ContactMethod, self.get_object()) old_phone_number = _phone_number_for_rematch(old_method) method = serializer.save() new_phone_number = _phone_number_for_rematch(method) @@ -107,7 +115,7 @@ def perform_update(self, serializer: BaseSerializer[ClientContactMethod]) -> Non if phone_numbers: rematch_phone_calls_task.delay(phone_numbers) - def perform_destroy(self, instance: ClientContactMethod) -> None: + def perform_destroy(self, instance: ContactMethod) -> None: phone_number = _phone_number_for_rematch(instance) instance.delete() if phone_number: diff --git a/apps/company/views/person_views.py b/apps/company/views/person_views.py new file mode 100644 index 000000000..892e44dc4 --- /dev/null +++ b/apps/company/views/person_views.py @@ -0,0 +1,316 @@ +"""First-class Person directory, relationship, and contact-method APIs.""" + +from typing import cast + +from django.db import transaction +from django.db.models import QuerySet +from django.shortcuts import get_object_or_404 +from drf_spectacular.types import OpenApiTypes +from drf_spectacular.utils import OpenApiParameter, extend_schema +from rest_framework import generics, permissions, serializers, status +from rest_framework.exceptions import ValidationError +from rest_framework.request import Request +from rest_framework.response import Response +from rest_framework.views import APIView + +from apps.company.models import Company, CompanyPersonLink, ContactMethod, Person +from apps.company.person_serializers import ( + CompanyLinkWriteSerializer, + CompanyPersonCreateSerializer, + CompanyPersonSerializer, + PersonCompanyLinkSerializer, + PersonContactMethodWriteSerializer, + PersonDetailSerializer, + PersonIdentityUpdateSerializer, + PersonSummarySerializer, + PhoneOwnershipConflictSerializer, + PhoneOwnershipRequestSerializer, + PhoneOwnershipSerializer, +) +from apps.company.serializers import ContactMethodSerializer +from apps.company.services.person_service import ( + CompanyLinkData, + NewPersonData, + PersonCompanyLinkData, + PersonDirectoryService, + PersonPhoneConflictError, + archive_person, + classify_phone_ownership, + create_person_for_company, + put_company_link, + remove_company_link, +) +from apps.crm.tasks import rematch_phone_calls_task +from apps.job.permissions import IsOfficeStaff +from apps.workflow.api.pagination import PageSizePagination + +PERSON_PERMISSIONS = [permissions.IsAuthenticated, IsOfficeStaff] + + +class PersonListView(generics.ListAPIView[Person]): + """List and search active people across company relationships.""" + + serializer_class = PersonSummarySerializer + permission_classes = PERSON_PERMISSIONS + pagination_class = PageSizePagination + + @extend_schema( + parameters=[ + OpenApiParameter( + name="q", + type=OpenApiTypes.STR, + location=OpenApiParameter.QUERY, + description="Search people by name, email, phone, or company.", + ), + OpenApiParameter( + name="include_archived", + type=OpenApiTypes.BOOL, + location=OpenApiParameter.QUERY, + description="Include archived (inactive) people in the results.", + ), + ] + ) + def get(self, request: Request, *args: object, **kwargs: object) -> Response: + return super().get(request, *args, **kwargs) + + def get_queryset(self) -> QuerySet[Person]: + include_archived = ( + self.request.query_params.get("include_archived", "").lower() == "true" + ) + return PersonDirectoryService.search( + self.request.query_params.get("q", ""), + include_archived=include_archived, + ) + + +class PersonDetailView(generics.RetrieveUpdateAPIView[Person]): + """Retrieve or update a Person's identity fields.""" + + queryset = Person.objects.all() + permission_classes = PERSON_PERMISSIONS + lookup_url_kwarg = "person_id" + + def get_serializer_class(self) -> type[serializers.BaseSerializer[Person]]: + if self.request.method in {"PUT", "PATCH"}: + return PersonIdentityUpdateSerializer + return PersonDetailSerializer + + def update(self, request: Request, *args: object, **kwargs: object) -> Response: + super().update(request, *args, **kwargs) + person = self.get_object() + return Response(PersonDetailSerializer(person).data) + + +class CompanyPeopleView(APIView): + """List a company's people or create a Person with its initial link.""" + + permission_classes = PERSON_PERMISSIONS + + @extend_schema(responses={200: CompanyPersonSerializer(many=True)}) + def get(self, request: Request, company_id: str) -> Response: + company = get_object_or_404(Company, id=company_id) + links = ( + CompanyPersonLink.objects.filter(company=company, is_active=True) + .select_related("person") + .annotate(phone=ContactMethod.primary_phone_for_link_annotation()) + .order_by("-is_primary", "person__name") + ) + return Response(CompanyPersonSerializer(links, many=True).data) + + @extend_schema( + request=CompanyPersonCreateSerializer, + responses={ + 201: CompanyPersonSerializer, + 409: PhoneOwnershipConflictSerializer, + }, + ) + def post(self, request: Request, company_id: str) -> Response: + company = get_object_or_404(Company, id=company_id) + payload = CompanyPersonCreateSerializer(data=request.data) + payload.is_valid(raise_exception=True) + data = cast(NewPersonData, payload.validated_data) + try: + link = create_person_for_company(company=company, data=data) + except PersonPhoneConflictError as exc: + response = PhoneOwnershipConflictSerializer(data=exc.ownership) + response.is_valid(raise_exception=True) + return Response(response.data, status=status.HTTP_409_CONFLICT) + link = ( + CompanyPersonLink.objects.select_related("person") + .annotate(phone=ContactMethod.primary_phone_for_link_annotation()) + .get(pk=link.pk) + ) + return Response( + CompanyPersonSerializer(link).data, status=status.HTTP_201_CREATED + ) + + +class CompanyPersonPhoneOwnershipView(APIView): + """Classify a phone before creating a Person for a company.""" + + permission_classes = PERSON_PERMISSIONS + + @extend_schema( + request=PhoneOwnershipRequestSerializer, + responses={200: PhoneOwnershipSerializer}, + ) + def post(self, request: Request, company_id: str) -> Response: + company = get_object_or_404(Company, id=company_id) + payload = PhoneOwnershipRequestSerializer(data=request.data) + payload.is_valid(raise_exception=True) + result = classify_phone_ownership( + company=company, raw_phone=payload.validated_data["phone"] + ) + return Response(PhoneOwnershipSerializer(result).data) + + +class PersonCompanyLinksView(APIView): + """List all active company relationships for a Person.""" + + permission_classes = PERSON_PERMISSIONS + + @extend_schema(responses={200: PersonCompanyLinkSerializer(many=True)}) + def get(self, request: Request, person_id: str) -> Response: + person = get_object_or_404(Person, id=person_id) + links = PersonDirectoryService.company_links(person) + return Response(PersonCompanyLinkSerializer(links, many=True).data) + + +class PersonCompanyLinkDetailView(APIView): + """Create, update, reactivate, or remove a Person-company relationship.""" + + permission_classes = PERSON_PERMISSIONS + + @extend_schema( + request=CompanyLinkWriteSerializer, + responses={200: PersonCompanyLinkSerializer}, + ) + def put(self, request: Request, person_id: str, company_id: str) -> Response: + person = get_object_or_404(Person, id=person_id) + company = get_object_or_404(Company, id=company_id) + payload = CompanyLinkWriteSerializer(data=request.data) + payload.is_valid(raise_exception=True) + link = put_company_link( + person=person, + company=company, + data=cast(CompanyLinkData, payload.validated_data), + ) + response_data: PersonCompanyLinkData = { + "company_id": str(link.company_id), + "company_name": company.name, + "position": link.position, + "is_primary": link.is_primary, + "notes": link.notes, + "is_active": link.is_active, + } + return Response(PersonCompanyLinkSerializer(response_data).data) + + @extend_schema(responses={204: None}) + def delete(self, request: Request, person_id: str, company_id: str) -> Response: + person = get_object_or_404(Person, id=person_id) + company = get_object_or_404(Company, id=company_id) + try: + remove_company_link(person=person, company=company) + except ValueError as exc: + raise ValidationError({"company_link": [str(exc)]}) from exc + return Response(status=status.HTTP_204_NO_CONTENT) + + +class PersonArchiveView(APIView): + """Explicitly retire a person (deactivate all links + archive).""" + + permission_classes = PERSON_PERMISSIONS + + @extend_schema(request=None, responses={200: PersonDetailSerializer}) + def post(self, request: Request, person_id: str) -> Response: + person = get_object_or_404(Person, id=person_id) + archive_person(person=person) + person.refresh_from_db() + return Response(PersonDetailSerializer(person).data) + + +def _schedule_contact_method_rematch(method: ContactMethod) -> None: + if method.method_type != ContactMethod.MethodType.PHONE: + return + normalized = method.normalized_value + transaction.on_commit(lambda: rematch_phone_calls_task.delay([normalized])) + + +class PersonContactMethodsView(APIView): + """List or create a Person's contact methods.""" + + permission_classes = PERSON_PERMISSIONS + + @extend_schema(responses={200: ContactMethodSerializer(many=True)}) + def get(self, request: Request, person_id: str) -> Response: + person = get_object_or_404(Person, id=person_id) + methods = person.contact_methods.order_by( + "method_type", "-is_primary", "label", "value" + ) + return Response(ContactMethodSerializer(methods, many=True).data) + + @extend_schema( + request=PersonContactMethodWriteSerializer, + responses={201: ContactMethodSerializer}, + ) + def post(self, request: Request, person_id: str) -> Response: + person = get_object_or_404(Person, id=person_id) + payload = PersonContactMethodWriteSerializer(data=request.data) + payload.is_valid(raise_exception=True) + serializer = ContactMethodSerializer( + data={ + **payload.validated_data, + "company": None, + "person": str(person.id), + "source": ContactMethod.Source.LOCAL, + } + ) + serializer.is_valid(raise_exception=True) + method = serializer.save() + _schedule_contact_method_rematch(method) + return Response( + ContactMethodSerializer(method).data, status=status.HTTP_201_CREATED + ) + + +class PersonContactMethodDetailView(APIView): + """Update or delete one Person contact method.""" + + permission_classes = PERSON_PERMISSIONS + + @extend_schema( + request=PersonContactMethodWriteSerializer, + responses={200: ContactMethodSerializer}, + ) + def patch(self, request: Request, person_id: str, method_id: str) -> Response: + method = get_object_or_404(ContactMethod, id=method_id, person_id=person_id) + payload = PersonContactMethodWriteSerializer(data=request.data, partial=True) + payload.is_valid(raise_exception=True) + serializer = ContactMethodSerializer( + method, data=payload.validated_data, partial=True + ) + serializer.is_valid(raise_exception=True) + old_normalized = method.normalized_value + old_method_type = method.method_type + updated = serializer.save() + numbers: set[str] = set() + if old_method_type == ContactMethod.MethodType.PHONE: + numbers.add(old_normalized) + if updated.method_type == ContactMethod.MethodType.PHONE: + numbers.add(updated.normalized_value) + if numbers: + sorted_numbers = sorted(numbers) + transaction.on_commit( + lambda: rematch_phone_calls_task.delay(sorted_numbers) + ) + return Response(ContactMethodSerializer(updated).data) + + @extend_schema(responses={204: None}) + def delete(self, request: Request, person_id: str, method_id: str) -> Response: + method = get_object_or_404(ContactMethod, id=method_id, person_id=person_id) + normalized = method.normalized_value + is_phone = method.method_type == ContactMethod.MethodType.PHONE + method.delete() + if is_phone: + transaction.on_commit(lambda: rematch_phone_calls_task.delay([normalized])) + return Response(status=status.HTTP_204_NO_CONTENT) 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 b044779fc..71e3b6941 100644 --- a/apps/crm/migrations/0001_baseline.py +++ b/apps/crm/migrations/0001_baseline.py @@ -12,18 +12,8 @@ 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"), + ("company", "0001_baseline"), ("job", "0001_baseline"), migrations.swappable_dependency(settings.AUTH_USER_MODEL), ] @@ -177,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", ), ), ( @@ -187,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/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/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/migrations/0005_phonecallrecord_person_alter_phonecallrecord_contact.py b/apps/crm/migrations/0005_phonecallrecord_person_alter_phonecallrecord_contact.py new file mode 100644 index 000000000..fa383148d --- /dev/null +++ b/apps/crm/migrations/0005_phonecallrecord_person_alter_phonecallrecord_contact.py @@ -0,0 +1,37 @@ +# Generated by Django 6.0.4 on 2026-07-08 21:59 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("company", "0004_person_link_structure"), + ("crm", "0004_rename_client_company"), + ] + + operations = [ + migrations.AddField( + model_name="phonecallrecord", + name="person", + field=models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="person_phone_calls", + to="company.person", + ), + ), + migrations.AlterField( + model_name="phonecallrecord", + name="contact", + field=models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="phone_calls", + to="company.companypersonlink", + ), + ), + ] diff --git a/apps/crm/migrations/0006_remove_phonecallrecord_contact_and_more.py b/apps/crm/migrations/0006_remove_phonecallrecord_contact_and_more.py new file mode 100644 index 000000000..12a1d148d --- /dev/null +++ b/apps/crm/migrations/0006_remove_phonecallrecord_contact_and_more.py @@ -0,0 +1,62 @@ +# Generated by Django 6.0.4 on 2026-07-08 22:47 + +from typing import Any + +import django.db.models.deletion +from django.db import migrations, models + + +def repair_call_people(apps: Any, schema_editor: Any) -> None: + PhoneCallRecord = apps.get_model("crm", "PhoneCallRecord") + CompanyPersonLink = apps.get_model("company", "CompanyPersonLink") + + link_person_by_id = dict( + CompanyPersonLink.objects.exclude(person__isnull=True).values_list( + "id", "person_id" + ) + ) + calls = PhoneCallRecord.objects.filter(person__isnull=True).exclude( + contact__isnull=True + ) + for call in calls.iterator(): + person_id = link_person_by_id.get(call.contact_id) + if person_id is not None: + call.person_id = person_id + call.save(update_fields=["person"]) + + +class Migration(migrations.Migration): + + dependencies = [ + ("company", "0006_alter_companypersonlink_options_and_more"), + ("crm", "0005_phonecallrecord_person_alter_phonecallrecord_contact"), + ] + + operations = [ + migrations.RunPython(repair_call_people, migrations.RunPython.noop), + migrations.RemoveIndex( + model_name="phonecallrecord", + name="crm_phone_contact_call_idx", + ), + migrations.RemoveField( + model_name="phonecallrecord", + name="contact", + ), + migrations.AlterField( + model_name="phonecallrecord", + name="person", + field=models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="phone_calls", + to="company.person", + ), + ), + migrations.AddIndex( + model_name="phonecallrecord", + index=models.Index( + fields=["person", "-call_datetime"], name="crm_phone_person_call_idx" + ), + ), + ] diff --git a/apps/crm/models.py b/apps/crm/models.py index 40df06676..b507fafb0 100644 --- a/apps/crm/models.py +++ b/apps/crm/models.py @@ -61,18 +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 ContactMethod - self.normalized_number = ClientContactMethod.normalize_phone(self.number) + self.normalized_number = ContactMethod.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( - self.normalized_number, None - ) + # Mirror of the ContactMethod.save() guard: a number cannot be + # both a company contact method and an active internal endpoint, or + # the company's calls would silently reclassify as INTERNAL. + conflict = ContactMethod.conflicting_company(self.normalized_number, set()) if conflict: raise ValidationError( f"phone number {self.normalized_number} already belongs to " @@ -90,7 +88,7 @@ def _active_number_changed(self) -> bool: """True when the endpoint is new or its number/is_active changed. Grandfathers pre-existing rows (symmetry with the grandfathering in - ClientContactMethod.check_phone_assignment): re-saving an existing + ContactMethod.check_phone_assignment): re-saving an existing endpoint without touching number or is_active must not start failing. """ if self._state.adding: @@ -181,15 +179,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", + person = models.ForeignKey( + "company.Person", on_delete=models.SET_NULL, null=True, blank=True, @@ -222,12 +220,12 @@ 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"], - name="crm_phone_contact_call_idx", + fields=["person", "-call_datetime"], + name="crm_phone_person_call_idx", ), models.Index( fields=["job", "-call_datetime"], @@ -263,12 +261,10 @@ def save( using: str | None = None, update_fields: Iterable[str] | None = None, ) -> None: - from apps.client.models import ClientContactMethod + from apps.company.models import ContactMethod - self.normalized_origin = ClientContactMethod.normalize_phone(self.origin) - self.normalized_destination = ClientContactMethod.normalize_phone( - self.destination - ) + self.normalized_origin = ContactMethod.normalize_phone(self.origin) + self.normalized_destination = ContactMethod.normalize_phone(self.destination) if update_fields is not None: fields = set(update_fields) if "origin" in fields: diff --git a/apps/crm/serializers.py b/apps/crm/serializers.py index 933059ffc..5e9b3056f 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 ContactMethod from apps.crm.models import ( PhoneCallRecord, PhoneCallRecording, @@ -25,10 +25,10 @@ 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() - contact = serializers.UUIDField(required=False, allow_null=True, default=None) + company = serializers.UUIDField() + person = serializers.UUIDField(required=False, allow_null=True, default=None) is_primary = serializers.BooleanField(required=False, default=False) def get_fields(self) -> dict[str, "serializers.Field[Any, Any, Any, Any]"]: @@ -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 = ContactMethod.conflicting_company(normalized, set()) if conflict: raise serializers.ValidationError( { @@ -242,8 +242,8 @@ def get_has_password(self, obj: PhoneProviderSettings) -> bool: class PhoneCallRecordSerializer(serializers.ModelSerializer[PhoneCallRecord]): recording = serializers.SerializerMethodField() - client_name = serializers.SerializerMethodField() - contact_name = serializers.SerializerMethodField() + company_name = serializers.SerializerMethodField() + person_name = serializers.SerializerMethodField() origin_endpoint_label = serializers.SerializerMethodField() destination_endpoint_label = serializers.SerializerMethodField() job_number = serializers.SerializerMethodField() @@ -273,10 +273,10 @@ class Meta: "destination_endpoint_label", "duration_seconds", "charge", - "client", - "client_name", - "contact", - "contact_name", + "company", + "company_name", + "person", + "person_name", "job", "job_number", "job_name", @@ -289,11 +289,11 @@ 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 "" + def get_person_name(self, obj: PhoneCallRecord) -> str: + return obj.person.name if obj.person else "" def get_origin_endpoint_label(self, obj: PhoneCallRecord) -> str: return obj.origin_endpoint.label if obj.origin_endpoint else "" diff --git a/apps/crm/services/phone_call_service.py b/apps/crm/services/phone_call_service.py index 13fa775ab..6c344d1a8 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 Company, ContactMethod, Person 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,8 +309,8 @@ def unlink_phone_call_job(*, call_id: str) -> PhoneCallRecord: def assign_phone_number_from_call( *, call_id: str, - client_id: str, - contact_id: str | None = None, + company_id: str, + person_id: str | None = None, label: str = "", is_primary: bool = False, ) -> PhoneCallRecord: @@ -325,8 +325,8 @@ def assign_phone_number_from_call( assign_phone_number( phone_number=call.external_number, - client_id=client_id, - contact_id=contact_id, + company_id=company_id, + person_id=person_id, label=label, is_primary=is_primary, ) @@ -524,8 +524,8 @@ 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, - "contact": classification.contact, + "company": classification.company, + "person": classification.person, "raw_json": payload, } call = PhoneCallRecord.objects.filter(provider_call_id=provider_call_id).first() @@ -595,12 +595,12 @@ class CallClassification: external_number: str origin_endpoint: PhoneEndpoint | None destination_endpoint: PhoneEndpoint | None - client: Client | None - contact: ClientContact | None + company: Company | None + person: Person | 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 @@ -615,9 +615,7 @@ def __init__(self, numbers: set[str] | None = None): for endpoint in PhoneEndpoint.objects.filter(is_active=True) } - def match_customer( - self, *values: str - ) -> tuple[Client | None, ClientContact | None]: + def match_customer(self, *values: str) -> tuple[Company | None, Person | None]: matches: set[tuple[str, str, str]] = set() for value in values: normalized = normalize_phone(value) @@ -628,20 +626,21 @@ 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)) - 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 + kind, object_id, _company_id = next(iter(matches)) + if kind == "person": + person = Person.objects.get(id=object_id) + company = Company.objects.get(id=next(iter(company_ids))) + return company, person + 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 +655,35 @@ def classify(self, origin: str, destination: str) -> CallClassification: external_number="", origin_endpoint=origin_endpoint, destination_endpoint=destination_endpoint, - client=None, - contact=None, + company=None, + person=None, ) if origin_endpoint: - client, contact = self.match_customer(normalized_destination) + company, person = 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, - contact=contact, + company=company, + person=person, ) if destination_endpoint: - client, contact = self.match_customer(normalized_origin) + company, person = 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, - contact=contact, + company=company, + person=person, ) - client, contact = self.match_customer(normalized_origin, normalized_destination) + company, person = self.match_customer(normalized_origin, normalized_destination) external_number = "" if normalized_origin and not normalized_destination: external_number = normalized_origin @@ -698,8 +697,8 @@ def classify(self, origin: str, destination: str) -> CallClassification: external_number=external_number, origin_endpoint=None, destination_endpoint=None, - client=client, - contact=contact, + company=company, + person=person, ) @@ -712,7 +711,7 @@ def is_call_payload(payload: dict[str, Any]) -> bool: def normalize_phone(value: Any) -> str: - return ClientContactMethod.normalize_phone(value) + return ContactMethod.normalize_phone(value) def configured_own_numbers() -> set[str]: @@ -724,7 +723,7 @@ def configured_own_numbers() -> set[str]: _REMATCH_FK_FIELDS = frozenset( - {"client", "contact", "origin_endpoint", "destination_endpoint"} + {"company", "person", "origin_endpoint", "destination_endpoint"} ) @@ -760,10 +759,12 @@ 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, - "contact": classification.contact, + "company": classification.company, + "person": classification.person, "direction": classification.direction, "our_number": classification.our_number, "external_number": classification.external_number, @@ -780,14 +781,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,69 +797,73 @@ def rematch_calls_for_numbers(numbers: list[str]) -> None: def assign_phone_number( *, phone_number: str, - client_id: str, - contact_id: str | None = None, + company_id: str, + person_id: str | None = None, label: str = "", is_primary: bool = False, -) -> ClientContactMethod: +) -> ContactMethod: normalized = normalize_phone(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") - if contact_id: - contact_uuid = _uuid_or_client_error(contact_id, "Contact not found") + company_uuid = _uuid_or_client_error(company_id, "Company not found") + owner_filter: dict[str, Company | Person | None] + if person_id: + person_uuid = _uuid_or_client_error(person_id, "Person not found") try: - contact = ClientContact.objects.select_related("client").get( - id=contact_uuid, - client_id=client_uuid, - is_active=True, - ) - except ClientContact.DoesNotExist as exc: - raise ValueError("Contact not found") from exc - owner_filter = {"contact": contact, "client": None} - existing_primary = ClientContactMethod.objects.filter( - contact=contact, - method_type=ClientContactMethod.MethodType.PHONE, + person = Person.objects.get(id=person_uuid, is_active=True) + except Person.DoesNotExist as exc: + raise ValueError("Person not found") from exc + if not person.company_links.filter( + company_id=company_uuid, is_active=True + ).exists(): + raise ValueError("Person is not linked to the selected company") + owner_filter = { + "person": person, + "company": None, + } + existing_primary = ContactMethod.objects.filter( + person=person, + method_type=ContactMethod.MethodType.PHONE, is_primary=True, ).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} - existing_primary = ClientContactMethod.objects.filter( - client=client, - contact__isnull=True, - method_type=ClientContactMethod.MethodType.PHONE, + company = Company.objects.get(id=company_uuid) + except Company.DoesNotExist as exc: + raise ValueError("Company not found") from exc + owner_filter = {"company": company, "person": None} + existing_primary = ContactMethod.objects.filter( + company=company, + person__isnull=True, + method_type=ContactMethod.MethodType.PHONE, is_primary=True, ).exists() should_be_primary = is_primary or not existing_primary - conflict = ClientContactMethod.conflicting_client(normalized, client_uuid) + conflict = ContactMethod.conflicting_company(normalized, {company_uuid}) if conflict: raise ValueError( f"phone number already belongs to {conflict.owner_display_name()}" ) - method, created = ClientContactMethod.objects.get_or_create( + method, created = ContactMethod.objects.get_or_create( **owner_filter, - method_type=ClientContactMethod.MethodType.PHONE, + method_type=ContactMethod.MethodType.PHONE, normalized_value=normalized, defaults={ "value": phone_number.strip(), "label": label.strip(), "is_primary": should_be_primary, - "source": ClientContactMethod.Source.LOCAL, + "source": ContactMethod.Source.LOCAL, }, ) if not created: method.value = phone_number.strip() method.label = label.strip() - method.source = ClientContactMethod.Source.LOCAL + method.source = ContactMethod.Source.LOCAL method.is_primary = should_be_primary or method.is_primary method.save( update_fields=["value", "label", "source", "is_primary", "updated_at"] @@ -878,28 +883,35 @@ 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", - "contact", - "contact__client", - ).filter(method_type=ClientContactMethod.MethodType.PHONE) + methods = ContactMethod.objects.select_related( + "company", + "person", + ).filter(method_type=ContactMethod.MethodType.PHONE) if numbers is not None: methods = methods.filter(normalized_value__in=numbers) for method in methods: normalized = method.normalized_value or normalize_phone(method.value) if not normalized or normalized in internal_numbers: continue - if method.contact_id: - if not method.contact.is_active: + if method.person_id: + person = method.person + if person is None: + raise RuntimeError(f"Contact method {method.id} has no person") + if not person.is_active: continue - index.setdefault(normalized, set()).add( - ("contact", str(method.contact_id), str(method.contact.client_id)) - ) + company_ids = method.owner_company_ids() + for company_id in company_ids: + index.setdefault(normalized, set()).add( + ("person", str(method.person_id), str(company_id)) + ) else: - if not method.client.allow_jobs: + company = method.company + if company is None: + raise RuntimeError(f"Contact method {method.id} has no company") + if not 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..5f962d779 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 Company, CompanyPersonLink, ContactMethod, Person from apps.crm.models import ( PhoneCallRecord, PhoneCallRecording, @@ -83,110 +83,117 @@ 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 person. """ - 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 _link(self, company: Company, name: str) -> CompanyPersonLink: + person = Person.objects.create(name=name) + return CompanyPersonLink.objects.create( + company=company, + person=person, + ) 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") + person = self._link(company, "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), - contact_id=str(contact.id), + company_id=str(company.id), + person_id=str(person.person_id), ) - self.assertEqual(method.contact_id, contact.id) + self.assertEqual(method.person_id, person.person_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") - ClientContactMethod.objects.create( - contact=contact, - method_type=ClientContactMethod.MethodType.PHONE, + company = self._client("Acme Ltd") + person = self._link(company, "Jane Smith") + ContactMethod.objects.create( + person=person.person, + method_type=ContactMethod.MethodType.PHONE, value="021 555 123", 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_contact, contact) + self.assertEqual(matched_company, company) + self.assertEqual(matched_contact, person.person) 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") - ClientContactMethod.objects.create( - contact=jane, - method_type=ClientContactMethod.MethodType.PHONE, + """Two contacts of one company resolve to a single effective company owner.""" + company = self._client("Acme Ltd") + jane = self._link(company, "Jane Smith") + john = self._link(company, "John Smith") + ContactMethod.objects.create( + person=jane.person, + method_type=ContactMethod.MethodType.PHONE, value="021 555 123", ) - on_john = ClientContactMethod.objects.create( - contact=john, - method_type=ClientContactMethod.MethodType.PHONE, + on_john = ContactMethod.objects.create( + person=john.person, + method_type=ContactMethod.MethodType.PHONE, 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: + def test_same_number_across_companies_is_rejected(self) -> None: first = self._client("Acme Ltd") second = self._client("Beta Ltd") - ClientContactMethod.objects.create( - client=first, - method_type=ClientContactMethod.MethodType.PHONE, + ContactMethod.objects.create( + company=first, + method_type=ContactMethod.MethodType.PHONE, value="021 555 123", ) with self.assertRaises(ValidationError): - ClientContactMethod.objects.create( - client=second, - method_type=ClientContactMethod.MethodType.PHONE, + ContactMethod.objects.create( + company=second, + method_type=ContactMethod.MethodType.PHONE, value="021 555 123", ) @@ -213,17 +220,17 @@ 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) - ClientContactMethod.objects.create( - client=second, - method_type=ClientContactMethod.MethodType.PHONE, + job = Job.objects.create(company=first, name="Linked Job", staff=staff) + ContactMethod.objects.create( + company=second, + method_type=ContactMethod.MethodType.PHONE, value="+6421555123", normalized_value="+6421555123", ) @@ -236,7 +243,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,8 +257,8 @@ 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.assertIsNone(call.contact) + self.assertEqual(call.company, second) + self.assertIsNone(call.person) self.assertIsNone(call.job) self.assertIsNone(call.job_linked_by) self.assertIsNone(call.job_linked_at) @@ -264,13 +271,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,13 +387,13 @@ 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, - method_type=ClientContactMethod.MethodType.PHONE, + ContactMethod.objects.create( + company=company, + method_type=ContactMethod.MethodType.PHONE, value="021 555 123", ) payload = self._payload() @@ -418,7 +425,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 +475,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 +498,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 +549,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 +594,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,16 +608,16 @@ 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/", - {"client_match": "unmatched"}, + {"company_match": "unmatched"}, ) unlinked_response = self.api.get( "/api/crm/phone-calls/", - {"client_match": "matched", "job_link": "unlinked"}, + {"company_match": "matched", "job_link": "unlinked"}, ) self.assertEqual(unmatched_response.status_code, 200) @@ -655,7 +662,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 +685,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 +694,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 +721,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 +743,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/") @@ -754,7 +761,7 @@ def test_malformed_call_id_on_unlink_does_not_persist_app_error(self) -> None: self.assertIn("Phone call not found", response.data["message"]) self.assertEqual(AppError.objects.count(), before) - def test_assign_number_bad_client_id_does_not_persist_app_error(self) -> None: + def test_assign_number_bad_company_id_does_not_persist_app_error(self) -> None: self.call.external_number = "+6421555000" self.call.save(update_fields=["external_number", "updated_at"]) before = AppError.objects.count() @@ -762,41 +769,44 @@ 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( + def test_assign_number_cross_company_person_does_not_persist_app_error( self, ) -> None: self.call.external_number = "+6421555001" self.call.save(update_fields=["external_number", "updated_at"]) - contact = ClientContact.objects.create( - client=self.other_client, - name="Other Contact", + person = Person.objects.create(name="Other Contact") + CompanyPersonLink.objects.create( + company=self.other_company, + person=person, ) before = AppError.objects.count() response = self.api.post( f"/api/crm/phone-calls/{self.call.id}/assign-number/", { - "client": str(self.client_obj.id), - "contact": str(contact.id), + "company": str(self.company_obj.id), + "person": str(person.id), }, format="json", ) self.assertEqual(response.status_code, 400) - self.assertIn("Contact not found", response.data["message"]) + self.assertIn( + "Person is not linked to the selected company", response.data["message"] + ) 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,9 +815,9 @@ 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: + def test_link_rejects_cross_company_job(self) -> None: response = self.api.post( f"/api/crm/phone-calls/{self.call.id}/job-link/", {"job": str(self.other_job.id)}, @@ -815,7 +825,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 +906,7 @@ def _call( self, provider_id: str, *, - client: Client | None, + company: Company | None, origin: str = "+6421555123", destination: str = "+6496365131", ) -> PhoneCallRecord: @@ -910,7 +920,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..11e0a8529 100644 --- a/apps/crm/tests/test_phone_endpoint_guard.py +++ b/apps/crm/tests/test_phone_endpoint_guard.py @@ -1,8 +1,8 @@ """One-number-one-owner symmetry: PhoneEndpoint side of the guard. -ClientContactMethod.save() refuses numbers held by an active PhoneEndpoint; +ContactMethod.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,23 +11,31 @@ 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 Company, CompanyPersonLink, ContactMethod, Person 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: - return ClientContactMethod.objects.create( - client=client, - method_type=ClientContactMethod.MethodType.PHONE, +def _client_phone(company: Company, value: str = "021 555 123") -> ContactMethod: + return ContactMethod.objects.create( + company=company, + method_type=ContactMethod.MethodType.PHONE, value=value, ) +def _link(company: Company, name: str) -> CompanyPersonLink: + person = Person.objects.create(name=name) + return CompanyPersonLink.objects.create( + company=company, + person=person, + ) + + def _endpoint(number: str, **overrides: object) -> PhoneEndpoint: fields: dict[str, object] = { "number": number, @@ -43,21 +51,19 @@ 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" - ) - ClientContactMethod.objects.create( - contact=contact, - method_type=ClientContactMethod.MethodType.PHONE, + contact = _link(_client("Acme Ltd"), "Jane Smith") + ContactMethod.objects.create( + person=contact.person, + method_type=ContactMethod.MethodType.PHONE, value="021 555 123", ) - with self.assertRaisesRegex(ValidationError, "contact Jane Smith at Acme Ltd"): + with self.assertRaisesRegex(ValidationError, "person Jane Smith"): _endpoint("021 555 123") def test_inactive_endpoint_over_client_number_is_allowed(self) -> None: @@ -88,13 +94,13 @@ def test_existing_endpoint_resave_is_grandfathered(self) -> None: endpoint = _endpoint("021 555 123") # Legacy cross-owned row inserted bypassing the method-side guard, # as pre-guard data was. - legacy = ClientContactMethod( - client=_client(), - method_type=ClientContactMethod.MethodType.PHONE, + legacy = ContactMethod( + company=_client(), + method_type=ContactMethod.MethodType.PHONE, value="021 555 123", ) - legacy.normalized_value = ClientContactMethod.normalize_phone("021 555 123") - ClientContactMethod.objects.bulk_create([legacy]) + legacy.normalized_value = ContactMethod.normalize_phone("021 555 123") + ContactMethod.objects.bulk_create([legacy]) endpoint.label = "Renamed line" endpoint.save() # number/is_active unchanged -> grandfathered @@ -136,13 +142,13 @@ 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(), - method_type=ClientContactMethod.MethodType.PHONE, + legacy = ContactMethod( + company=_client(), + method_type=ContactMethod.MethodType.PHONE, value="021 555 123", ) - legacy.normalized_value = ClientContactMethod.normalize_phone("021 555 123") - ClientContactMethod.objects.bulk_create([legacy]) + legacy.normalized_value = ContactMethod.normalize_phone("021 555 123") + ContactMethod.objects.bulk_create([legacy]) response = self.api.patch( f"/api/crm/phone-endpoints/{endpoint.id}/", diff --git a/apps/crm/views/phone_call_views.py b/apps/crm/views/phone_call_views.py index 301836f4f..c45e2ede4 100644 --- a/apps/crm/views/phone_call_views.py +++ b/apps/crm/views/phone_call_views.py @@ -65,8 +65,8 @@ 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")), - ("contact_id", _query_uuid(request.query_params.get("contact"), "contact")), + ("company_id", _query_uuid(request.query_params.get("company"), "company")), + ("person_id", _query_uuid(request.query_params.get("person"), "person")), ("job_id", _query_uuid(request.query_params.get("job"), "job")), ( "origin_endpoint_id", @@ -117,14 +117,14 @@ def _query_date(value: str | None, field_name: str) -> date | None: return parsed -def _apply_client_match_filter( +def _apply_company_match_filter( queryset: QuerySet[PhoneCallRecord], - client_match: str, + company_match: str, ) -> QuerySet[PhoneCallRecord]: - if client_match == "matched": - return queryset.filter(client_id__isnull=False) - if client_match == "unmatched": - return queryset.filter(client_id__isnull=True) + if company_match == "matched": + return queryset.filter(company_id__isnull=False) + if company_match == "unmatched": + return queryset.filter(company_id__isnull=True) return queryset @@ -184,8 +184,9 @@ def _apply_search_filter( normalized_phone = normalize_phone(search) filters = ( - Q(client__name__icontains=search) - | Q(contact__name__icontains=search) + Q(company__name__icontains=search) + | Q(person__name__icontains=search) + | Q(person__name__icontains=search) | Q(origin_endpoint__label__icontains=search) | Q(destination_endpoint__label__icontains=search) | Q(job__name__icontains=search) @@ -206,9 +207,9 @@ def _filter_phone_call_queryset( queryset: QuerySet[PhoneCallRecord], request: Request, ) -> QuerySet[PhoneCallRecord]: - client_match = _query_choice( - request.query_params.get("client_match"), - "client_match", + company_match = _query_choice( + request.query_params.get("company_match"), + "company_match", {"all", "matched", "unmatched"}, ) job_link = _query_choice( @@ -225,7 +226,7 @@ def _filter_phone_call_queryset( request.query_params.get("has_recording"), "has_recording" ) - queryset = _apply_client_match_filter(queryset, client_match) + queryset = _apply_company_match_filter(queryset, company_match) queryset = _apply_job_link_filter(queryset, job_link) queryset = _apply_direction_filter(queryset, direction) queryset = _apply_recording_filter(queryset, has_recording) @@ -240,8 +241,8 @@ class PhoneCallRecordViewSet(viewsets.ReadOnlyModelViewSet[PhoneCallRecord]): @extend_schema( parameters=[ - OpenApiParameter("client", OpenApiTypes.UUID, OpenApiParameter.QUERY), - OpenApiParameter("contact", OpenApiTypes.UUID, OpenApiParameter.QUERY), + OpenApiParameter("company", OpenApiTypes.UUID, OpenApiParameter.QUERY), + OpenApiParameter("person", OpenApiTypes.UUID, OpenApiParameter.QUERY), OpenApiParameter("job", OpenApiTypes.UUID, OpenApiParameter.QUERY), OpenApiParameter( "origin_endpoint", OpenApiTypes.UUID, OpenApiParameter.QUERY @@ -249,7 +250,7 @@ class PhoneCallRecordViewSet(viewsets.ReadOnlyModelViewSet[PhoneCallRecord]): OpenApiParameter( "destination_endpoint", OpenApiTypes.UUID, OpenApiParameter.QUERY ), - OpenApiParameter("client_match", OpenApiTypes.STR, OpenApiParameter.QUERY), + OpenApiParameter("company_match", OpenApiTypes.STR, OpenApiParameter.QUERY), OpenApiParameter("job_link", OpenApiTypes.STR, OpenApiParameter.QUERY), OpenApiParameter("direction", OpenApiTypes.STR, OpenApiParameter.QUERY), OpenApiParameter( @@ -282,8 +283,8 @@ def list(self, request: Request, *args: str, **kwargs: str) -> Response: def get_queryset(self) -> QuerySet[PhoneCallRecord]: queryset = ( PhoneCallRecord.objects.select_related( - "client", - "contact", + "company", + "person", "job", "job_linked_by", "origin_endpoint", @@ -301,7 +302,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: @@ -373,12 +374,12 @@ def assign_number( ) -> Response: payload = PhoneNumberAssignmentSerializer(data=request.data) payload.is_valid(raise_exception=True) - contact = payload.validated_data["contact"] + person = payload.validated_data["person"] return self._call_operation_response( lambda: assign_phone_number_from_call( call_id=str(pk), - client_id=str(payload.validated_data["client"]), - contact_id=str(contact) if contact else None, + company_id=str(payload.validated_data["company"]), + person_id=str(person) if person 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 b61ecbd07..73c801867 100644 --- a/apps/job/migrations/0001_baseline.py +++ b/apps/job/migrations/0001_baseline.py @@ -15,117 +15,8 @@ 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"), + ("company", "0001_baseline"), ("workflow", "0001_baseline"), migrations.swappable_dependency(settings.AUTH_USER_MODEL), ] @@ -354,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", ), ), ( @@ -365,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/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/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/migrations/0004_job_person_alter_job_contact.py b/apps/job/migrations/0004_job_person_alter_job_contact.py new file mode 100644 index 000000000..4fdc6f570 --- /dev/null +++ b/apps/job/migrations/0004_job_person_alter_job_contact.py @@ -0,0 +1,39 @@ +# Generated by Django 6.0.4 on 2026-07-08 21:59 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("company", "0004_person_link_structure"), + ("job", "0003_rename_client_company"), + ] + + operations = [ + migrations.AddField( + model_name="job", + name="person", + field=models.ForeignKey( + blank=True, + help_text="The person for this job", + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="person_jobs", + to="company.person", + ), + ), + migrations.AlterField( + model_name="job", + name="contact", + field=models.ForeignKey( + blank=True, + help_text="The contact person for this job", + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="jobs", + to="company.companypersonlink", + ), + ), + ] diff --git a/apps/job/migrations/0005_remove_job_contact_alter_job_person.py b/apps/job/migrations/0005_remove_job_contact_alter_job_person.py new file mode 100644 index 000000000..24c0134f5 --- /dev/null +++ b/apps/job/migrations/0005_remove_job_contact_alter_job_person.py @@ -0,0 +1,51 @@ +# Generated by Django 6.0.4 on 2026-07-08 22:47 + +from typing import Any + +import django.db.models.deletion +from django.db import migrations, models + + +def repair_job_people(apps: Any, schema_editor: Any) -> None: + Job = apps.get_model("job", "Job") + CompanyPersonLink = apps.get_model("company", "CompanyPersonLink") + + link_person_by_id = dict( + CompanyPersonLink.objects.exclude(person__isnull=True).values_list( + "id", "person_id" + ) + ) + jobs = Job.objects.filter(person__isnull=True).exclude(contact__isnull=True) + for job in jobs.iterator(): + person_id = link_person_by_id.get(job.contact_id) + if person_id is not None: + job.person_id = person_id + job.save(update_fields=["person"]) + + +class Migration(migrations.Migration): + + dependencies = [ + ("company", "0006_alter_companypersonlink_options_and_more"), + ("job", "0004_job_person_alter_job_contact"), + ] + + operations = [ + migrations.RunPython(repair_job_people, migrations.RunPython.noop), + migrations.RemoveField( + model_name="job", + name="contact", + ), + migrations.AlterField( + model_name="job", + name="person", + field=models.ForeignKey( + blank=True, + help_text="The person for this job", + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="jobs", + to="company.person", + ), + ), + ] diff --git a/apps/job/migrations/0006_rename_job_event_people_company_terms.py b/apps/job/migrations/0006_rename_job_event_people_company_terms.py new file mode 100644 index 000000000..b704c96c1 --- /dev/null +++ b/apps/job/migrations/0006_rename_job_event_people_company_terms.py @@ -0,0 +1,74 @@ +from typing import Any + +from django.db import migrations + +EVENT_TYPE_RENAMES = { + "client_changed": "company_changed", + "contact_changed": "person_changed", +} +EVENT_TYPE_REVERSES = {value: key for key, value in EVENT_TYPE_RENAMES.items()} +DETAIL_LABEL_RENAMES = { + "Client": "Company", + "Contact": "Person", +} +DETAIL_LABEL_REVERSES = {value: key for key, value in DETAIL_LABEL_RENAMES.items()} + + +def _rename_event_types(JobEvent: Any, mapping: dict[str, str]) -> None: + for old_value, new_value in mapping.items(): + JobEvent.objects.filter(event_type=old_value).update(event_type=new_value) + + +def _rename_detail_labels(JobEvent: Any, mapping: dict[str, str]) -> None: + queryset = JobEvent.objects.filter( + event_type__in=[ + "client_changed", + "contact_changed", + "company_changed", + "person_changed", + ] + ) + for event in queryset.iterator(): + detail = event.detail or {} + changed = False + + field_name = detail.get("field_name") + if field_name in mapping: + detail["field_name"] = mapping[field_name] + changed = True + + changes = detail.get("changes") + if isinstance(changes, list): + for change in changes: + if not isinstance(change, dict): + continue + change_field_name = change.get("field_name") + if change_field_name in mapping: + change["field_name"] = mapping[change_field_name] + changed = True + + if changed: + event.detail = detail + event.save(update_fields=["detail"]) + + +def forwards(apps: Any, schema_editor: Any) -> None: + JobEvent = apps.get_model("job", "JobEvent") + _rename_detail_labels(JobEvent, DETAIL_LABEL_RENAMES) + _rename_event_types(JobEvent, EVENT_TYPE_RENAMES) + + +def backwards(apps: Any, schema_editor: Any) -> None: + JobEvent = apps.get_model("job", "JobEvent") + _rename_detail_labels(JobEvent, DETAIL_LABEL_REVERSES) + _rename_event_types(JobEvent, EVENT_TYPE_REVERSES) + + +class Migration(migrations.Migration): + dependencies = [ + ("job", "0005_remove_job_contact_alter_job_person"), + ] + + operations = [ + migrations.RunPython(forwards, backwards), + ] 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..a0cd3ee6d 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,22 +207,21 @@ 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", + person = models.ForeignKey( + "company.Person", on_delete=models.SET_NULL, null=True, blank=True, related_name="jobs", - help_text="The contact person for this job", + help_text="The person for this job", ) job_number = models.IntegerField(unique=True) # Job 1234 description = models.TextField( @@ -301,7 +300,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 +420,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 +552,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,42 +929,46 @@ 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}, + "company_changed", + { + "field_name": "Company", + "old_value": old_company, + "new_value": new_company, + }, ) @staticmethod - def _handle_contact_change(self_job, old_contact_id, new_contact_id): - old_contact = "None" - new_contact = "None" - if old_contact_id: + def _handle_person_change(self_job, old_person_id, new_person_id): + old_person = "None" + new_person = "None" + if old_person_id: try: - from apps.client.models import ClientContact + from apps.company.models import Person - old_contact = ClientContact.objects.get(id=old_contact_id).name + old_person = Person.objects.get(id=old_person_id).name except Exception: - old_contact = "Unknown Contact" - if new_contact_id: - new_contact = ( - self_job.contact.name if self_job.contact else "Unknown Contact" - ) + old_person = "Unknown Person" + if new_person_id: + new_person = self_job.person.name if self_job.person else "Unknown Person" return ( - "contact_changed", + "person_changed", { - "field_name": "Primary contact", - "old_value": old_contact, - "new_value": new_contact, + "field_name": "Person", + "old_value": old_person, + "new_value": new_person, }, ) @@ -1098,8 +1101,8 @@ 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__, - "contact_id": _handle_contact_change.__func__, + "company_id": _handle_company_change.__func__, + "person_id": _handle_person_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..bcefae311 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") - contact_name = detail.get("contact_name") + company_name = detail.get("company_name", "Unknown") + person_name = detail.get("person_name") initial_status = detail.get("initial_status", "Unknown") pricing = detail.get("pricing_methodology", "Unknown") - contact_info = f" (Contact: {contact_name})" if contact_name else "" + person_info = f" (Person: {person_name})" if person_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}{person_info}. " f"Initial status: {initial_status}. " f"Pricing methodology: {pricing}." ) @@ -327,8 +327,8 @@ def _build_jsa_description(detail: dict) -> str: "job_created": _build_job_created_description.__func__, "status_changed": _build_status_changed_description.__func__, "job_updated": _build_changes_description.__func__, - "client_changed": _build_changes_description.__func__, - "contact_changed": _build_changes_description.__func__, + "company_changed": _build_changes_description.__func__, + "person_changed": _build_changes_description.__func__, "notes_updated": _build_changes_description.__func__, "delivery_date_changed": _build_changes_description.__func__, "quote_accepted": _build_changes_description.__func__, diff --git a/apps/job/serializers/__init__.py b/apps/job/serializers/__init__.py index 50b61ec00..701c22210 100644 --- a/apps/job/serializers/__init__.py +++ b/apps/job/serializers/__init__.py @@ -30,6 +30,15 @@ ArchivedJobIssueSerializer, ArchivedJobsComplianceResponseSerializer, ComplianceSummarySerializer, + DuplicateCompanyGroupSerializer, + DuplicateCompanyMemberSerializer, + DuplicateIdentitiesResponseSerializer, + DuplicateIdentityEvidenceSerializer, + DuplicateIdentityReportSummarySerializer, + DuplicatePersonCompanyLinkSerializer, + DuplicatePersonContactMethodSerializer, + DuplicatePersonGroupSerializer, + DuplicatePersonSummarySerializer, DuplicatePhoneIssueSerializer, DuplicatePhoneOwnerSerializer, DuplicatePhoneSummarySerializer, @@ -196,6 +205,15 @@ "DataIntegritySummarySerializer", "DiffPreviewSerializer", "DraftLineSerializer", + "DuplicateCompanyGroupSerializer", + "DuplicateCompanyMemberSerializer", + "DuplicateIdentitiesResponseSerializer", + "DuplicateIdentityEvidenceSerializer", + "DuplicateIdentityReportSummarySerializer", + "DuplicatePersonCompanyLinkSerializer", + "DuplicatePersonContactMethodSerializer", + "DuplicatePersonGroupSerializer", + "DuplicatePersonSummarySerializer", "DuplicatePhoneIssueSerializer", "DuplicatePhoneOwnerSerializer", "DuplicatePhoneSummarySerializer", 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..88d15a576 100644 --- a/apps/job/serializers/data_quality_report_serializers.py +++ b/apps/job/serializers/data_quality_report_serializers.py @@ -2,7 +2,20 @@ from rest_framework import serializers -from apps.client.services.duplicate_phone_report import ( +from apps.company.services.duplicate_identity_report import ( + DuplicateCompanyGroup, + DuplicateCompanyMember, + DuplicateIdentityEvidence, + DuplicateIdentityReport, + DuplicateIdentityReportSummary, + DuplicatePersonGroup, +) +from apps.company.services.duplicate_person_report import ( + DuplicatePersonCompanyLink, + DuplicatePersonContactMethod, + DuplicatePersonSummary, +) +from apps.company.services.duplicate_phone_report import ( DuplicatePhoneIssue, DuplicatePhoneOwner, DuplicatePhonesReport, @@ -30,7 +43,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,21 +84,21 @@ 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 'person'") owner_name = serializers.CharField(help_text="Human-readable owner") - effective_client_id = serializers.CharField( - allow_null=True, help_text="Client the number resolves to" + effective_company_id = serializers.CharField( + allow_null=True, help_text="Company the number resolves to" ) class DuplicatePhoneSummarySerializer(serializers.Serializer[DuplicatePhoneSummary]): """Summary of duplicate-phone issues.""" - cross_client = serializers.IntegerField( - help_text="Numbers owned by more than one client" + cross_company = serializers.IntegerField( + 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" ) @@ -93,7 +106,7 @@ class DuplicatePhoneIssueSerializer(serializers.Serializer[DuplicatePhoneIssue]) """One problematic phone number and its owners.""" normalized_value = serializers.CharField(help_text="Normalized phone number") - issue = serializers.CharField(help_text="'cross_client' or 'internal_line'") + issue = serializers.CharField(help_text="'cross_company' or 'internal_line'") endpoint_label = serializers.CharField( required=False, allow_null=True, @@ -111,3 +124,99 @@ class DuplicatePhonesResponseSerializer(serializers.Serializer[DuplicatePhonesRe ) summary = DuplicatePhoneSummarySerializer(help_text="Summary of issues") checked_at = serializers.DateTimeField(help_text="When the check was performed") + + +class DuplicatePersonCompanyLinkSerializer( + serializers.Serializer[DuplicatePersonCompanyLink] +): + link_id = serializers.UUIDField() + company_id = serializers.UUIDField() + company_name = serializers.CharField() + position = serializers.CharField(allow_null=True) + is_primary = serializers.BooleanField() + is_active = serializers.BooleanField() + + +class DuplicatePersonContactMethodSerializer( + serializers.Serializer[DuplicatePersonContactMethod] +): + method_id = serializers.UUIDField() + method_type = serializers.ChoiceField(choices=["phone", "email"]) + value = serializers.CharField() + normalized_value = serializers.CharField() + contact_label = serializers.CharField() + is_primary = serializers.BooleanField() + + +class DuplicatePersonSummarySerializer(serializers.Serializer[DuplicatePersonSummary]): + person_id = serializers.UUIDField() + name = serializers.CharField() + email = serializers.EmailField(allow_null=True) + is_active = serializers.BooleanField() + created_at = serializers.DateTimeField() + updated_at = serializers.DateTimeField() + company_links = DuplicatePersonCompanyLinkSerializer(many=True) + contact_methods = DuplicatePersonContactMethodSerializer(many=True) + job_count = serializers.IntegerField() + phone_call_count = serializers.IntegerField() + + +class DuplicateIdentityEvidenceSerializer( + serializers.Serializer[DuplicateIdentityEvidence] +): + kind = serializers.ChoiceField( + choices=["name", "email", "email_domain", "phone", "address", "shared_person"] + ) + normalized_value = serializers.CharField() + owner_count = serializers.IntegerField() + + +class DuplicateCompanyMemberSerializer(serializers.Serializer[DuplicateCompanyMember]): + company_id = serializers.UUIDField() + name = serializers.CharField() + email = serializers.EmailField(allow_null=True) + address = serializers.CharField(allow_null=True) + allow_jobs = serializers.BooleanField() + is_account_customer = serializers.BooleanField() + is_supplier = serializers.BooleanField() + xero_archived = serializers.BooleanField() + job_count = serializers.IntegerField() + contact_names = serializers.ListField(child=serializers.CharField()) + + +class DuplicateCompanyGroupSerializer(serializers.Serializer[DuplicateCompanyGroup]): + group_id = serializers.CharField() + fingerprint = serializers.CharField() + recommendation = serializers.ChoiceField(choices=["merge", "review"]) + reason_codes = serializers.ListField(child=serializers.CharField()) + canonical_id = serializers.UUIDField(allow_null=True) + members = DuplicateCompanyMemberSerializer(many=True) + evidence = DuplicateIdentityEvidenceSerializer(many=True) + + +class DuplicatePersonGroupSerializer(serializers.Serializer[DuplicatePersonGroup]): + group_id = serializers.CharField() + fingerprint = serializers.CharField() + recommendation = serializers.ChoiceField(choices=["merge", "review"]) + reason_codes = serializers.ListField(child=serializers.CharField()) + canonical_id = serializers.UUIDField(allow_null=True) + members = DuplicatePersonSummarySerializer(many=True) + evidence = DuplicateIdentityEvidenceSerializer(many=True) + + +class DuplicateIdentityReportSummarySerializer( + serializers.Serializer[DuplicateIdentityReportSummary] +): + company_merge_groups = serializers.IntegerField() + company_review_groups = serializers.IntegerField() + person_merge_groups = serializers.IntegerField() + person_review_groups = serializers.IntegerField() + + +class DuplicateIdentitiesResponseSerializer( + serializers.Serializer[DuplicateIdentityReport] +): + company_groups = DuplicateCompanyGroupSerializer(many=True) + person_groups = DuplicatePersonGroupSerializer(many=True) + summary = DuplicateIdentityReportSummarySerializer() + checked_at = serializers.DateTimeField() 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..e30951303 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 Company, Person from apps.job.models import Job, JobEvent, JobFile from apps.workflow.models import XeroPayItem @@ -113,26 +113,26 @@ 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(), - source="contact", - write_only=False, # Allow read access + person_id = serializers.PrimaryKeyRelatedField( + queryset=Person.objects.all(), + source="person", + write_only=False, required=False, allow_null=True, ) - contact_name = serializers.CharField( - source="contact.name", read_only=True, required=False, allow_null=True + person_name = serializers.CharField( + source="person.name", read_only=True, required=False, allow_null=True ) default_xero_pay_item_id = serializers.PrimaryKeyRelatedField( queryset=XeroPayItem.objects.all(), @@ -206,10 +206,10 @@ class Meta: fields = [ "id", "name", - "client_id", - "client_name", - "contact_id", - "contact_name", + "company_id", + "company_name", + "person_id", + "person_name", "job_number", "notes", "order_number", @@ -250,33 +250,6 @@ def validate(self, attrs): if DEBUG_SERIALIZER: logger.debug(f"JobSerializer validate called with attrs: {attrs}") - # Validate contact belongs to client - contact = attrs.get("contact") - client = attrs.get("client") - - # If we're updating and no client is provided, use the existing client - if not client and self.instance: - client = self.instance.client - - if contact and client: - logger.debug( - f"JobSerializer validate - Checking if contact {contact.id} belongs to client {client.id}" - ) - if contact.client != client: - logger.error( - f"JobSerializer validate - Contact {contact.id} does not belong to client {client.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}." - } - ) - if DEBUG_SERIALIZER: - logger.debug( - f"JobSerializer validate - Contact {contact.id} belongs to client {client.id}" - ) - logger.debug("JobSerializer validate - Contact validation passed") - # No longer validating pricing data - use CostSet/CostLine instead validated = super().validate(attrs) @@ -286,7 +259,7 @@ def validate(self, attrs): def update(self, instance, validated_data): # Store original values for change detection - original_contact = instance.contact if "contact" in validated_data else None + original_person = instance.person if "person" in validated_data else None # Remove read-only/computed fields to avoid AttributeError validated_data.pop("quoted", None) @@ -353,13 +326,10 @@ def update(self, instance, validated_data): # Save the instance (enrichment kwargs flow through to _create_change_events) instance.save(staff=staff, **save_enrichment) - # Log contact change after save if it actually changed - if ( - "contact" in validated_data - and original_contact != validated_data["contact"] - ): + # Log person change after save if it actually changed + if "person" in validated_data and original_person != validated_data["person"]: logger.debug( - f"JobSerializer update - contact changed from {original_contact} to {instance.contact}" + f"JobSerializer update - person changed from {original_person} to {instance.person}" ) return instance @@ -437,8 +407,8 @@ def get_undo_description(self, obj) -> str | None: "status": "status", "order_number": "order number", "notes": "notes", - "client_id": "client", - "contact_id": "contact", + "company_id": "company", + "person_id": "person", "delivery_date": "delivery date", "job_status": "status", "paid": "payment status", @@ -501,23 +471,30 @@ 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) - contact_id = serializers.UUIDField(required=False, allow_null=True) + person_id = serializers.UUIDField(required=False, allow_null=True) pricing_methodology = serializers.CharField( required=False, allow_null=True, allow_blank=True ) @@ -631,7 +608,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 +763,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,17 +783,17 @@ 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 + person_id = serializers.UUIDField( + source="person.id", read_only=True, allow_null=True ) - contact_name = serializers.CharField( - source="contact.name", read_only=True, allow_null=True + person_name = serializers.CharField( + source="person.name", read_only=True, allow_null=True ) quoted = serializers.BooleanField() default_xero_pay_item_id = serializers.UUIDField( @@ -831,10 +808,10 @@ class Meta: # Derive fields from Job.JOB_DIRECT_FIELDS, plus special fields fields = [ "job_id", - "client_id", - "client_name", - "contact_id", - "contact_name", + "company_id", + "company_name", + "person_id", + "person_name", "quoted", "default_xero_pay_item_id", "default_xero_pay_item_name", @@ -1022,8 +999,8 @@ def get_undo_description(self, obj) -> str | None: "status": "status", "order_number": "order number", "notes": "notes", - "client_id": "client", - "contact_id": "contact", + "company_id": "company", + "person_id": "person", "delivery_date": "delivery date", "job_status": "status", "paid": "payment status", @@ -1095,7 +1072,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,11 +1110,11 @@ 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" + person_id = serializers.UUIDField( + required=False, allow_null=True, help_text="Person ID" ) # Files @@ -1145,41 +1122,26 @@ 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): - """Validate that contact exists if provided""" + def validate_person_id(self, value): + """Validate that person exists if provided""" if value: try: - ClientContact.objects.get(id=value) - except ClientContact.DoesNotExist: - raise serializers.ValidationError("Contact not found") + Person.objects.get(id=value) + except Person.DoesNotExist as exc: + raise serializers.ValidationError("Person not found") from exc return value def validate(self, attrs): """Cross-field validation""" - # Validate contact belongs to client if both are provided - contact_id = attrs.get("contact_id") - client_id = attrs.get("client_id") - - if contact_id and client_id: - try: - contact = ClientContact.objects.get(id=contact_id) - client = Client.objects.get(id=client_id) - if contact.client != client: - raise serializers.ValidationError( - {"contact_id": "Contact does not belong to the selected client"} - ) - except (ClientContact.DoesNotExist, Client.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..5df3dc9e0 100644 --- a/apps/job/serializers/kanban_serializer.py +++ b/apps/job/serializers/kanban_serializer.py @@ -69,8 +69,8 @@ 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) - contact_person = serializers.CharField(required=False, allow_blank=True) + company_name = serializers.CharField(required=False, allow_blank=True) + person_name = 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) created_before = serializers.DateField(required=False, allow_null=True) @@ -100,9 +100,9 @@ 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) - contact_person = serializers.CharField(allow_blank=True) + # Company and person info + company_name = serializers.CharField(allow_blank=True) + person_name = serializers.CharField(allow_blank=True) # People assigned to the job people = KanbanJobPersonSerializer(many=True) @@ -198,9 +198,9 @@ 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) - contact_person = serializers.CharField(allow_blank=True) + # Company and person info + company_name = serializers.CharField(allow_blank=True) + person_name = serializers.CharField(allow_blank=True) # People assigned to the job (empty list for now) people = KanbanJobPersonSerializer(many=True) @@ -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() - contact_person = serializers.CharField(allow_blank=True, allow_null=True) + company_name = serializers.CharField() + person_name = 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..7a555a103 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 Company, CompanyPersonLink, Person from apps.job.models import ( CostLine, CostSet, @@ -92,9 +92,9 @@ 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()) - issues.extend(DataIntegrityService._check_clientcontact_fks()) + # Company App + issues.extend(DataIntegrityService._check_company_fks()) + issues.extend(DataIntegrityService._check_companypersonlink_fks()) # Purchasing App issues.extend(DataIntegrityService._check_purchaseorder_fks()) @@ -115,30 +115,30 @@ 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_contact_ids = set(ClientContact.objects.values_list("id", flat=True)) + valid_company_ids = set(Company.objects.values_list("id", flat=True)) + valid_person_ids = set(Person.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: + if job.person_id and job.person_id not in valid_person_ids: issues.append( { "model": "Job", "record_id": str(job.id), - "field": "contact", - "target_model": "ClientContact", - "target_id": str(job.contact_id), + "field": "person", + "target_model": "Person", + "target_id": str(job.person_id), } ) if job.created_by_id and job.created_by_id not in valid_staff_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,42 +567,56 @@ 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), } ) return issues @staticmethod - def _check_clientcontact_fks() -> list[dict[str, Any]]: - """Check ClientContact FK references.""" + def _check_companypersonlink_fks() -> list[dict[str, Any]]: + """Check CompanyPersonLink 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_person_ids = set(Person.objects.values_list("id", flat=True)) - for contact in ClientContact.objects.all(): - if contact.client_id not in valid_client_ids: + for contact in CompanyPersonLink.objects.all(): + if contact.company_id not in valid_company_ids: + issues.append( + { + "model": "CompanyPersonLink", + "record_id": str(contact.id), + "field": "company", + "target_model": "Company", + "target_id": str(contact.company_id), + } + ) + if contact.person_id not in valid_person_ids: issues.append( { - "model": "ClientContact", + "model": "CompanyPersonLink", "record_id": str(contact.id), - "field": "client", - "target_model": "Client", - "target_id": str(contact.client_id), + "field": "person", + "target_model": "Person", + "target_id": str(contact.person_id), } ) @@ -613,17 +627,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 +771,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 +804,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 +824,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 +955,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 +1229,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 +1254,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..39cdb772d 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 Company, Person from apps.job.models import Job, JobDeltaRejection, JobEvent, LabourSubtype from apps.job.models.costing import CostLine from apps.job.serializers import JobSerializer @@ -33,7 +33,6 @@ QuoteSerializer, ) from apps.job.services.delta_checksum import compute_job_delta_checksum, normalise_value -from apps.workflow.exceptions import AlreadyLoggedException from apps.workflow.models import CompanyDefaults, XeroPayItem from apps.workflow.services.error_persistence import persist_and_raise @@ -74,7 +73,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 +251,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, } @@ -283,13 +282,13 @@ def create_job(data: Dict[str, Any], user: Staff) -> Job: if data.get(field): job_data[field] = data[field] - # Contact (optional relationship) - if contact_id := data.get("contact_id"): + # Person (optional relationship) + if person_id := data.get("person_id"): try: - contact = ClientContact.objects.get(id=contact_id) - job_data["contact"] = contact - except ClientContact.DoesNotExist: - raise ValueError(f"Contact with id {contact_id} not found") + person = Person.objects.get(id=person_id) + job_data["person"] = person + except Person.DoesNotExist as exc: + raise ValueError(f"Person with id {person_id} not found") from exc if "is_urgent" in data: job_data["is_urgent"] = data["is_urgent"] @@ -310,15 +309,15 @@ 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" - contact_name = job.contact.name if job.contact else None + company_name = job.company.name if job.company else "Shop Job" + person_name = job.person.name if job.person else None JobEvent.objects.create( job=job, event_type="job_created", detail={ "job_name": job.name, - "client_name": client_name, - "contact_name": contact_name, + "company_name": company_name, + "person_name": person_name, "initial_status": job.get_status_display(), "pricing_methodology": job.get_pricing_methodology_display(), }, @@ -669,7 +668,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 +714,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 +947,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 +1064,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}" @@ -1076,15 +1075,14 @@ def update_job( # DEBUG: Log incoming data logger.debug(f"JobRestService.update_job - Incoming data: {data}") logger.debug( - f"JobRestService.update_job - Current job contact: {job.contact}" + f"JobRestService.update_job - Current job person: {job.person}" ) logger.debug( - f"JobRestService.update_job - Current job contact_id: {job.contact.id if job.contact else None}" + f"JobRestService.update_job - Current job person_id: {job.person.id if job.person else None}" ) - # Snapshot values needed for post-save logic (priority bump, client change) + # Snapshot values needed for post-save logic (priority bump) original_status = job.status - original_client_id = job.client_id if delta_payload.job_id and str(job.id) != delta_payload.job_id: detail = { @@ -1146,45 +1144,6 @@ 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. - # 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 - and not contact_explicitly_updated - ): - primary_contact = ClientContact.objects.filter( - client_id=job.client_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"contact={'None' if not primary_contact else primary_contact.id}" - ) - elif ( - job.contact - and job.client - and job.contact.client_id != job.client_id - ): - logger.warning( - "Clearing mismatched contact after client change: " - f"contact.client_id={job.contact.client_id} != job.client_id={job.client_id}" - ) - job.contact = None - job.save(staff=user) - except Exception as _e: - # Persist the error but do not mask the main operation - try: - persist_and_raise(_e) - except AlreadyLoggedException: - pass - result_job = job except PreconditionFailed: if soft_fail_context: @@ -1577,8 +1536,8 @@ def get_job_timeline(job_id: UUID) -> list[Dict[str, Any]]: "status": "status", "order_number": "order number", "notes": "notes", - "client_id": "client", - "contact_id": "contact", + "company_id": "company", + "person_id": "person", "delivery_date": "delivery date", "job_status": "status", "paid": "payment status", @@ -1763,7 +1722,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 +1767,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..1cd1c1d23 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 Company, Person from apps.job.models import CostSet, Job from apps.job.services.kanban_categorization_service import KanbanCategorizationService from apps.workflow.models import CompanyDefaults @@ -55,10 +55,10 @@ 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] - contact_names: Dict[UUID, str] + company_names: Dict[UUID, str] + person_names: Dict[UUID, str] people_by_job: Dict[UUID, List[Staff]] @@ -71,8 +71,8 @@ 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_CONTACT_CONTAINS = 55.0 + SEARCH_SCORE_COMPANY_CONTAINS = 55.0 + SEARCH_SCORE_PERSON_CONTAINS = 55.0 SEARCH_SCORE_DESCRIPTION_CONTAINS = 35.0 SEARCH_SCORE_TRIGRAM_MULTIPLIER = 30.0 SEARCH_SCORE_FALLBACK_RATIO = 0.5 @@ -87,18 +87,18 @@ class KanbanService: "reason": "name_contains", }, { - "db_path": "client__name", - "expression": Coalesce("client__name", Value(""), output_field=TextField()), - "score": SEARCH_SCORE_CLIENT_CONTAINS, - "reason": "client_contains", - }, - { - "db_path": "contact__name", + "db_path": "company__name", "expression": Coalesce( - "contact__name", Value(""), output_field=TextField() + "company__name", Value(""), output_field=TextField() ), - "score": SEARCH_SCORE_CONTACT_CONTAINS, - "reason": "contact_contains", + "score": SEARCH_SCORE_COMPANY_CONTAINS, + "reason": "company_contains", + }, + { + "db_path": "person__name", + "expression": Coalesce("person__name", Value(""), output_field=TextField()), + "score": SEARCH_SCORE_PERSON_CONTAINS, + "reason": "person_contains", }, { "db_path": "description", @@ -244,7 +244,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", "person", "quote") .prefetch_related("invoices") .order_by("-trigram_score", "-created_at") ) @@ -365,12 +365,12 @@ 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 "contact__name": + case "company__name": return ( - job.contact.name if job.contact_id and job.contact else "" + job.company.name if job.company_id and job.company else "" ).lower() + case "person__name": + return (job.person.name if job.person_id and job.person else "").lower() case "description": return (job.description or "").lower() case "job_number": @@ -391,7 +391,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 +440,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,14 +520,14 @@ 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} - contact_names = dict( - ClientContact.objects.filter(id__in=contact_ids).values_list("id", "name") + person_ids = {job.person_id for job in jobs} - {None} + person_names = dict( + Person.objects.filter(id__in=person_ids).values_list("id", "name") ) people_links = list( @@ -549,10 +549,10 @@ 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, - contact_names=contact_names, + company_names=company_names, + person_names=person_names, people_by_job=dict(people_by_job), ) @@ -623,11 +623,11 @@ 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 "" + "person_name": ( + context.person_names.get(job.person_id, "") if job.person_id else "" ), "people": [ { @@ -650,7 +650,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,11 +951,11 @@ 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) + if person_name := filters.get("person_name", "").strip(): + jobs_query = jobs_query.filter(person__name__icontains=person_name) if order_number := filters.get("order_number", "").strip(): jobs_query = jobs_query.filter(order_number__icontains=order_number) 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 acc0c7c89..412db2a4d 100644 --- a/apps/job/services/workshop_pdf_service.py +++ b/apps/job/services/workshop_pdf_service.py @@ -22,7 +22,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 ContactMethod from apps.crm.models import PhoneEndpoint from apps.job.enums import SpeedQualityTradeoff from apps.job.models import CostLine, CostSet, Job, JobFile @@ -35,26 +35,25 @@ JOB_SUMMARY_PDF_FILENAME = "JobSummary.pdf" 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_PERSON_PHONE_ATTR = "_workshop_pdf_person_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 - rule carried over from the pre-ClientContactMethod scalar fields). + Display preference order: the job person's own number first, falling back + to the company's number when the person has no phone method. """ - if not hasattr(job, WORKSHOP_PDF_CONTACT_PHONE_ATTR) or not hasattr( - job, WORKSHOP_PDF_CLIENT_PHONE_ATTR + if not hasattr(job, WORKSHOP_PDF_PERSON_PHONE_ATTR) or not hasattr( + 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 "") + person_phone = getattr(job, WORKSHOP_PDF_PERSON_PHONE_ATTR) + if person_phone: + return str(person_phone) + return str(getattr(job, WORKSHOP_PDF_COMPANY_PHONE_ATTR) or "") def _primary_company_endpoint_number() -> str: @@ -71,11 +70,11 @@ def _primary_company_endpoint_number() -> str: def _primary_phone_annotations() -> dict[str, Coalesce]: return { - WORKSHOP_PDF_CONTACT_PHONE_ATTR: ClientContactMethod.primary_phone_annotation( - owner="contact", outer_ref="contact_id" + WORKSHOP_PDF_PERSON_PHONE_ATTR: ContactMethod.primary_phone_annotation( + owner="person", outer_ref="person_id" ), - WORKSHOP_PDF_CLIENT_PHONE_ATTR: ClientContactMethod.primary_phone_annotation( - owner="client", outer_ref="client_id" + WORKSHOP_PDF_COMPANY_PHONE_ATTR: ContactMethod.primary_phone_annotation( + owner="company", outer_ref="company_id" ), } @@ -84,7 +83,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", "person") .annotate(**_primary_phone_annotations()) .get(id=job_id), ) @@ -103,8 +102,8 @@ def get_job_for_workshop_pdf(job_id: UUID) -> Job: return cast( Job, Job.objects.select_related( - "client", - "contact", + "company", + "person", "latest_estimate", "latest_quote", "latest_actual", @@ -150,8 +149,8 @@ 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 + if hasattr(job, WORKSHOP_PDF_PERSON_PHONE_ATTR) and hasattr( + job, WORKSHOP_PDF_COMPANY_PHONE_ATTR ): return job return get_job_for_delivery_docket_pdf(job.id) @@ -420,8 +419,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, @@ -1162,20 +1161,20 @@ 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" - contact_name = job.contact.name if job.contact else "" - contact_phone = _primary_phone_for_job(job) - contact_info = ( - f"{escape(contact_name)}
{escape(contact_phone)}" - if contact_phone - else escape(contact_name or "N/A") + company_name = job.company.name if job.company else "N/A" + person_name = job.person.name if job.person else "" + person_phone = _primary_phone_for_job(job) + person_info = ( + f"{escape(person_name)}
{escape(person_phone)}" + if person_phone + else escape(person_name or "N/A") ) contact_table = Table( [ [ - _plain_paragraph(client_name, header_client_style), - Paragraph(contact_info, header_contact_style), + _plain_paragraph(company_name, header_company_style), + Paragraph(person_info, header_contact_style), ] ], colWidths=[CONTENT_WIDTH * 0.42, CONTENT_WIDTH * 0.58], @@ -1311,23 +1310,25 @@ 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" - contact_name = job.contact.name if job.contact else "" - - contact_phone = _primary_phone_for_job(job) - contact_info = ( - f"{contact_name}
{contact_phone}" if contact_phone else contact_name + company_name = job.company.name if job.company else "N/A" + person_name = job.person.name if job.person else "" + + person_phone = _primary_phone_for_job(job) + person_info = ( + f"{escape(person_name)}
{escape(person_phone)}" + if person_phone + else escape(person_name or "N/A") ) # Delivery docket details - no workshop time or internal notes job_details = [ [ - Paragraph(client_name, header_client_style), - Paragraph(contact_info, header_contact_style), + _plain_paragraph(company_name, header_company_style), + Paragraph(person_info, header_contact_style), ], [ Paragraph("DESCRIPTION", label_style), - Paragraph(job.description or "N/A", body_style), + _plain_paragraph(job.description, body_style), ], [ Paragraph("ENTRY DATE", label_style), @@ -1345,7 +1346,7 @@ def add_delivery_docket_details_table( ], [ Paragraph("ORDER NUMBER", label_style), - Paragraph(job.order_number or "N/A", body_style), + _plain_paragraph(job.order_number, body_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..9a2e8b5f7 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 Company, Person from apps.job.models import CostLine, Job, JobEvent, JobFile, LabourSubtype from apps.workflow.models import CompanyDefaults, XeroPayItem @@ -73,19 +73,16 @@ 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, - name="Jane Doe", - ) + person = Person.objects.create(name="Jane Doe") job = Job.objects.create( - client=client, - contact=contact, + company=company, + person=person, name="Custom Bracket Fabrication", description=("Stainless steel mounting bracket, 200x100x5mm, 4 holes per spec"), notes=( 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_identities_view.py b/apps/job/tests/test_duplicate_identities_view.py new file mode 100644 index 000000000..ab5c38bdf --- /dev/null +++ b/apps/job/tests/test_duplicate_identities_view.py @@ -0,0 +1,39 @@ +from django.utils import timezone + +from apps.accounts.models import Staff +from apps.company.models import Company +from apps.testing import BaseAPITestCase + +URL = "/api/job/data-quality/duplicate-identities/" + + +class DuplicateIdentitiesViewTests(BaseAPITestCase): + def _office_staff(self) -> Staff: + return Staff.objects.create_user( + email="office@example.com", + password="testpass", + first_name="Office", + last_name="Staff", + is_office_staff=True, + ) + + def test_returns_grouped_duplicates_to_office_staff(self) -> None: + Company.objects.create(name="Acme Ltd", xero_last_modified=timezone.now()) + Company.objects.create( + name="CASH SALE - Acme Limited", + xero_last_modified=timezone.now(), + ) + self.client.force_authenticate(self._office_staff()) + + response = self.client.get(URL) + + self.assertEqual(response.status_code, 200) + self.assertEqual(response.data["summary"]["company_merge_groups"], 1) + self.assertEqual(len(response.data["company_groups"]), 1) + + def test_forbidden_for_non_office_staff(self) -> None: + self.client.force_authenticate(self.test_staff) + + response = self.client.get(URL) + + self.assertEqual(response.status_code, 403) diff --git a/apps/job/tests/test_duplicate_phones_view.py b/apps/job/tests/test_duplicate_phones_view.py index 9712fd4ad..c1dc860b3 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 Company, ContactMethod 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: - method = ClientContactMethod( - client=client, - method_type=ClientContactMethod.MethodType.PHONE, + def _phone(self, value: str, company: Company) -> None: + method = ContactMethod( + company=company, + method_type=ContactMethod.MethodType.PHONE, value=value, ) - method.normalized_value = ClientContactMethod.normalize_phone(value) - ClientContactMethod.objects.bulk_create([method]) + method.normalized_value = ContactMethod.normalize_phone(value) + ContactMethod.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()) @@ -36,7 +36,7 @@ def test_returns_cross_client_conflict_to_office_staff(self) -> None: response = self.client.get(URL) self.assertEqual(response.status_code, 200) - self.assertEqual(response.data["summary"]["cross_client"], 1) + self.assertEqual(response.data["summary"]["cross_company"], 1) self.assertEqual(len(response.data["duplicate_phones"]), 1) self.assertEqual(len(response.data["duplicate_phones"][0]["owners"]), 2) 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_terminology_migration.py b/apps/job/tests/test_job_event_terminology_migration.py new file mode 100644 index 000000000..198cfaf41 --- /dev/null +++ b/apps/job/tests/test_job_event_terminology_migration.py @@ -0,0 +1,83 @@ +from typing import ClassVar + +from django.db import connection +from django.db.migrations.executor import MigrationExecutor +from django.test import TransactionTestCase + + +class JobEventTerminologyMigrationTests(TransactionTestCase): + migrate_from: ClassVar[tuple[tuple[str, str], ...]] = ( + ("job", "0005_remove_job_contact_alter_job_person"), + ) + migrate_to: ClassVar[tuple[tuple[str, str], ...]] = ( + ("job", "0006_rename_job_event_people_company_terms"), + ) + + def setUp(self) -> None: + super().setUp() + self.executor = MigrationExecutor(connection) + self.executor.migrate(self.migrate_from) + self.old_apps = self.executor.loader.project_state(self.migrate_from).apps + + def tearDown(self) -> None: + self.executor.loader.build_graph() + self.executor.migrate(self.executor.loader.graph.leaf_nodes()) + super().tearDown() + + def test_forward_and_reverse_rename_event_types_and_detail_labels(self) -> None: + Staff = self.old_apps.get_model("accounts", "Staff") + JobEvent = self.old_apps.get_model("job", "JobEvent") + staff = Staff.objects.create( + email="migration-staff@example.test", + password="not-used", + first_name="Migration", + last_name="Staff", + wage_rate=0, + base_wage_rate=0, + ) + company_event = JobEvent.objects.create( + staff=staff, + event_type="client_changed", + detail={ + "field_name": "Client", + "old_value": "Old Co", + "new_value": "New Co", + }, + ) + person_event = JobEvent.objects.create( + staff=staff, + event_type="contact_changed", + detail={ + "changes": [ + { + "field_name": "Contact", + "old_value": "Old Person", + "new_value": "New Person", + } + ] + }, + ) + + self.executor.loader.build_graph() + self.executor.migrate(self.migrate_to) + new_apps = self.executor.loader.project_state(self.migrate_to).apps + JobEvent = new_apps.get_model("job", "JobEvent") + + company_event = JobEvent.objects.get(pk=company_event.pk) + person_event = JobEvent.objects.get(pk=person_event.pk) + self.assertEqual(company_event.event_type, "company_changed") + self.assertEqual(company_event.detail["field_name"], "Company") + self.assertEqual(person_event.event_type, "person_changed") + self.assertEqual(person_event.detail["changes"][0]["field_name"], "Person") + + self.executor.loader.build_graph() + self.executor.migrate(self.migrate_from) + reversed_apps = self.executor.loader.project_state(self.migrate_from).apps + JobEvent = reversed_apps.get_model("job", "JobEvent") + + company_event = JobEvent.objects.get(pk=company_event.pk) + person_event = JobEvent.objects.get(pk=person_event.pk) + self.assertEqual(company_event.event_type, "client_changed") + self.assertEqual(company_event.detail["field_name"], "Client") + self.assertEqual(person_event.event_type, "contact_changed") + self.assertEqual(person_event.detail["changes"][0]["field_name"], "Contact") 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_header_view.py b/apps/job/tests/test_job_header_view.py index 166565e2e..a51c1c6a3 100644 --- a/apps/job/tests/test_job_header_view.py +++ b/apps/job/tests/test_job_header_view.py @@ -1,7 +1,7 @@ """Job header endpoint tests. Guards the ETag/If-None-Match round trip on the job header fetch, and the -null-client contract (shell jobs have no client; every client/contact field +null-company contract (shell jobs have no company; every company/contact field must serialize as null, not 500). """ @@ -13,7 +13,7 @@ if TYPE_CHECKING: from rest_framework.response import _MonkeyPatchedResponse -from apps.client.models import Client +from apps.company.models import Company from apps.job.models import Job from apps.testing import BaseAPITestCase from apps.workflow.models import XeroPayItem @@ -24,10 +24,10 @@ def setUp(self) -> None: super().setUp() self.client.force_authenticate(user=self.test_staff) - def _job(self, *, job_client: Client | None) -> Job: + def _job(self, *, job_company: Company | None) -> Job: job: Job = Job.objects.create( name="Header Job", - client=job_client, + company=job_company, created_by=self.test_staff, default_xero_pay_item=XeroPayItem.get_ordinary_time(), staff=self.test_staff, @@ -42,23 +42,23 @@ def _get_header( return self.client.get(url, HTTP_IF_NONE_MATCH=if_none_match) return self.client.get(url) - def test_header_for_job_without_client(self) -> None: - job = self._job(job_client=None) + def test_header_for_job_without_company(self) -> None: + job = self._job(job_company=None) response = self._get_header(job) self.assertEqual(response.status_code, 200) payload = response.json() - self.assertIsNone(payload["client_id"]) - self.assertIsNone(payload["client_name"]) - self.assertIsNone(payload["contact_id"]) - self.assertIsNone(payload["contact_name"]) + self.assertIsNone(payload["company_id"]) + self.assertIsNone(payload["company_name"]) + self.assertIsNone(payload["person_id"]) + self.assertIsNone(payload["person_name"]) def test_etag_round_trip_returns_304(self) -> None: - job_client = Client.objects.create( + job_company = Company.objects.create( name="Acme Ltd", xero_last_modified=timezone.now() ) - job = self._job(job_client=job_client) + job = self._job(job_company=job_company) first = self._get_header(job) etag = first.headers["ETag"] 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..17fec110d 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 Company, Person 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,15 +45,15 @@ 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") + person = Person.objects.create(name="Site Contact") job = Job.objects.create( name="Editable Job", - client=client, - contact=contact, + company=company, + person=person, created_by=self.test_staff, default_xero_pay_item=XeroPayItem.get_ordinary_time(), staff=self.test_staff, 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..ca10b5670 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 Company, CompanyPersonLink, Person 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,22 +36,26 @@ def _make_job( self, *, name: str, - client_name: str, + company_name: str, status: str = "in_progress", - contact_name: str | None = None, + person_name: str | None = None, order_number: str | None = None, ): - client = self._make_client(client_name) - contact = None - if contact_name: - contact = ClientContact.objects.create(client=client, name=contact_name) + company = self._make_client(company_name) + person = None + if person_name: + person = Person.objects.create(name=person_name) + CompanyPersonLink.objects.create( + company=company, + person=person, + ) self.job_number += 1 return Job.objects.create( staff=self.test_staff, name=name, - client=client, - contact=contact, + company=company, + person=person, status=status, job_number=self.job_number, created_by=self.test_staff, @@ -68,7 +72,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 +87,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 +100,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 +118,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 +141,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 +161,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 +187,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 +206,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 +222,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 +249,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 +257,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 +265,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,14 +284,14 @@ 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", - contact_name="Alice Brown", + company_name="Another Company", + person_name="Alice Brown", ) jobs = list(KanbanService.perform_advanced_search({"universal_search": "977"})) @@ -299,14 +303,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 +332,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 +348,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( @@ -362,17 +366,17 @@ def test_perform_advanced_search_matches_client_tokens_in_any_order(self): self.assertEqual([job.id for job in jobs], [target.id]) - def test_perform_advanced_search_matches_contact_name_substring(self): + def test_perform_advanced_search_matches_person_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", - contact_name="Molly Wainwright", + company_name="Weaver, Decker and Schultz", + person_name="Molly Wainwright", ) self._make_job( name="Other work", - client_name="Other Client", - contact_name="Alice Brown", + company_name="Other Company", + person_name="Alice Brown", ) jobs = list(KanbanService.perform_advanced_search({"universal_search": "wain"})) @@ -380,15 +384,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 +407,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 +420,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 +436,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 +454,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 +469,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 +488,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 +507,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 +526,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 +540,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 +557,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 +574,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 +593,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 +614,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 +631,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 +644,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 +662,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..e91b78b6f 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 Company, CompanyPersonLink, Person 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, ) @@ -43,6 +43,13 @@ def _set_summary_revenue(self, cost_set, revenue): def _serialize_one(self, job: Job) -> dict[str, Any]: return KanbanService.serialize_jobs_for_api([job])[0] + def _link(self, name: str) -> CompanyPersonLink: + person = Person.objects.create(name=name) + return CompanyPersonLink.objects.create( + company=self.client_obj, + person=person, + ) + def test_over_budget_when_tm_actual_exceeds_price_cap(self): """T&M jobs show over-budget when actual revenue exceeds the price cap.""" job = self._create_job("time_materials") @@ -80,10 +87,9 @@ def test_serialize_jobs_for_api_query_count_is_constant(self): jobs = [] 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}" - ) - job.save(staff=self.test_staff, update_fields=["contact"]) + link = self._link(f"Contact {index}") + job.person = link.person + job.save(staff=self.test_staff, update_fields=["person"]) job.people.add(self.test_staff) self._set_summary_revenue(job.latest_actual, Decimal("100.00")) self._set_summary_revenue(job.latest_quote, Decimal("200.00")) @@ -107,10 +113,10 @@ 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") - job.contact = contact + contact = self._link("Jane Doe") + job.person = contact.person job.delivery_date = timezone.localdate() - job.save(staff=self.test_staff, update_fields=["contact", "delivery_date"]) + job.save(staff=self.test_staff, update_fields=["person", "delivery_date"]) staff_b = Staff.objects.create_user( email="kanban-shape-b@example.com", password="testpass", @@ -136,8 +142,8 @@ def test_serialized_shape_for_fully_populated_job(self): "name", "description", "job_number", - "client_name", - "contact_person", + "company_name", + "person_name", "people", "status", "status_key", @@ -161,8 +167,8 @@ def test_serialized_shape_for_fully_populated_job(self): "badge_color", }, ) - self.assertEqual(serialized["client_name"], "Test Client") - self.assertEqual(serialized["contact_person"], "Jane Doe") + self.assertEqual(serialized["company_name"], "Test Company") + self.assertEqual(serialized["person_name"], "Jane Doe") self.assertEqual(serialized["quote_revenue"], 200.0) self.assertEqual(serialized["time_and_materials_revenue"], 100.0) self.assertEqual(serialized["created_by_id"], str(self.test_staff.id)) @@ -178,7 +184,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 +206,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_schema_contract.py b/apps/job/tests/test_labour_schema_contract.py index 44715bbcb..4d3b85ba7 100644 --- a/apps/job/tests/test_labour_schema_contract.py +++ b/apps/job/tests/test_labour_schema_contract.py @@ -1,27 +1,34 @@ from __future__ import annotations -from typing import Any, ClassVar, cast +from typing import ClassVar from django.test import TestCase from drf_spectacular.generators import SchemaGenerator +from apps.workflow.api.types import JsonObject, JsonValue + + +def _as_object(value: JsonValue) -> JsonObject: + if not isinstance(value, dict): + raise TypeError(f"expected JSON object, got {type(value).__name__}") + return value + class LabourRateSchemaContractTests(TestCase): """The OpenAPI contract must advertise non-negative labour rates.""" - schemas: ClassVar[dict[str, Any]] + schemas: ClassVar[JsonObject] @classmethod def setUpTestData(cls) -> None: - generator = SchemaGenerator() # type: ignore[no-untyped-call] # drf-spectacular does not ship complete typing for tests. - schema = generator.get_schema(public=True) # type: ignore[no-untyped-call] # Contract test intentionally exercises drf-spectacular. - assert schema is not None - cls.schemas = cast(dict[str, Any], schema["components"]["schemas"]) - - def _property(self, schema_name: str, property_name: str) -> dict[str, Any]: - return cast( - dict[str, Any], self.schemas[schema_name]["properties"][property_name] - ) + schema = SchemaGenerator().get_schema(public=True) + if schema is None: + raise RuntimeError("schema generation returned None") + cls.schemas = _as_object(_as_object(schema["components"])["schemas"]) + + def _property(self, schema_name: str, property_name: str) -> JsonObject: + schema = _as_object(self.schemas[schema_name]) + return _as_object(_as_object(schema["properties"])[property_name]) def test_labour_subtype_default_rate_is_non_negative(self) -> None: for schema_name in [ 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..781bd7ed2 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 Company, ContactMethod, Person from apps.job.models import CostSet, Job, LabourSubtype from apps.job.models.costing import CostLine from apps.job.services.workshop_pdf_service import ( @@ -33,33 +33,30 @@ 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, - name="Jane Doe", - ) + self.person = Person.objects.create(name="Jane Doe") self.job = Job.objects.create( - client=self.client_obj, - contact=self.contact, + company=self.client_obj, + person=self.person, name="Phone Pref Job", staff=self.test_staff, ) - ClientContactMethod.objects.create( - client=self.client_obj, - method_type=ClientContactMethod.MethodType.PHONE, + ContactMethod.objects.create( + company=self.client_obj, + method_type=ContactMethod.MethodType.PHONE, value="09 555 0001", ) def test_contact_phone_wins_when_contact_has_one(self) -> None: - ClientContactMethod.objects.create( - contact=self.contact, - method_type=ClientContactMethod.MethodType.PHONE, + ContactMethod.objects.create( + person=self.person, + method_type=ContactMethod.MethodType.PHONE, value="021 555 100", ) loaded_job = get_job_for_delivery_docket_pdf(self.job.id) @@ -72,8 +69,8 @@ def test_falls_back_to_client_phone_when_contact_has_none(self) -> None: self.assertEqual(_primary_phone_for_job(loaded_job), "09 555 0001") def test_no_contact_uses_client_phone(self) -> None: - self.job.contact = None - self.job.save(staff=self.test_staff, update_fields=["contact"]) + self.job.person = None + self.job.save(staff=self.test_staff, update_fields=["person"]) loaded_job = get_job_for_delivery_docket_pdf(self.job.id) self.assertEqual(_primary_phone_for_job(loaded_job), "09 555 0001") @@ -85,9 +82,9 @@ def test_plain_job_phone_lookup_is_rejected(self) -> None: _primary_phone_for_job(self.job) def test_annotated_contact_phone_wins_when_contact_has_one(self) -> None: - ClientContactMethod.objects.create( - contact=self.contact, - method_type=ClientContactMethod.MethodType.PHONE, + ContactMethod.objects.create( + person=self.person, + method_type=ContactMethod.MethodType.PHONE, value="021 555 100", ) @@ -396,12 +393,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, ) @@ -553,9 +550,9 @@ def test_preloaded_workshop_pdf_render_does_not_query_cost_lines(self) -> None: estimate = self.job.latest_estimate assert estimate is not None self._add_time(estimate, "Workshop", "4.000", "Workshop") - ClientContactMethod.objects.create( - client=self.job.client, - method_type=ClientContactMethod.MethodType.PHONE, + ContactMethod.objects.create( + company=self.job.company, + method_type=ContactMethod.MethodType.PHONE, value="09 555 0001", ) @@ -583,7 +580,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_contactmethod"' in query["sql"] ] self.assertEqual(cost_line_queries, []) self.assertEqual(contact_method_queries, []) @@ -659,12 +656,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/urls_rest.py b/apps/job/urls_rest.py index 895ebf117..b2a7a4e26 100644 --- a/apps/job/urls_rest.py +++ b/apps/job/urls_rest.py @@ -3,6 +3,7 @@ from apps.job.views.data_integrity_views import DataIntegrityReportView from apps.job.views.data_quality_report_views import ( ArchivedJobsComplianceView, + DuplicateIdentitiesView, DuplicatePhonesView, ) from apps.job.views.delivery_docket_view import DeliveryDocketView @@ -336,6 +337,11 @@ DuplicatePhonesView.as_view(), name="data_quality_duplicate_phones", ), + path( + "data-quality/duplicate-identities/", + DuplicateIdentitiesView.as_view(), + name="data_quality_duplicate_identities", + ), path( "data-integrity/scan/", DataIntegrityReportView.as_view(), 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/__init__.py b/apps/job/views/__init__.py index f55f59eff..2fbcbf9a7 100644 --- a/apps/job/views/__init__.py +++ b/apps/job/views/__init__.py @@ -6,7 +6,11 @@ ) from .assign_job_view import JobAssignmentCreateView, JobAssignmentDeleteView from .data_integrity_views import DataIntegrityReportView -from .data_quality_report_views import ArchivedJobsComplianceView, DuplicatePhonesView +from .data_quality_report_views import ( + ArchivedJobsComplianceView, + DuplicateIdentitiesView, + DuplicatePhonesView, +) from .delivery_docket_view import DeliveryDocketView from .job_costing_views import JobCostSetView, JobQuoteRevisionView from .job_costline_views import ( @@ -109,6 +113,7 @@ "CostLineUpdateView", "DataIntegrityReportView", "DeliveryDocketView", + "DuplicateIdentitiesView", "DuplicatePhonesView", "FetchAllJobsAPIView", "FetchJobsAPIView", diff --git a/apps/job/views/data_quality_report_views.py b/apps/job/views/data_quality_report_views.py index 905317af7..63c2d0607 100644 --- a/apps/job/views/data_quality_report_views.py +++ b/apps/job/views/data_quality_report_views.py @@ -12,10 +12,14 @@ 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_identity_report import ( + DuplicateIdentityReportService, +) +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, + DuplicateIdentitiesResponseSerializer, DuplicatePhonesResponseSerializer, ) from apps.job.services.data_quality_report import ArchivedJobsComplianceService @@ -71,7 +75,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 +85,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() @@ -94,3 +98,31 @@ def get(self, request: Request) -> Response: except Exception as exc: err = persist_app_error(exc) raise AlreadyLoggedException(exc, err.id) from exc + + +class DuplicateIdentitiesView(APIView): + """API view for grouped Company and Person duplicate exceptions.""" + + permission_classes = [IsAuthenticated, IsOfficeStaff] + + @extend_schema( + operation_id="check_duplicate_identities", + summary="Check for duplicate companies and people", + description=( + "List compact groups of Company and Person identities that are safe " + "to merge automatically or require review." + ), + responses={200: DuplicateIdentitiesResponseSerializer, 500: dict}, + tags=["Data Quality"], + ) + def get(self, request: Request) -> Response: + try: + result = DuplicateIdentityReportService().get_report() + serializer = DuplicateIdentitiesResponseSerializer(data=result) + serializer.is_valid(raise_exception=True) + return Response(serializer.data, status=status.HTTP_200_OK) + except AlreadyLoggedException: + raise + except Exception as exc: + err = persist_app_error(exc) + raise AlreadyLoggedException(exc, err.id) from exc 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..e83e06f32 100644 --- a/apps/job/views/job_rest_views.py +++ b/apps/job/views/job_rest_views.py @@ -259,11 +259,11 @@ 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", - "contact_id": "optional-contact-uuid" + "person_id": "optional-person-uuid" "pricing_methodology": "Optional methodology (defaults to T&M)" } """ @@ -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/person for joins query_fields = [ "id", "updated_at", - "client_id", - "contact_id", + "company_id", + "person_id", ] + Job.JOB_DIRECT_FIELDS job = ( - Job.objects.select_related("client", "contact") + Job.objects.select_related("company", "person") .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..bc237261c 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,18 +359,18 @@ 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", + name="person_name", type=str, location=OpenApiParameter.QUERY, required=False, - description="Filter by contact person", + description="Filter by person name", ), OpenApiParameter( name="order_number", @@ -439,8 +439,8 @@ 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", ""), - "contact_person": request.GET.get("contact_person", ""), + "company_name": request.GET.get("company_name", ""), + "person_name": request.GET.get("person_name", ""), "order_number": request.GET.get("order_number", ""), "created_by": request.GET.get("created_by", ""), "created_after": request.GET.get("created_after", ""), 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..6e42c59ea 100644 --- a/apps/job/views/workshop_view.py +++ b/apps/job/views/workshop_view.py @@ -30,7 +30,9 @@ def get_queryset(self): """Retrieve jobs for the workshop kanban view.""" staff = self.request.user logger.info(f"Fetching in-progress jobs for staff ID: {staff.id}") - jobs = Job.objects.filter(people__id=staff.id, status__in=["in_progress"]) + jobs = Job.objects.filter( + people__id=staff.id, status__in=["in_progress"] + ).select_related("company", "person") logger.info(f"Retrieved {jobs.count()} jobs for staff ID: {staff.id}") return [ @@ -39,8 +41,8 @@ def get_queryset(self): "name": job.name, "description": job.description, "job_number": job.job_number, - "client_name": job.client.name, - "contact_person": job.contact.name if job.contact else None, + "company_name": job.company.name, + "person_name": job.person.name if job.person else None, "people": [ { "id": staff.id, 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/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/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/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 f7f7e182e..0efad55f0 100644 --- a/apps/purchasing/migrations/0001_baseline.py +++ b/apps/purchasing/migrations/0001_baseline.py @@ -14,44 +14,8 @@ 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"), + ("company", "0001_baseline"), ("job", "0001_baseline"), migrations.swappable_dependency(settings.AUTH_USER_MODEL), ] @@ -147,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", ), ), ( @@ -157,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/stock_search_service.py b/apps/purchasing/services/stock_search_service.py index c181d1252..aedcbc14c 100644 --- a/apps/purchasing/services/stock_search_service.py +++ b/apps/purchasing/services/stock_search_service.py @@ -420,7 +420,7 @@ def search_stock(query: str, limit: int = 10) -> List[Dict[str, Any]]: Quick top-N search for typeahead/autocomplete callers. Returns [] for queries shorter than 3 characters; matches the guard - used by ClientRestService.search_clients. + used by CompanyRestService.search_companies. """ try: if not query or len(query.strip()) < 3: diff --git a/apps/purchasing/services/supplier_search_service.py b/apps/purchasing/services/supplier_search_service.py index 3f7b7c344..ecddbacc2 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, Q from django.utils import timezone -from apps.client.models import Client, ClientContactMethod, SupplierSearchAlias +from apps.company.models import Company, ContactMethod, 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 @@ -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,18 +138,18 @@ 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 "", - "phone": client.primary_phone, - "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 "", + "id": str(company.id), + "name": company.name, + "email": company.email or "", + "phone": company.primary_phone, + "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, @@ -173,7 +173,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", @@ -181,8 +181,8 @@ def list_suppliers( & ~Q(purchase_orders__status="deleted"), distinct=True, ), - primary_phone=ClientContactMethod.primary_phone_annotation( - owner="client", outer_ref="pk" + primary_phone=ContactMethod.primary_phone_annotation( + owner="company", outer_ref="pk" ), ) .defer("raw_json") @@ -206,7 +206,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, @@ -222,15 +222,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 = [ @@ -239,7 +239,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), @@ -253,7 +253,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, @@ -267,7 +267,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) @@ -276,7 +276,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 979dba1d7..5b981f73d 100644 --- a/apps/quoting/migrations/0001_baseline.py +++ b/apps/quoting/migrations/0001_baseline.py @@ -13,31 +13,8 @@ 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"), + ("company", "0001_baseline"), migrations.swappable_dependency(settings.AUTH_USER_MODEL), ] @@ -76,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", ), ), ], @@ -138,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", ), ), ], @@ -173,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", ), ), ], @@ -380,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", ), ), ], @@ -415,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..84db298b7 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,8 +69,7 @@ def handle(self, *args, **options): job = Job( name=name, status="special", - client=client, - contact=None, + company=company, pricing_methodology="time_materials", speed_quality_tradeoff="normal", job_is_valid=True, 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/__init__.py b/apps/workflow/__init__.py index 07f6bf557..a76b6b2c7 100644 --- a/apps/workflow/__init__.py +++ b/apps/workflow/__init__.py @@ -61,6 +61,7 @@ XeroAppCreateSerializer, XeroAppSerializer, XeroAuthenticationErrorResponseSerializer, + XeroBrandingThemeSerializer, XeroDocumentErrorResponseSerializer, XeroDocumentSuccessResponseSerializer, XeroErrorDetailResponseSerializer, @@ -138,6 +139,7 @@ "XeroAppCreateSerializer", "XeroAppSerializer", "XeroAuthenticationErrorResponseSerializer", + "XeroBrandingThemeSerializer", "XeroDocumentErrorResponseSerializer", "XeroDocumentSuccessResponseSerializer", "XeroErrorDetailResponseSerializer", diff --git a/apps/workflow/accounting/document_theme_service.py b/apps/workflow/accounting/document_theme_service.py new file mode 100644 index 000000000..3dad89dec --- /dev/null +++ b/apps/workflow/accounting/document_theme_service.py @@ -0,0 +1,29 @@ +"""Selection of the sales document branding theme.""" + +from uuid import UUID + +from apps.workflow.accounting.provider import AccountingProvider +from apps.workflow.accounting.types import DocumentTheme + + +def resolve_sales_branding_theme( + provider: AccountingProvider, + configured_id: UUID | None, +) -> DocumentTheme | None: + """Preserve a live selection or return the provider's first theme. + + Providers return themes in their preferred order. The Xero provider orders + them by Xero's ``SortOrder``. + """ + themes = provider.list_document_themes() + if not themes: + return None + + return next( + ( + theme + for theme in themes + if configured_id is not None and theme.external_id == str(configured_id) + ), + themes[0], + ) diff --git a/apps/workflow/accounting/provider.py b/apps/workflow/accounting/provider.py index 8a25ef0bb..d203975a5 100644 --- a/apps/workflow/accounting/provider.py +++ b/apps/workflow/accounting/provider.py @@ -8,12 +8,13 @@ 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 ( ContactResult, DocumentResult, + DocumentTheme, InvoicePayload, POPayload, QuotePayload, @@ -61,11 +62,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.""" ... @@ -75,6 +76,10 @@ def search_contact_by_name(self, name: str) -> ContactResult | None: # --- Documents --- + def list_document_themes(self) -> list[DocumentTheme]: + """List selectable document themes from the accounting system.""" + ... + def create_invoice(self, payload: InvoicePayload) -> DocumentResult: """Create an invoice in the accounting system.""" ... diff --git a/apps/workflow/accounting/types.py b/apps/workflow/accounting/types.py index 8a844d4d1..d62f05063 100644 --- a/apps/workflow/accounting/types.py +++ b/apps/workflow/accounting/types.py @@ -6,6 +6,15 @@ from decimal import Decimal +@dataclass(frozen=True) +class DocumentTheme: + """A selectable document presentation theme from an accounting provider.""" + + external_id: str + name: str + is_default: bool + + @dataclass class DocumentLineItem: """A single line item on an invoice, quote, or purchase order.""" @@ -22,10 +31,11 @@ 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 + document_theme_external_id: str currency_code: str = "NZD" reference: str | None = None url: str | None = None @@ -38,10 +48,11 @@ 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 + document_theme_external_id: str currency_code: str = "NZD" reference: str | None = None status: str = "DRAFT" diff --git a/apps/workflow/accounting/xero/provider.py b/apps/workflow/accounting/xero/provider.py index 8137553ba..d36ff60d0 100644 --- a/apps/workflow/accounting/xero/provider.py +++ b/apps/workflow/accounting/xero/provider.py @@ -5,17 +5,22 @@ import logging from datetime import datetime from typing import TYPE_CHECKING +from uuid import UUID from apps.workflow.accounting.registry import register_provider from apps.workflow.api.xero.transforms import process_xero_data +from apps.workflow.exceptions import AlreadyLoggedException from apps.workflow.services.error_persistence import persist_app_error if TYPE_CHECKING: - from apps.client.models import Client + from xero_python.accounting import AccountingApi + + from apps.company.models import Company from apps.job.models import Job from apps.workflow.accounting.types import ( ContactResult, DocumentResult, + DocumentTheme, InvoicePayload, POPayload, QuotePayload, @@ -33,7 +38,7 @@ class XeroAccountingProvider: # --- Shared helpers (DRY) --- @staticmethod - def _get_api(): + def _get_api() -> tuple[AccountingApi, str]: from xero_python.accounting import AccountingApi from apps.workflow.api.xero.auth import api_client, get_tenant_id @@ -125,31 +130,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) @@ -171,6 +176,43 @@ def search_contact_by_name(self, name: str) -> ContactResult | None: # --- Documents --- + def list_document_themes(self) -> list[DocumentTheme]: + from apps.workflow.accounting.types import DocumentTheme + + try: + api, tenant_id = self._get_api() + response = api.get_branding_themes(tenant_id) + ranked_themes: list[tuple[int, DocumentTheme]] = [] + + for theme in response.branding_themes: + if not isinstance(theme.branding_theme_id, str): + raise ValueError("Xero branding theme is missing its identifier") + if not isinstance(theme.name, str) or not theme.name: + raise ValueError("Xero branding theme is missing its name") + if not isinstance(theme.sort_order, int): + raise ValueError( + f"Xero branding theme {theme.name} is missing its sort order" + ) + + external_id = str(UUID(theme.branding_theme_id)) + ranked_themes.append( + ( + theme.sort_order, + DocumentTheme( + external_id=external_id, + name=theme.name, + is_default=theme.sort_order == 0, + ), + ) + ) + + return [theme for _sort_order, theme in sorted(ranked_themes)] + except AlreadyLoggedException: + raise + except Exception as exc: + err = persist_app_error(exc) + raise AlreadyLoggedException(exc, err.id) from exc + def create_invoice(self, payload: InvoicePayload) -> DocumentResult: from xero_python.accounting.models import Contact, Invoice @@ -182,7 +224,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(), @@ -192,6 +234,7 @@ def create_invoice(self, payload: InvoicePayload) -> DocumentResult: status=payload.status, reference=payload.reference, url=payload.url, + branding_theme_id=payload.document_theme_external_id, ) response = api.create_invoices( @@ -253,7 +296,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(), @@ -262,6 +305,7 @@ def create_quote(self, payload: QuotePayload) -> DocumentResult: currency_code=payload.currency_code, status=payload.status, reference=payload.reference, + branding_theme_id=payload.document_theme_external_id, ) response = api.create_quotes( diff --git a/apps/workflow/accounting/xero/readonly_provider.py b/apps/workflow/accounting/xero/readonly_provider.py index 2757d3c6d..5d6db4cd4 100644 --- a/apps/workflow/accounting/xero/readonly_provider.py +++ b/apps/workflow/accounting/xero/readonly_provider.py @@ -2,7 +2,7 @@ Selected by the registry when ``settings.XERO_READONLY`` is true (E2E/test backends only). Every write logs a warning and returns a well-formed fake -result so callers — the invoice/quote/PO managers and the client-create +result so callers — the invoice/quote/PO managers and the company-create flow — behave exactly as with real Xero, without anything reaching the Xero tenant. Suppressed writes are not errors: nothing here persists an AppError. @@ -21,7 +21,7 @@ from apps.workflow.accounting.xero.provider import XeroAccountingProvider if TYPE_CHECKING: - from apps.client.models import Client + from apps.company.models import Company from apps.workflow.accounting.types import ( ContactResult, DocumentLineItem, @@ -64,33 +64,33 @@ class XeroReadOnlyProvider(XeroAccountingProvider): # --- Contacts --- - def create_contact(self, client: Client) -> ContactResult: + def create_contact(self, company: Company) -> ContactResult: from apps.workflow.accounting.types import ContactResult - if not client.validate_for_xero(): + if not company.validate_for_xero(): return ContactResult( - success=False, error=f"Client {client.id} failed Xero validation" + success=False, error=f"Company {company.id} failed Xero validation" ) - # Mirror push.create_client_contact_in_xero's side effect: callers - # (and the frontend Xero badge) read client.xero_contact_id. - client.xero_contact_id = _fake_id() - client.save(update_fields=["xero_contact_id"]) - _log_suppressed("create_contact", f"client {client.id} ({client.name})") + # Mirror push.create_company_contact_in_xero's side effect: callers + # (and the frontend Xero badge) read company.xero_contact_id. + company.xero_contact_id = _fake_id() + company.save(update_fields=["xero_contact_id"]) + _log_suppressed("create_contact", f"company {company.id} ({company.name})") return ContactResult( - success=True, external_id=client.xero_contact_id, name=client.name + success=True, external_id=company.xero_contact_id, name=company.name ) - def update_contact(self, client: Client) -> ContactResult: + def update_contact(self, company: Company) -> ContactResult: from apps.workflow.accounting.types import ContactResult - if not client.xero_contact_id: - # Mirror sync_client_to_xero: updating a client that has no + if not company.xero_contact_id: + # Mirror sync_company_to_xero: updating a company that has no # contact ID is an upsert — it creates the contact and assigns # a fresh ID rather than succeeding with a missing external_id. - return self.create_contact(client) - _log_suppressed("update_contact", f"client {client.id} ({client.name})") + return self.create_contact(company) + _log_suppressed("update_contact", f"company {company.id} ({company.name})") return ContactResult( - success=True, external_id=client.xero_contact_id, name=client.name + success=True, external_id=company.xero_contact_id, name=company.name ) # --- Documents --- @@ -101,7 +101,7 @@ def create_invoice(self, payload: InvoicePayload) -> DocumentResult: fake = _fake_id() number = f"INV-E2E-{fake[:8].upper()}" sub_total, tax, total = _fake_totals(payload.line_items) - _log_suppressed("create_invoice", f"{number} for {payload.client_name}") + _log_suppressed("create_invoice", f"{number} for {payload.company_name}") return DocumentResult( success=True, external_id=fake, @@ -114,7 +114,7 @@ def create_invoice(self, payload: InvoicePayload) -> DocumentResult: "_total_tax": tax, "_total": total, "_amount_due": total, - "_contact": {"_name": payload.client_name}, + "_contact": {"_name": payload.company_name}, "_e2e_stub": True, }, ) @@ -132,7 +132,7 @@ def create_quote(self, payload: QuotePayload) -> DocumentResult: fake = _fake_id() number = f"QU-E2E-{fake[:8].upper()}" sub_total, _tax, total = _fake_totals(payload.line_items) - _log_suppressed("create_quote", f"{number} for {payload.client_name}") + _log_suppressed("create_quote", f"{number} for {payload.company_name}") return DocumentResult( success=True, external_id=fake, @@ -143,7 +143,7 @@ def create_quote(self, payload: QuotePayload) -> DocumentResult: "_quote_number": number, "_sub_total": sub_total, "_total": total, - "_contact": {"_name": payload.client_name}, + "_contact": {"_name": payload.company_name}, "_e2e_stub": True, }, ) diff --git a/apps/workflow/api/pagination.py b/apps/workflow/api/pagination.py index cca7292f8..192e3aa8c 100644 --- a/apps/workflow/api/pagination.py +++ b/apps/workflow/api/pagination.py @@ -1,12 +1,7 @@ -from typing import TypeAlias - from rest_framework.pagination import PageNumberPagination from rest_framework.response import Response -JsonValue: TypeAlias = ( - None | bool | int | float | str | list["JsonValue"] | dict[str, "JsonValue"] -) -JsonObject: TypeAlias = dict[str, JsonValue] +from apps.workflow.api.types import JsonObject, JsonValue class FiftyPerPagePagination(PageNumberPagination): 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/types.py b/apps/workflow/api/types.py new file mode 100644 index 000000000..2f29d7c42 --- /dev/null +++ b/apps/workflow/api/types.py @@ -0,0 +1,6 @@ +from typing import TypeAlias + +JsonValue: TypeAlias = ( + None | bool | int | float | str | list["JsonValue"] | dict[str, "JsonValue"] +) +JsonObject: TypeAlias = dict[str, JsonValue] 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/active_app.py b/apps/workflow/api/xero/active_app.py index 13719dbb1..d9215a1f3 100644 --- a/apps/workflow/api/xero/active_app.py +++ b/apps/workflow/api/xero/active_app.py @@ -116,7 +116,6 @@ def wipe_tokens_and_quota(app: XeroApp) -> None: from Xero's perspective; old tokens and quota state are invalid). """ XeroApp.objects.filter(id=app.id).update( - tenant_id=None, token_type=None, access_token=None, refresh_token=None, diff --git a/apps/workflow/api/xero/auth.py b/apps/workflow/api/xero/auth.py index c008d9b80..0d7e660db 100644 --- a/apps/workflow/api/xero/auth.py +++ b/apps/workflow/api/xero/auth.py @@ -145,15 +145,9 @@ def _make_token_saver(app_id) -> Callable[[Dict[str, Any]], None]: here, NOT to whichever row is currently active. This is what makes credential swaps safe under concurrent refreshes. - The saver writes tokens only — never tenant_id. tenant_id is per-app - and a connections lookup needs an ApiClient; the only ApiClient - available here is the global ``api_client`` proxy, which resolves to - the currently active row, not necessarily ``app_id``. Writing the - proxy's tenant onto ``app_id``'s row would corrupt the row→tenant - binding the moment ``app_id`` isn't the active one (e.g. a refresh - in flight when an operator swaps active apps). XeroApp.tenant_id is - informational; live calls read CompanyDefaults.xero_tenant_id and the - global TENANT_ID_CACHE_KEY, not this column. + The saver writes token fields only. The active tenant lives on + CompanyDefaults.xero_tenant_id and the global TENANT_ID_CACHE_KEY — + resolved per call by get_tenant_id(), never stored on the app row. """ def _save(token: Dict[str, Any]) -> None: @@ -370,8 +364,11 @@ def exchange_code_for_token( def get_tenant_id() -> str: - """Retrieve the tenant ID, refreshing the token or fetching from Xero - connections if needed.""" + """Return the active Xero tenant ID (cached in TENANT_ID_CACHE_KEY). + + Source of truth is CompanyDefaults.xero_tenant_id; the cache only avoids + re-reading it. An unset value is a configuration error, not a fallback case. + """ tenant_id = cache.get(TENANT_ID_CACHE_KEY) payload = get_valid_token() @@ -385,20 +382,15 @@ def get_tenant_id() -> str: return tenant_id company_defaults = CompanyDefaults.get_solo() - if company_defaults.xero_tenant_id: - tenant_id = company_defaults.xero_tenant_id - cache.set(TENANT_ID_CACHE_KEY, tenant_id) - logger.info("Xero tenant ID resolved from CompanyDefaults and cached") - return tenant_id - - try: - tenant_id = get_tenant_id_from_connections() - cache.set(TENANT_ID_CACHE_KEY, tenant_id) - logger.info("Xero tenant ID resolved from Xero connections and cached") - except AlreadyLoggedException: - raise - except Exception as exc: + if not company_defaults.xero_tenant_id: + exc = RuntimeError( + "No Xero tenant ID configured in company defaults. " + "Please set this up first." + ) err = persist_app_error(exc) raise AlreadyLoggedException(exc, err.id) from exc + tenant_id = company_defaults.xero_tenant_id + cache.set(TENANT_ID_CACHE_KEY, tenant_id) + logger.info("Xero tenant ID resolved from CompanyDefaults and cached") return tenant_id diff --git a/apps/workflow/api/xero/push.py b/apps/workflow/api/xero/push.py index e7817097e..6f28a1a3f 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,39 +470,39 @@ 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""" - if not clients_to_create: +def bulk_create_contacts_in_xero(companies_to_create, batch_size=50): + """Create multiple company contacts in Xero in batches of 50""" + if not companies_to_create: return 0 accounting_api = AccountingApi(api_client) total_created = 0 - for i in range(0, len(clients_to_create), batch_size): - batch = clients_to_create[i : i + batch_size] + for i in range(0, len(companies_to_create), batch_size): + batch = companies_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) @@ -527,24 +527,24 @@ def bulk_create_contacts_in_xero(clients_to_create, batch_size=50): time.sleep(SLEEP_TIME) - # Map response back to clients by submission order. Verified + # Map response back to companies 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 b498e1868..935a58d5d 100644 --- a/apps/workflow/api/xero/reprocess_xero.py +++ b/apps/workflow/api/xero/reprocess_xero.py @@ -16,17 +16,15 @@ Invoice, InvoiceLineItem, ) -from apps.client.models import ( - Client, - ClientContact, - ClientContactMethod, +from apps.company.models import ( + Company, + ContactMethod, SupplierPickupAddress, ) from apps.crm.tasks import rematch_phone_calls_task from apps.workflow.exceptions import AlreadyLoggedException from apps.workflow.models import XeroAccount from apps.workflow.services.error_persistence import ( - persist_and_raise, persist_app_error, ) @@ -40,8 +38,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 +48,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 [] @@ -59,33 +57,32 @@ def sync_xero_phone_methods(client: Client) -> list[str]: if not isinstance(phone_entry, dict): continue value = _xero_phone_value(phone_entry) - normalized = ClientContactMethod.normalize_phone(value) + normalized = ContactMethod.normalize_phone(value) if not normalized: continue - # The "one number, one client" rule is enforced by the grandfathered - # ClientContactMethod.save() guard reached via get_or_create below: + # The "one number, one company" rule is enforced by the grandfathered + # ContactMethod.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, - contact=None, - method_type=ClientContactMethod.MethodType.PHONE, + _, created = ContactMethod.objects.get_or_create( + company=company, + method_type=ContactMethod.MethodType.PHONE, normalized_value=normalized, defaults={ "value": value, "label": phone_type, "is_primary": phone_type == "DEFAULT", - "source": ClientContactMethod.Source.IMPORTED, + "source": ContactMethod.Source.IMPORTED, }, ) except AlreadyLoggedException: 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 +90,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 +166,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,18 +240,23 @@ 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() - client.save() + company.name = company.name or "Unnamed Company" + company.xero_last_modified = company.xero_last_modified or timezone.now() + company.save() return # Capture old values for change tracking (only for updates, not new clients) @@ -263,33 +265,31 @@ def set_client_fields(client: Client, new_from_xero: bool = False) -> None: "email", "address", "is_account_customer", - "primary_contact_name", - "primary_contact_email", "xero_archived", ] 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 @@ -299,12 +299,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" @@ -323,11 +325,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 ( @@ -348,7 +350,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, @@ -361,76 +363,10 @@ 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) - contact_last_name = raw_json.get("_last_name", 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}" - case (str() as first, None) if first: - client.primary_contact_name = first - case (None, str() as last) if last: - client.primary_contact_name = last - case _: - client.primary_contact_name = "Unnamed Contact" - - contact_email = raw_json.get("_email_address", None) - if contact_email: - client.primary_contact_email = contact_email - - additional_persons = raw_json.get("_contact_persons", []) - if len(additional_persons) > 0: - client.additional_contact_persons = [ - { - "name": (person.get("_first_name") or "") - + (" " + person.get("_last_name") if person.get("_last_name") else ""), - "email": person.get("_email_address"), - } - for person in additional_persons - if isinstance(person, dict) - ] - for person in additional_persons: - if isinstance(person, dict): - first_name = person.get("_first_name") or "" - last_name = person.get("_last_name") or "" - name = (first_name + (" " + last_name if last_name else "")).strip() - - # Skip contacts with empty names - they're pointless - if not name: - logger.debug( - f"Skipping contact with empty name for client {client.name}" - ) - continue - - # Use None instead of empty string for nullable fields - email = person.get("_email_address") or None - - try: - contact, created = ClientContact.objects.get_or_create( - client=client, - name=name, - defaults={"email": email}, - ) - # Update email if contact exists and email changed - if not created and contact.email != email: - contact.email = email - contact.save() - except ClientContact.MultipleObjectsReturned as exc: - # Should NEVER happen after migrations + constraint. - # If we hit this, data integrity is broken - fail fast. - persist_and_raise( - exc, - additional_context={ - "operation": "set_client_fields_duplicate_contact", - "client_id": str(client.id), - "contact_name": name, - }, - ) - # Handle xero_last_modified updated_date_utc_str = raw_json.get("_updated_date_utc") if updated_date_utc_str: @@ -440,23 +376,23 @@ 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(): - client.save() - created_numbers = sync_xero_phone_methods(client) + company.save() + 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 @@ -465,7 +401,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: @@ -473,18 +409,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)." ) @@ -520,21 +456,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..acb42c949 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,8 +24,8 @@ logger = logging.getLogger("xero") -def seed_clients_to_xero(clients): - """Bulk process clients: link existing contacts + create missing ones in batches of 50""" +def seed_companies_to_xero(companies): + """Bulk process companies: link existing Xero contacts or create missing ones.""" # Get all existing Xero contacts (one API call) try: existing_contacts = get_all_xero_contacts() @@ -34,7 +34,7 @@ def seed_clients_to_xero(clients): raise # FAIL EARLY # Multimap: a single Xero name can map to multiple contact_ids (Xero - # allows duplicate contact names). Local clients with shared names + # allows duplicate contact names). Local companies with shared names # (legitimate — different real customers in Morris/MSM data) must each # claim a distinct Xero contact_id; the column is unique-constrained. existing_by_name = defaultdict(list) @@ -43,40 +43,40 @@ def seed_clients_to_xero(clients): results = {"linked": 0, "created": 0, "failed": []} - # Separate clients into link vs create lists - clients_to_link = [] - clients_to_create = [] + # Separate companies into link vs create lists + companies_to_link = [] + companies_to_create = [] - for client in clients: - candidates = existing_by_name.get(client.name.lower()) + for company in companies: + 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)) + companies_to_link.append((company, existing_contact_id)) else: - clients_to_create.append(client) + companies_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 companies_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. # bulk_create_contacts_in_xero relies on Xero preserving submission # order in the response. If you suspect that's changed, run # scripts/integration/verify_xero_batch_order.py before this seed. - if clients_to_create: - results["created"] = bulk_create_contacts_in_xero(clients_to_create) + if companies_to_create: + results["created"] = bulk_create_contacts_in_xero(companies_to_create) return results @@ -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 09344aff2..4d12e43cb 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,64 +868,64 @@ 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""" - clients = [] +def sync_companies(xero_contacts): + """Sync Xero contacts to Company model""" + companies: list[Company] = [] for contact in xero_contacts: raw_json = process_xero_data(contact) - client: Client + company: Company - # 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_company = Company.objects.filter( xero_contact_id=contact.contact_id ).first() - if existing_client: + if existing_company: # 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_company + 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_company = Company.objects.filter(name=contact_name).first() - if matching_client: - if matching_client.xero_contact_id is None: + if matching_company: + if matching_company.xero_contact_id is None: # Safe to link - no existing Xero ID - matching_client.xero_contact_id = contact.contact_id - matching_client.raw_json = raw_json - matching_client.xero_last_modified = timezone.now() - matching_client.xero_archived = ( + matching_company.xero_contact_id = contact.contact_id + matching_company.raw_json = raw_json + matching_company.xero_last_modified = timezone.now() + matching_company.xero_archived = ( contact.contact_status == "ARCHIVED" ) - matching_client.xero_merged_into_id = getattr( + matching_company.xero_merged_into_id = getattr( contact, "merged_to_contact_id", None ) - matching_client.save() + matching_company.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_company.id}) to Xero contact {contact.contact_id}" ) - client = matching_client + company = matching_company 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_company.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(), @@ -938,11 +938,11 @@ def sync_clients(xero_contacts): else: # Active contact name collision — real conflict raise ValueError( - f"Name '{contact_name}' already linked to Xero ID {matching_client.xero_contact_id}, cannot link to {contact.contact_id}" + f"Name '{contact_name}' already linked to Xero ID {matching_company.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(), @@ -954,7 +954,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(), @@ -963,40 +963,40 @@ 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) + companies.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 companies: + 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] ", ) - return clients + return companies def sync_accounts(xero_accounts): diff --git a/apps/workflow/fixtures/company_defaults.json b/apps/workflow/fixtures/company_defaults.json index 0d6542400..e9007ac1d 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, @@ -13,9 +13,6 @@ "allow_jobs": true, "xero_last_modified": "2024-01-01T00:00:00Z", "raw_json": {}, - "primary_contact_name": null, - "primary_contact_email": null, - "additional_contact_persons": [], "django_created_at": "2024-01-01T00:00:00Z", "django_updated_at": "2024-01-01T00:00:00Z", "xero_last_synced": "2024-01-01T00:00:00Z", @@ -47,6 +44,7 @@ "accounting_provider": "xero", "xero_tenant_id": null, "xero_shortcode": null, + "xero_sales_branding_theme_id": null, "enable_xero_sync": false, "xero_automated_day_floor": 100, "job_delta_soft_fail": true, @@ -75,8 +73,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..ba9a0c9d8 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, @@ -13,9 +13,6 @@ "allow_jobs": true, "xero_last_modified": "2024-01-01T00:00:00Z", "raw_json": {}, - "primary_contact_name": null, - "primary_contact_email": null, - "additional_contact_persons": [], "django_created_at": "2024-01-01T00:00:00Z", "django_updated_at": "2024-01-01T00:00:00Z", "xero_last_synced": "2024-01-01T00:00:00Z", @@ -47,6 +44,7 @@ "accounting_provider": "xero", "xero_tenant_id": null, "xero_shortcode": null, + "xero_sales_branding_theme_id": null, "enable_xero_sync": false, "xero_automated_day_floor": 100, "job_delta_soft_fail": true, @@ -75,8 +73,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/backport_data_backup.py b/apps/workflow/management/commands/backport_data_backup.py index 938efcf7a..4132558cc 100644 --- a/apps/workflow/management/commands/backport_data_backup.py +++ b/apps/workflow/management/commands/backport_data_backup.py @@ -8,6 +8,7 @@ from django.conf import settings from django.core.management.base import BaseCommand +from apps.workflow.exceptions import AlreadyLoggedException from apps.workflow.services import db_scrubber from apps.workflow.services.error_persistence import persist_app_error @@ -166,9 +167,11 @@ def handle(self, *args, **options): self.stdout.write( self.style.SUCCESS(f"Scrubbed dump written: {scrubbed_dump}") ) - except Exception as exc: - persist_app_error(exc) + except AlreadyLoggedException: raise + except Exception as exc: + err = persist_app_error(exc) + raise AlreadyLoggedException(exc, err.id) from exc def _run(self, cmd, env=None): subprocess.run(cmd, check=True, env=env, capture_output=True, text=True) diff --git a/apps/workflow/management/commands/e2e_cleanup.py b/apps/workflow/management/commands/e2e_cleanup.py index 255039dde..9666e77cd 100644 --- a/apps/workflow/management/commands/e2e_cleanup.py +++ b/apps/workflow/management/commands/e2e_cleanup.py @@ -13,16 +13,17 @@ from django.core.management.base import BaseCommand from django.db import transaction +from django.db.models import Model, Q, QuerySet from apps.accounting.models import Invoice, Quote -from apps.client.models import Client, ClientContact +from apps.company.models import Company, CompanyPersonLink, Person from apps.job.models import Job, QuoteSpreadsheet from apps.purchasing.models import PurchaseOrder, PurchaseOrderLine logger = logging.getLogger(__name__) TEST_DATA_PREFIX = "[TEST]" -TEST_CLIENT_NAME = "ABC Carpet Cleaning TEST IGNORE" +TEST_COMPANY_NAME = "ABC Carpet Cleaning TEST IGNORE" LEGACY_E2E_PREFIXES = ["E2E Test Client", "E2E Modal Client", "E2E Test Supplier"] @@ -41,68 +42,89 @@ 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_prefix_client_contacts = ClientContact.objects.filter( - client__in=test_clients + test_people = CompanyPersonLink.objects.filter( + person__name__startswith=TEST_DATA_PREFIX ) - - # Legacy E2E-prefixed clients and their data - from django.db.models import Q + test_companies = Company.objects.filter(name__startswith=TEST_DATA_PREFIX) + # Swept so Job.company PROTECT doesn't block the [TEST] company delete. + test_prefix_company_jobs = Job.objects.filter(company__in=test_companies) + test_prefix_company_people = CompanyPersonLink.objects.filter( + company__in=test_companies + ) + test_person_records = Person.objects.filter(name__startswith=TEST_DATA_PREFIX) 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_companies = Company.objects.filter(legacy_q) + legacy_company_jobs = Job.objects.filter(company__in=legacy_companies) + legacy_company_people = CompanyPersonLink.objects.filter( + company__in=legacy_companies + ) - # 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/people on the test company are test artifacts) + test_company_qs = Company.objects.filter(name=TEST_COMPANY_NAME) + test_company_jobs = Job.objects.filter(company__in=test_company_qs) + test_company_people = CompanyPersonLink.objects.filter( + company__in=test_company_qs + ) # Report self.stdout.write("\n=== E2E Test Data ===\n") self._report_queryset("[TEST]-prefixed jobs", test_jobs, "name") - 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("[TEST]-prefixed people", test_people, "person__name") self._report_queryset( - "Legacy E2E client contacts", legacy_client_contacts, "name" + "Underlying [TEST]-prefixed person records", + test_person_records, + "name", ) + self._report_queryset("[TEST]-prefixed companies", test_companies, "name") + self._report_queryset("Legacy E2E companies", legacy_companies, "name") + self._report_queryset("Legacy E2E company jobs", legacy_company_jobs, "name") self._report_queryset( - f"Jobs on test client ({TEST_CLIENT_NAME})", test_client_jobs, "name" + "Legacy E2E company people", legacy_company_people, "person__name" ) self._report_queryset( - f"Contacts on test client ({TEST_CLIENT_NAME})", - test_client_contacts, - "name", + f"Jobs on test company ({TEST_COMPANY_NAME})", test_company_jobs, "name" ) self._report_queryset( - "Jobs on [TEST]-prefixed clients", test_prefix_client_jobs, "name" + f"People on test company ({TEST_COMPANY_NAME})", + test_company_people, + "person__name", ) self._report_queryset( - "Contacts on [TEST]-prefixed clients", - test_prefix_client_contacts, - "name", + "Jobs on [TEST]-prefixed companies", test_prefix_company_jobs, "name" + ) + self._report_queryset( + "People on [TEST]-prefixed companies", + test_prefix_company_people, + "person__name", ) + all_companies_to_delete = (test_companies | legacy_companies).distinct() + all_people_links_to_delete = ( + test_company_people + | legacy_company_people + | test_people + | test_prefix_company_people + ).distinct() + people_ids_to_delete = set( + all_people_links_to_delete.values_list("person_id", flat=True) + ) | set(test_person_records.values_list("id", flat=True)) + total = ( test_jobs.count() - + test_contacts.count() - + test_clients.count() - + legacy_clients.count() - + legacy_client_jobs.count() - + legacy_client_contacts.count() - + test_client_jobs.count() - + test_client_contacts.count() - + test_prefix_client_jobs.count() - + test_prefix_client_contacts.count() + + test_people.count() + + test_person_records.count() + + test_companies.count() + + legacy_companies.count() + + legacy_company_jobs.count() + + legacy_company_people.count() + + test_company_jobs.count() + + test_company_people.count() + + test_prefix_company_jobs.count() + + test_prefix_company_people.count() ) if total == 0: @@ -126,7 +148,10 @@ def handle(self, *args, **options): # Collect all jobs that will be deleted (union of all sources) all_jobs_to_delete = ( - test_jobs | test_client_jobs | legacy_client_jobs | test_prefix_client_jobs + test_jobs + | test_company_jobs + | legacy_company_jobs + | test_prefix_company_jobs ).distinct() # Check for rows with PROTECTED FKs pointing at these jobs @@ -162,15 +187,12 @@ def handle(self, *args, **options): "sheet_id", ) - # Collect all clients to delete - all_clients_to_delete = (test_clients | legacy_clients).distinct() - - # Find POs referencing these clients (PROTECTED FK on supplier) - linked_pos = PurchaseOrder.objects.filter(supplier__in=all_clients_to_delete) + # Find POs referencing these companies (PROTECTED FK on supplier) + linked_pos = PurchaseOrder.objects.filter(supplier__in=all_companies_to_delete) if linked_pos.exists(): self._report_queryset( - "Purchase orders linked to test clients (will be deleted)", + "Purchase orders linked to test companies (will be deleted)", linked_pos, "id", ) @@ -185,7 +207,7 @@ def handle(self, *args, **options): if count: self.stdout.write(f" Invoices: {count} objects ({details})") - # 2. POs on test clients (PROTECTED FK on supplier) + # 2. POs on test companies (PROTECTED FK on supplier) count, details = linked_pos.delete() if count: self.stdout.write(f" Purchase orders: {count} objects ({details})") @@ -203,46 +225,59 @@ 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.) - count, details = test_client_jobs.delete() - self.stdout.write(f" Test client jobs: {count} objects ({details})") + # 4. Jobs on test company (cascade deletes cost sets, cost lines, etc.) + count, details = test_company_jobs.delete() + self.stdout.write(f" Test company jobs: {count} objects ({details})") - # 5. Contacts on test client - count, details = test_client_contacts.delete() - self.stdout.write(f" Test client contacts: {count} objects ({details})") + # 5. People on test company + count, details = test_company_people.delete() + self.stdout.write(f" Test company people: {count} objects ({details})") - # 6. Legacy client data - count, details = legacy_client_jobs.delete() - self.stdout.write(f" Legacy client jobs: {count} objects ({details})") + # 6. Legacy company data + count, details = legacy_company_jobs.delete() + 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})") + count, details = legacy_company_people.delete() + self.stdout.write(f" Legacy company people: {count} objects ({details})") - count, details = legacy_clients.delete() - self.stdout.write(f" Legacy clients: {count} objects ({details})") + count, details = legacy_companies.delete() + self.stdout.write(f" Legacy companies: {count} objects ({details})") # 7. [TEST]-prefixed items (may overlap with above, that's fine) count, details = test_jobs.delete() self.stdout.write(f" [TEST] jobs: {count} objects ({details})") - count, details = test_contacts.delete() - self.stdout.write(f" [TEST] contacts: {count} objects ({details})") + count, details = test_people.delete() + self.stdout.write(f" [TEST] people: {count} objects ({details})") + + # 8. Jobs/people on [TEST]-prefixed companies (unblocks company delete) + count, details = test_prefix_company_jobs.delete() + self.stdout.write( + f" Jobs on [TEST] companies: {count} objects ({details})" + ) - # 8. Jobs/contacts on [TEST]-prefixed clients (unblocks client delete) - count, details = test_prefix_client_jobs.delete() - self.stdout.write(f" Jobs on [TEST] clients: {count} objects ({details})") + count, details = test_prefix_company_people.delete() + self.stdout.write( + f" People on [TEST] companies: {count} objects ({details})" + ) - count, details = test_prefix_client_contacts.delete() + remaining_non_test_people_links = CompanyPersonLink.objects.exclude( + company__in=all_companies_to_delete + ).values_list("person_id", flat=True) + orphaned_test_people = Person.objects.filter( + id__in=people_ids_to_delete + ).exclude(id__in=remaining_non_test_people_links) + count, details = orphaned_test_people.delete() self.stdout.write( - f" Contacts on [TEST] clients: {count} objects ({details})" + f" Underlying test person records: {count} objects ({details})" ) - count, details = test_clients.delete() - self.stdout.write(f" [TEST] clients: {count} objects ({details})") + count, details = test_companies.delete() + self.stdout.write(f" [TEST] companies: {count} objects ({details})") self.stdout.write("\nDone.") - def _report_queryset(self, label, qs, field): + def _report_queryset(self, label: str, qs: QuerySet[Model], field: str) -> None: count = qs.count() if count == 0: return 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..2e58139bf --- /dev/null +++ b/apps/workflow/management/commands/relabel_client_app.py @@ -0,0 +1,93 @@ +"""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. + +TEMPORARY KAN-278: remove this command and its deploy hook after every +production instance has completed the cutover and produced a verified +company-schema backup. + +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'" + ) + renamed_tables = 0 + for old, new in TABLE_RENAMES: + cursor.execute("SELECT to_regclass(%s)", [old]) + if cursor.fetchone()[0] is None: + continue + cursor.execute(f'ALTER TABLE "{old}" RENAME TO "{new}"') + renamed_tables += 1 + 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"{renamed_tables} 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..97502bbde 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,56 +233,58 @@ 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 Xero contacts for all companies with jobs plus 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() - if not test_client: + test_company = Company.objects.filter(name=cd.test_company_name).first() + if not test_company: 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( + # Find companies with jobs that need xero_contact_id + company_ids_needing_sync = set( + Company.objects.filter( jobs__isnull=False, xero_contact_id__isnull=True ).values_list("id", flat=True) ) - # Also include test client 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}") + # Also include test company if it needs syncing + if not test_company.xero_contact_id: + company_ids_needing_sync.add(test_company.id) + self.stdout.write(f"Including test company: {cd.test_company_name}") - clients_needing_sync = Client.objects.filter(id__in=client_ids_needing_sync) + companies_needing_sync = Company.objects.filter(id__in=company_ids_needing_sync) self.stdout.write( - f"Found {clients_needing_sync.count()} clients needing Xero contact IDs" + f"Found {companies_needing_sync.count()} companies needing Xero contact IDs" ) - if not clients_needing_sync.exists(): - self.stdout.write("All clients with jobs already have Xero contact IDs") + if not companies_needing_sync.exists(): + self.stdout.write("All companies with jobs already have Xero contact IDs") return 0 if dry_run: - for client in clients_needing_sync[:10]: # Show first 10 - job_count = client.jobs.count() + for company in companies_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") - return clients_needing_sync.count() + if companies_needing_sync.count() > 10: + self.stdout.write( + f" ... and {companies_needing_sync.count() - 10} more" + ) + return companies_needing_sync.count() # Call sync module for bulk processing - self.stdout.write("Processing clients with Xero sync module...") - results = seed_clients_to_xero(clients_needing_sync) + self.stdout.write("Processing companies with Xero sync module...") + results = seed_companies_to_xero(companies_needing_sync) # Report results self.stdout.write( @@ -290,7 +292,7 @@ def process_contacts(self, dry_run): ) if results["failed"]: - self.stdout.write(f"Failed to process {len(results['failed'])} clients:") + self.stdout.write(f"Failed to process {len(results['failed'])} companies:") for name in results["failed"][:5]: # Show first 5 failures self.stdout.write(f" • {name}") if len(results["failed"]) > 5: @@ -299,10 +301,10 @@ def process_contacts(self, dry_run): return results["linked"] + results["created"] def process_projects(self, dry_run): - """Phase 2: Create projects for all jobs whose clients have xero_contact_id""" + """Phase 2: Create projects for all jobs whose companies 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( @@ -311,14 +313,14 @@ def process_projects(self, dry_run): if not jobs_needing_sync.exists(): self.stdout.write( - "All jobs with valid clients already have Xero project IDs" + "All jobs with valid companies already have Xero project IDs" ) return 0 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 +355,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 +371,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 +387,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 +406,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 +431,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 +452,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 +564,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 +594,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 +611,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 +627,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 +661,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 +681,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 +760,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 +919,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 +947,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/management/commands/xero.py b/apps/workflow/management/commands/xero.py index 427cddd25..06db25c6d 100644 --- a/apps/workflow/management/commands/xero.py +++ b/apps/workflow/management/commands/xero.py @@ -1,4 +1,5 @@ import datetime +from uuid import UUID import requests from django.core.cache import cache @@ -12,6 +13,10 @@ from apps.accounts.models import Staff from apps.timesheet.services import PayrollEmployeeSyncService +from apps.workflow.accounting.document_theme_service import ( + resolve_sales_branding_theme, +) +from apps.workflow.accounting.registry import get_provider from apps.workflow.api.xero.auth import api_client, get_tenant_id, get_valid_token from apps.workflow.api.xero.payroll import ( get_earnings_rates, @@ -73,7 +78,10 @@ def add_arguments(self, parser): parser.add_argument( "--setup", action="store_true", - help="Configure Xero tenant ID, shortcode, and payroll calendar", + help=( + "Configure Xero tenant ID, shortcode, sales branding theme, " + "and payroll calendar" + ), ) parser.add_argument( "--no-set", @@ -427,7 +435,7 @@ def _ensure_demo_xero_items_exist(self, calendar_name: str, tenant_id: str) -> N ) def run_setup(self): - """Configure Xero tenant ID, shortcode, and payroll calendar.""" + """Configure Xero tenant, theme, shortcode, and payroll calendar.""" self.stdout.write("Setting up Xero connection...") # Step 1: Get connected organisations @@ -495,7 +503,17 @@ def run_setup(self): shortcode = org_response.organisations[0].short_code - # Step 5: Fetch payroll calendar ID + # Step 5: Select a live sales branding theme for this tenant + sales_branding_theme = resolve_sales_branding_theme( + get_provider(), company.xero_sales_branding_theme_id + ) + if sales_branding_theme is None: + raise CommandError( + "Xero returned no branding themes. Create a branding theme in " + "Xero before running setup." + ) + + # Step 6: Fetch payroll calendar ID calendar_name = company.xero_payroll_calendar_name if not calendar_name: self.stdout.write( @@ -521,14 +539,27 @@ def run_setup(self): return payroll_calendar_id = matching_calendar["id"] - # Step 6: Save to CompanyDefaults - company.xero_tenant_id = tenant_id + # Step 7: Save to CompanyDefaults company.xero_shortcode = shortcode + company.xero_sales_branding_theme_id = UUID(sales_branding_theme.external_id) company.xero_payroll_calendar_id = payroll_calendar_id - company.save() + company.save( + update_fields=[ + "xero_shortcode", + "xero_sales_branding_theme_id", + "xero_payroll_calendar_id", + ] + ) self.stdout.write(self.style.SUCCESS(f"Tenant ID: {tenant_id}")) self.stdout.write(self.style.SUCCESS(f"Shortcode: {shortcode}")) + self.stdout.write( + self.style.SUCCESS( + "Sales Branding Theme: " + f"{sales_branding_theme.name} " + f"({sales_branding_theme.external_id})" + ) + ) if payroll_calendar_id: self.stdout.write( self.style.SUCCESS( 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 b7036553c..47bdaa354 100644 --- a/apps/workflow/migrations/0001_baseline.py +++ b/apps/workflow/migrations/0001_baseline.py @@ -13,248 +13,8 @@ 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"), + ("company", "0001_baseline"), migrations.swappable_dependency(settings.AUTH_USER_MODEL), ] @@ -1258,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/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"), 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/migrations/0007_rename_search_telemetry_client_domain.py b/apps/workflow/migrations/0007_rename_search_telemetry_client_domain.py new file mode 100644 index 000000000..9ee772bb3 --- /dev/null +++ b/apps/workflow/migrations/0007_rename_search_telemetry_client_domain.py @@ -0,0 +1,35 @@ +from typing import Any + +from django.db import migrations, models + + +def forwards(apps: Any, schema_editor: Any) -> None: + SearchTelemetryEvent = apps.get_model("workflow", "SearchTelemetryEvent") + SearchTelemetryEvent.objects.filter(domain="client").update(domain="company") + + +def backwards(apps: Any, schema_editor: Any) -> None: + SearchTelemetryEvent = apps.get_model("workflow", "SearchTelemetryEvent") + SearchTelemetryEvent.objects.filter(domain="company").update(domain="client") + + +class Migration(migrations.Migration): + dependencies = [ + ("workflow", "0006_alter_companydefaults_shop_company_and_more"), + ] + + operations = [ + migrations.RunPython(forwards, backwards), + migrations.AlterField( + model_name="searchtelemetryevent", + name="domain", + field=models.CharField( + choices=[ + ("company", "Company"), + ("kanban", "Kanban"), + ("stock", "Stock"), + ], + max_length=20, + ), + ), + ] diff --git a/apps/workflow/migrations/0008_rename_search_telemetry_company_lookup_source.py b/apps/workflow/migrations/0008_rename_search_telemetry_company_lookup_source.py new file mode 100644 index 000000000..e64991c61 --- /dev/null +++ b/apps/workflow/migrations/0008_rename_search_telemetry_company_lookup_source.py @@ -0,0 +1,27 @@ +from typing import Any + +from django.db import migrations + + +def forwards(apps: Any, schema_editor: Any) -> None: + SearchTelemetryEvent = apps.get_model("workflow", "SearchTelemetryEvent") + SearchTelemetryEvent.objects.filter(source="client_lookup").update( + source="company_lookup" + ) + + +def backwards(apps: Any, schema_editor: Any) -> None: + SearchTelemetryEvent = apps.get_model("workflow", "SearchTelemetryEvent") + SearchTelemetryEvent.objects.filter(source="company_lookup").update( + source="client_lookup" + ) + + +class Migration(migrations.Migration): + dependencies = [ + ("workflow", "0007_rename_search_telemetry_client_domain"), + ] + + operations = [ + migrations.RunPython(forwards, backwards), + ] diff --git a/apps/workflow/migrations/0009_rename_remaining_crm_telemetry_sources.py b/apps/workflow/migrations/0009_rename_remaining_crm_telemetry_sources.py new file mode 100644 index 000000000..97d334801 --- /dev/null +++ b/apps/workflow/migrations/0009_rename_remaining_crm_telemetry_sources.py @@ -0,0 +1,32 @@ +from typing import Any + +from django.db import migrations + +SOURCE_RENAMES = { + "crm_clients_table": "crm_companies_table", + "crm_client_detail_phone_numbers": "crm_company_detail_phone_numbers", +} + + +def forwards(apps: Any, schema_editor: Any) -> None: + SearchTelemetryEvent = apps.get_model("workflow", "SearchTelemetryEvent") + database = schema_editor.connection.alias + for old_source, new_source in SOURCE_RENAMES.items(): + SearchTelemetryEvent.objects.using(database).filter(source=old_source).update( + source=new_source + ) + + +def backwards(apps: Any, schema_editor: Any) -> None: + SearchTelemetryEvent = apps.get_model("workflow", "SearchTelemetryEvent") + database = schema_editor.connection.alias + for old_source, new_source in SOURCE_RENAMES.items(): + SearchTelemetryEvent.objects.using(database).filter(source=new_source).update( + source=old_source + ) + + +class Migration(migrations.Migration): + dependencies = [("workflow", "0008_rename_search_telemetry_company_lookup_source")] + + operations = [migrations.RunPython(forwards, backwards)] diff --git a/apps/workflow/migrations/0010_remove_xeroapp_tenant_id.py b/apps/workflow/migrations/0010_remove_xeroapp_tenant_id.py new file mode 100644 index 000000000..bf78e4346 --- /dev/null +++ b/apps/workflow/migrations/0010_remove_xeroapp_tenant_id.py @@ -0,0 +1,17 @@ +# Generated by Django 6.0.4 on 2026-07-15 06:19 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ("workflow", "0009_rename_remaining_crm_telemetry_sources"), + ] + + operations = [ + migrations.RemoveField( + model_name="xeroapp", + name="tenant_id", + ), + ] diff --git a/apps/workflow/migrations/0011_companydefaults_xero_sales_branding_theme_id.py b/apps/workflow/migrations/0011_companydefaults_xero_sales_branding_theme_id.py new file mode 100644 index 000000000..9d03c45a3 --- /dev/null +++ b/apps/workflow/migrations/0011_companydefaults_xero_sales_branding_theme_id.py @@ -0,0 +1,68 @@ +# Generated by Django 5.2.4 on 2026-07-16 00:00 + +from uuid import UUID + +from django.db import migrations, models + + +def populate_connected_install_theme(apps, _schema_editor): + """Backfill connected installs; setup handles unconnected installations.""" + CompanyDefaults = apps.get_model("workflow", "CompanyDefaults") + XeroApp = apps.get_model("workflow", "XeroApp") + + company_defaults = CompanyDefaults.objects.first() + if company_defaults is None or company_defaults.xero_tenant_id is None: + return + + has_usable_oauth = ( + XeroApp.objects.filter(is_active=True) + .exclude(refresh_token__isnull=True) + .exclude(refresh_token="") + .exists() + ) + if not has_usable_oauth: + return + + from apps.workflow.accounting.document_theme_service import ( + resolve_sales_branding_theme, + ) + from apps.workflow.accounting.registry import get_provider + + selected_theme = resolve_sales_branding_theme(get_provider(), None) + if selected_theme is None: + raise RuntimeError( + "Xero returned no branding themes while configuring the sales " + "branding theme migration." + ) + + CompanyDefaults.objects.filter(pk=company_defaults.pk).update( + xero_sales_branding_theme_id=UUID(selected_theme.external_id) + ) + + +class Migration(migrations.Migration): + dependencies = [ + ("workflow", "0010_remove_xeroapp_tenant_id"), + ] + + operations = [ + migrations.AddField( + model_name="companydefaults", + name="xero_sales_branding_theme_id", + field=models.UUIDField( + blank=True, + help_text=( + "Branding theme applied to every quote and sales invoice " + "created in Xero. Select a theme containing the required " + "terms and conditions; it is configured during Xero setup " + "and required before sales documents can be created." + ), + null=True, + verbose_name="Xero sales branding theme", + ), + ), + migrations.RunPython( + populate_connected_install_theme, + migrations.RunPython.noop, + ), + ] diff --git a/apps/workflow/models/company_defaults.py b/apps/workflow/models/company_defaults.py index ecc14b336..6b03ba5d5 100644 --- a/apps/workflow/models/company_defaults.py +++ b/apps/workflow/models/company_defaults.py @@ -125,6 +125,17 @@ class CompanyDefaults(SingletonModel): blank=True, help_text="Xero organisation shortcode for deep linking (e.g., '!8-5Xl')", ) + xero_sales_branding_theme_id = models.UUIDField( + null=True, + blank=True, + verbose_name="Xero sales branding theme", + help_text=( + "Branding theme applied to every quote and sales invoice created in " + "Xero. Select a theme containing the required terms and conditions; " + "it is configured during Xero setup and required before sales " + "documents can be created." + ), + ) enable_xero_sync = models.BooleanField( default=True, help_text="Gate for Xero sync. Defaults True (prod). Dev fixture sets False; seed_xero_from_database sets True after prod IDs are cleared.", @@ -250,19 +261,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..63b7274e4 100644 --- a/apps/workflow/models/search_telemetry_event.py +++ b/apps/workflow/models/search_telemetry_event.py @@ -6,14 +6,14 @@ 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" CLICK = "click", "Click" class Domain(models.TextChoices): - CLIENT = "client", "Client" + COMPANY = "company", "Company" KANBAN = "kanban", "Kanban" STOCK = "stock", "Stock" diff --git a/apps/workflow/models/settings_metadata.py b/apps/workflow/models/settings_metadata.py index 1bab5e9c8..0637d43ca 100644 --- a/apps/workflow/models/settings_metadata.py +++ b/apps/workflow/models/settings_metadata.py @@ -135,12 +135,13 @@ 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", "xero_shortcode": "xero", + "xero_sales_branding_theme_id": "xero", "enable_xero_sync": "xero", "xero_automated_day_floor": "xero", "xero_payroll_calendar_name": "xero", @@ -165,8 +166,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): @@ -192,7 +193,11 @@ def get_field_metadata( else: label = field_name.replace("_", " ").title() - ui_type = get_ui_type_for_field(field) + ui_type = ( + "xero_branding_theme" + if field_name == "xero_sales_branding_theme_id" + else get_ui_type_for_field(field) + ) return { "key": field_name, "label": label, diff --git a/apps/workflow/models/xero_app.py b/apps/workflow/models/xero_app.py index 07099b791..380b6b3b7 100644 --- a/apps/workflow/models/xero_app.py +++ b/apps/workflow/models/xero_app.py @@ -31,7 +31,6 @@ class XeroApp(models.Model): is_active = models.BooleanField(default=False) - tenant_id = models.CharField(max_length=100, null=True, blank=True) token_type = models.CharField(max_length=50, null=True, blank=True) access_token = models.TextField(null=True, blank=True) refresh_token = models.TextField(null=True, blank=True) diff --git a/apps/workflow/serializers.py b/apps/workflow/serializers.py index fe8de504e..d52346c03 100644 --- a/apps/workflow/serializers.py +++ b/apps/workflow/serializers.py @@ -2,6 +2,8 @@ from rest_framework import serializers +from apps.workflow.accounting.types import DocumentTheme + # Existing models used in this serializer module from .models import ( AIProvider, @@ -93,7 +95,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 @@ -119,7 +121,6 @@ class Meta: "webhook_key", "is_active", "has_tokens", - "tenant_id", "day_remaining", "minute_remaining", "snapshot_at", @@ -131,7 +132,6 @@ class Meta: "id", "is_active", "has_tokens", - "tenant_id", "day_remaining", "minute_remaining", "snapshot_at", @@ -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 ) @@ -429,6 +429,7 @@ class XeroPingResponseSerializer(serializers.Serializer): connected = serializers.BooleanField() xero_readonly = serializers.BooleanField() + xero_production_client = serializers.BooleanField() # --------------------------------------------------------------------------- @@ -631,7 +632,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. @@ -644,3 +645,11 @@ class GroupedErrorResolveResponseSerializer(serializers.Serializer): """Response body for grouped resolve/unresolve endpoints.""" updated = serializers.IntegerField() + + +class XeroBrandingThemeSerializer(serializers.Serializer[DocumentTheme]): + """A Xero branding theme available for sales documents.""" + + branding_theme_id = serializers.UUIDField(source="external_id") + name = serializers.CharField() + is_default = serializers.BooleanField() diff --git a/apps/workflow/services/db_scrubber.py b/apps/workflow/services/db_scrubber.py index c8916ff8c..9a93ad4e6 100644 --- a/apps/workflow/services/db_scrubber.py +++ b/apps/workflow/services/db_scrubber.py @@ -6,9 +6,9 @@ 2. Delete the unlinked accounting records that _filter_unlinked_accounting_records dropped. 3. Truncate the tables in EXCLUDE_MODELS (minus framework tables). -Anything beyond that is OUT OF SCOPE for this task and must NOT be added without -a separate ticket — see docs/plans/2026-04-25-pg-dump-backport-refresh-plan.md -"Strict like-for-like contract". +It additionally excludes DB-backed external-system credentials introduced +after the legacy command. A scrubbed backup must never carry configuration that +could authenticate against production services. Safety: refuses to run unless settings.DATABASES["scrub"]["NAME"] ends in "_scrub" — last line of defence against a misconfigured SCRUB_DB_NAME pointing @@ -18,7 +18,7 @@ from collections.abc import Callable from django.conf import settings -from django.db import transaction +from django.db import connections, transaction from faker import Faker from apps.accounting.models import ( @@ -31,7 +31,8 @@ ) 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 Company, ContactMethod, Person +from apps.workflow.exceptions import AlreadyLoggedException from apps.workflow.models import CompanyDefaults from apps.workflow.services.error_persistence import persist_app_error @@ -83,15 +84,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,12 +116,12 @@ 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 ``ContactMethod.save()`` (which ``bulk_update`` deliberately bypasses). """ for _ in range(1000): value = generate() - normalized = ClientContactMethod.normalize_value(method_type, value) + normalized = ContactMethod.normalize_value(method_type, value) key = (method_type, normalized) if normalized and key not in used: used.add(key) @@ -130,23 +131,19 @@ 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 companies and people. - Top-level fields touched: name (with allow-list), primary_contact_name, - primary_contact_email, email. + Top-level fields touched: company name/email and person name/email. raw_json paths touched: _name, _email_address, _bank_account_details, _phones[]._phone_number, _batch_payments._bank_account_number, _batch_payments._bank_account_name. - - Anything else (address, additional_contact_persons, supplierpickupaddress, - 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 +153,10 @@ 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.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,30 +173,30 @@ 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() - contact.email = fake.email() - contact.save( + for person in Person.objects.using(SCRUB_ALIAS).all(): + person.name = fake.name() + person.email = fake.email() + person.save( using=SCRUB_ALIAS, 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 by a linked person. used_method_values: set[tuple[str, str]] = set() - methods_to_update: list[ClientContactMethod] = [] + methods_to_update: list[ContactMethod] = [] for method in ( - ClientContactMethod.objects.using(SCRUB_ALIAS) - .exclude(client__name__in=preserved) - .exclude(contact__client__name__in=preserved) + ContactMethod.objects.using(SCRUB_ALIAS) + .exclude(company__name__in=preserved) + .exclude(person__company_links__company__name__in=preserved) ): - if method.method_type == ClientContactMethod.MethodType.PHONE: + if method.method_type == ContactMethod.MethodType.PHONE: generate = fake.phone_number - elif method.method_type == ClientContactMethod.MethodType.EMAIL: + elif method.method_type == ContactMethod.MethodType.EMAIL: generate = fake.email else: continue @@ -211,10 +206,10 @@ 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 ContactMethod.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( + ContactMethod.objects.using(SCRUB_ALIAS).bulk_update( methods_to_update, ["value", "normalized_value"], batch_size=500 ) @@ -271,14 +266,20 @@ def _delete_unlinked_accounting() -> None: # xeropayitem would cascade through Job.default_xero_pay_item and # CostLine.xero_pay_item and erase every Job and CostLine in the dump. # Pay item names aren't PII; letting prod's set through is harmless. +_PRIVATE_CONFIG_TABLES = ( + "workflow_aiprovider", + "workflow_xeroapp", + "workflow_serviceapikey", + "crm_phoneprovidersettings", + "quoting_suppliercredential", +) + _EXCLUDED_TABLES = ( # In joined-table inheritance, child tables have FKs back to parent. # TRUNCATE parent WITH CASCADE will cascade to children. Do NOT include # child tables — workflow_xeroerror will be cascaded from workflow_apperror. "workflow_apperror", # Parent; CASCADE will delete xeroerror children - "workflow_xeroapp", - "workflow_serviceapikey", - "quoting_suppliercredential", + *_PRIVATE_CONFIG_TABLES, "accounts_historicalstaff", "process_historicalform", "process_historicalformentry", @@ -288,13 +289,26 @@ def _delete_unlinked_accounting() -> None: def _truncate_excluded_tables() -> None: """Mirror legacy EXCLUDE_MODELS by emptying these tables in the scrub DB.""" - from django.db import connections - with connections[SCRUB_ALIAS].cursor() as cur: for table in _EXCLUDED_TABLES: cur.execute(f'TRUNCATE TABLE "{table}" RESTART IDENTITY CASCADE') +def _assert_private_config_removed() -> None: + """Fail closed if a scrubbed DB still contains external credentials.""" + remaining: list[str] = [] + with connections[SCRUB_ALIAS].cursor() as cur: + for table in _PRIVATE_CONFIG_TABLES: + cur.execute(f'SELECT COUNT(*) FROM "{table}"') + count = cur.fetchone()[0] + if count: + remaining.append(f"{table}={count}") + if remaining: + raise RuntimeError( + "Private configuration remained after scrubbing: " + ", ".join(remaining) + ) + + def scrub() -> None: """Reproduce the legacy command's PII handling on the scrub DB. @@ -305,10 +319,13 @@ 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() - except Exception as exc: - persist_app_error(exc) + _assert_private_config_removed() + except AlreadyLoggedException: raise + except Exception as exc: + err = persist_app_error(exc) + raise AlreadyLoggedException(exc, err.id) from exc diff --git a/apps/workflow/tests/test_api_schema_coverage.py b/apps/workflow/tests/test_api_schema_coverage.py index 9a36cc624..301c516f3 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/", @@ -101,13 +101,18 @@ def _normalize_path(self, url_pattern: str) -> str: # Normalize trailing slash return path.rstrip("/") - def _get_schema_paths(self) -> set: + def _get_schema_paths(self) -> set[str]: """Get all paths from the OpenAPI schema.""" - generator = SchemaGenerator() - schema = generator.get_schema(public=True) - if schema and "paths" in schema: - return {path.lstrip("/").rstrip("/") for path in schema["paths"].keys()} - return set() + schema = SchemaGenerator().get_schema(public=True) + if schema is None or "paths" not in schema: + return set() + paths = schema["paths"] + if not isinstance(paths, dict): + raise TypeError( + f"expected OpenAPI 'paths' to be a JSON object, " + f"got {type(paths).__name__}" + ) + return {path.lstrip("/").rstrip("/") for path in paths} def test_all_api_endpoints_in_schema(self): """Every API endpoint must be documented in the OpenAPI schema.""" diff --git a/apps/workflow/tests/test_backup_scripts.py b/apps/workflow/tests/test_backup_scripts.py index dc646f7e0..ab60f6d5c 100644 --- a/apps/workflow/tests/test_backup_scripts.py +++ b/apps/workflow/tests/test_backup_scripts.py @@ -1,4 +1,5 @@ import importlib.util +import os import subprocess import sys import tempfile @@ -6,11 +7,16 @@ from pathlib import Path from unittest import mock +from django.core.management import call_command from django.test import SimpleTestCase +from apps.workflow.exceptions import AlreadyLoggedException +from apps.workflow.management.commands.backport_data_backup import Command + REPO_ROOT = Path(__file__).resolve().parents[3] CLEANUP_BACKUPS = REPO_ROOT / "scripts" / "cleanup_backups.py" BACKUP_INSTANCE_FILES = REPO_ROOT / "scripts" / "backup_instance_files.sh" +PULL_PROD_BACKUP = REPO_ROOT / "scripts" / "pull_prod_backup.sh" def load_cleanup_module() -> types.ModuleType: @@ -23,6 +29,178 @@ def load_cleanup_module() -> types.ModuleType: class BackupScriptTests(SimpleTestCase): + def _write_stub(self, directory: Path, name: str, body: str) -> None: + path = directory / name + path.write_text("#!/usr/bin/env bash\nset -u\n" + body) + path.chmod(0o755) + + def _run_prod_pull( + self, + *, + generation_status: int = 0, + copy_status: int = 0, + verifier_status: int = 0, + checksum_status: int = 0, + cleanup_status: int = 0, + ) -> tuple[subprocess.CompletedProcess[str], list[str], bool]: + with tempfile.TemporaryDirectory() as tmp: + temp_repo = Path(tmp) + scripts_dir = temp_repo / "scripts" + restore_dir = temp_repo / "restore" + bin_dir = temp_repo / "bin" + scripts_dir.mkdir() + restore_dir.mkdir() + bin_dir.mkdir() + + script = scripts_dir / PULL_PROD_BACKUP.name + script.write_text(PULL_PROD_BACKUP.read_text()) + script.chmod(0o755) + event_log = temp_repo / "events.log" + + self._write_stub(bin_dir, "date", 'echo "20260715_120000"\n') + self._write_stub( + bin_dir, + "ssh", + """printf 'ssh:%s\\n' "$*" >> "$EVENT_LOG" +if [[ "$*" == *"backport_data_backup"* ]]; then + exit "$GENERATION_STATUS" +fi +exit "$CLEANUP_STATUS" +""", + ) + self._write_stub( + bin_dir, + "scp", + """printf 'scp:%s\\n' "$*" >> "$EVENT_LOG" +source_path="$1" +destination="$2" +filename="${source_path##*/}" +: > "${destination%/}/$filename" +exit "$COPY_STATUS" +""", + ) + self._write_stub( + bin_dir, + "python", + """printf 'verify:%s\\n' "$*" >> "$EVENT_LOG" +exit "$VERIFIER_STATUS" +""", + ) + self._write_stub( + bin_dir, + "sha256sum", + """printf 'sha256sum:%s\\n' "$*" >> "$EVENT_LOG" +if [[ "$CHECKSUM_STATUS" -ne 0 ]]; then + exit "$CHECKSUM_STATUS" +fi +printf 'abc123 %s\\n' "$1" +""", + ) + + environment = { + **os.environ, + "PATH": f"{bin_dir}:/usr/bin:/bin", + "EVENT_LOG": str(event_log), + "GENERATION_STATUS": str(generation_status), + "COPY_STATUS": str(copy_status), + "VERIFIER_STATUS": str(verifier_status), + "CHECKSUM_STATUS": str(checksum_status), + "CLEANUP_STATUS": str(cleanup_status), + "REMOTE_USER": "backup-operator", + } + result = subprocess.run( + [str(script), "prod.example", "dw_msm_prod"], + capture_output=True, + check=False, + env=environment, + text=True, + ) + events = event_log.read_text().splitlines() + archive_exists = ( + restore_dir / "scrubbed_dw_msm_prod_20260715_120000.dump" + ).exists() + return result, events, archive_exists + + def test_prod_pull_success_preserves_verified_archive_and_cleans_remote( + self, + ) -> None: + result, events, archive_exists = self._run_prod_pull() + + self.assertEqual(result.returncode, 0) + self.assertTrue(archive_exists) + self.assertEqual( + [event.split(":", 1)[0] for event in events], + ["ssh", "scp", "verify", "sha256sum", "ssh"], + ) + self.assertIn("--allow-legacy-client-baseline", events[2]) + self.assertIn("backport_data_backup", events[0]) + self.assertIn("rm -f", events[-1]) + + def test_prod_pull_failures_remove_partial_archive_and_preserve_status( + self, + ) -> None: + cases = ( + ("generation", 41, 0, 0, 0, 41, ["ssh", "ssh"]), + ("copy", 0, 42, 0, 0, 42, ["ssh", "scp", "ssh"]), + ( + "verification", + 0, + 0, + 43, + 0, + 43, + ["ssh", "scp", "verify", "ssh"], + ), + ( + "checksum", + 0, + 0, + 0, + 44, + 44, + ["ssh", "scp", "verify", "sha256sum", "ssh"], + ), + ) + for ( + stage, + generation_status, + copy_status, + verifier_status, + checksum_status, + expected_status, + expected_events, + ) in cases: + with self.subTest(stage=stage): + result, events, archive_exists = self._run_prod_pull( + generation_status=generation_status, + copy_status=copy_status, + verifier_status=verifier_status, + checksum_status=checksum_status, + ) + + self.assertEqual(result.returncode, expected_status) + self.assertFalse(archive_exists) + self.assertEqual( + [event.split(":", 1)[0] for event in events], expected_events + ) + self.assertIn("rm -f", events[-1]) + + def test_prod_pull_cleanup_failure_is_reported_after_success(self) -> None: + result, events, archive_exists = self._run_prod_pull(cleanup_status=45) + + self.assertEqual(result.returncode, 45) + self.assertTrue(archive_exists) + self.assertIn("rm -f", events[-1]) + + def test_prod_pull_original_failure_wins_over_cleanup_failure(self) -> None: + result, _events, archive_exists = self._run_prod_pull( + verifier_status=43, + cleanup_status=45, + ) + + self.assertEqual(result.returncode, 43) + self.assertFalse(archive_exists) + def test_cleanup_copies_remote_before_pruning_expired_backups(self) -> None: cleanup = load_cleanup_module() @@ -160,3 +338,46 @@ def test_file_backup_script_is_incremental_and_scoped(self) -> None: self.assertIn("ARCHIVE_RETENTION_DAYS=30", content) self.assertIn("rclone purge", content) self.assertIn("refusing to back up symlinked directory", content) + + +class BackportCommandErrorPersistenceTests(SimpleTestCase): + def test_prelogged_scrub_failure_is_not_persisted_again(self) -> None: + command = Command() + failure = AlreadyLoggedException(RuntimeError("scrub failed"), "error-123") + with tempfile.TemporaryDirectory() as tmp: + output = Path(tmp) / "backup.dump" + with ( + mock.patch.object(command, "_run"), + mock.patch.object(command, "_run_pipe"), + mock.patch( + "apps.workflow.management.commands.backport_data_backup.db_scrubber.scrub", + side_effect=failure, + ), + mock.patch( + "apps.workflow.management.commands.backport_data_backup.persist_app_error" + ) as persist_app_error, + self.assertRaises(AlreadyLoggedException) as raised, + ): + call_command(command, output=str(output)) + + self.assertIs(raised.exception, failure) + persist_app_error.assert_not_called() + + def test_new_command_failure_is_persisted_and_wrapped(self) -> None: + command = Command() + failure = RuntimeError("backup failed") + with tempfile.TemporaryDirectory() as tmp: + output = Path(tmp) / "backup.dump" + with ( + mock.patch.object(command, "_run", side_effect=failure), + mock.patch( + "apps.workflow.management.commands.backport_data_backup.persist_app_error" + ) as persist_app_error, + self.assertRaises(AlreadyLoggedException) as raised, + ): + persist_app_error.return_value.id = "error-456" + call_command(command, output=str(output)) + + self.assertIs(raised.exception.original, failure) + self.assertEqual(raised.exception.app_error_id, "error-456") + persist_app_error.assert_called_once_with(failure) diff --git a/apps/workflow/tests/test_company_defaults_api.py b/apps/workflow/tests/test_company_defaults_api.py index 6eca7a986..08970a506 100644 --- a/apps/workflow/tests/test_company_defaults_api.py +++ b/apps/workflow/tests/test_company_defaults_api.py @@ -1,3 +1,5 @@ +import uuid + from django.db import connection from django.test.utils import CaptureQueriesContext from rest_framework import status @@ -12,24 +14,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( @@ -51,3 +53,29 @@ def test_patch_canonicalizes_blank_optional_urls_to_null(self): self.assertIsNone(company_defaults.master_quote_template_url) self.assertIsNone(company_defaults.gdrive_quotes_folder_url) self.assertIsNone(company_defaults.company_url) + + def test_patch_persists_and_clears_xero_sales_branding_theme(self) -> None: + """Admins can operate the required Xero document theme setting.""" + theme_id = uuid.uuid4() + client = APIClient() + client.force_authenticate(user=self.test_staff) + + response = client.patch( + "/api/company-defaults/", + {"xero_sales_branding_theme_id": str(theme_id)}, + format="json", + ) + payload = response.json() + + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(payload["xero_sales_branding_theme_id"], str(theme_id)) + + response = client.patch( + "/api/company-defaults/", + {"xero_sales_branding_theme_id": None}, + format="json", + ) + payload = response.json() + + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertIsNone(payload["xero_sales_branding_theme_id"]) diff --git a/apps/workflow/tests/test_company_defaults_schema.py b/apps/workflow/tests/test_company_defaults_schema.py index 62c6af3e9..79b510188 100644 --- a/apps/workflow/tests/test_company_defaults_schema.py +++ b/apps/workflow/tests/test_company_defaults_schema.py @@ -222,3 +222,24 @@ def test_company_section_has_company_name(self): field_keys = [f["key"] for f in company_section["fields"]] self.assertIn("company_name", field_keys) + + def test_xero_section_exposes_sales_branding_theme_selector(self) -> None: + """The theme is operable through Company Settings, not hidden config.""" + client = APIClient() + client.force_authenticate(user=self.staff) + response = client.get("/api/company-defaults/schema/") + payload = response.json() + + xero_section = next( + section for section in payload["sections"] if section["key"] == "xero" + ) + theme_field = next( + field + for field in xero_section["fields"] + if field["key"] == "xero_sales_branding_theme_id" + ) + + self.assertEqual(theme_field["type"], "xero_branding_theme") + self.assertEqual(theme_field["label"], "Xero Sales Branding Theme") + self.assertFalse(theme_field["read_only"]) + self.assertIn("terms and conditions", theme_field["help_text"]) diff --git a/apps/workflow/tests/test_data_versions_view.py b/apps/workflow/tests/test_data_versions_view.py index e3351ab4e..dfd1da864 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 Company, CompanyPersonLink, Person 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,24 +201,28 @@ 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") + person = Person.objects.create(name="Original Person") + CompanyPersonLink.objects.create( + company=company, + person=person, + ) + _job(office_staff, company=company, person=person) before = auth_client.get("/api/data-versions/").json()["kanban"] - contact.name = "Updated Contact" - contact.save(update_fields=["name"]) + person.name = "Updated Person" + person.save(update_fields=["name"]) after = auth_client.get("/api/data-versions/").json()["kanban"] assert before != after -def test_client_partial_save_changes_kanban_version( +def test_company_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..e7514cd67 100644 --- a/apps/workflow/tests/test_db_scrubber.py +++ b/apps/workflow/tests/test_db_scrubber.py @@ -1,16 +1,24 @@ -"""Unit tests for db_scrubber collision-safe value generation. +"""Unit tests for production database scrubbing contracts. The full scrub runs against the ``scrub`` DB alias (a restored prod copy) and is not exercised here; these tests pin the novel logic that keeps the scrub from aborting on a normalized-value collision. """ +from unittest.mock import MagicMock, patch + from django.test import SimpleTestCase -from apps.client.models import ClientContactMethod -from apps.workflow.services.db_scrubber import _unique_scrub_value +from apps.company.models import ContactMethod +from apps.workflow.exceptions import AlreadyLoggedException +from apps.workflow.services.db_scrubber import ( + _PRIVATE_CONFIG_TABLES, + _assert_private_config_removed, + _unique_scrub_value, + scrub, +) -PHONE = ClientContactMethod.MethodType.PHONE +PHONE = ContactMethod.MethodType.PHONE class UniqueScrubValueTests(SimpleTestCase): @@ -19,7 +27,7 @@ def test_returns_value_and_normalized_and_records_it(self) -> None: value, normalized = _unique_scrub_value(lambda: "021 111 111", PHONE, used) self.assertEqual(value, "021 111 111") - self.assertEqual(normalized, ClientContactMethod.normalize_phone("021 111 111")) + self.assertEqual(normalized, ContactMethod.normalize_phone("021 111 111")) self.assertIn((PHONE, normalized), used) def test_skips_values_that_normalize_to_an_already_used_number(self) -> None: @@ -37,3 +45,88 @@ def test_raises_when_every_attempt_collides(self) -> None: _unique_scrub_value(lambda: "021 111 111", PHONE, used) # seed `used` with self.assertRaises(RuntimeError): _unique_scrub_value(lambda: "021 111 111", PHONE, used) + + +class PrivateConfigurationScrubTests(SimpleTestCase): + def test_external_credentials_are_part_of_the_scrub_contract(self) -> None: + self.assertEqual( + set(_PRIVATE_CONFIG_TABLES), + { + "workflow_aiprovider", + "workflow_xeroapp", + "workflow_serviceapikey", + "crm_phoneprovidersettings", + "quoting_suppliercredential", + }, + ) + + @patch("apps.workflow.services.db_scrubber.connections") + def test_private_config_postcondition_accepts_empty_tables( + self, connections: MagicMock + ) -> None: + cursor = connections.__getitem__.return_value.cursor.return_value.__enter__ + cursor.return_value.fetchone.side_effect = [(0,)] * len(_PRIVATE_CONFIG_TABLES) + + _assert_private_config_removed() + + @patch("apps.workflow.services.db_scrubber.connections") + def test_private_config_postcondition_reports_counts_not_values( + self, connections: MagicMock + ) -> None: + cursor = connections.__getitem__.return_value.cursor.return_value.__enter__ + cursor.return_value.fetchone.side_effect = [ + (2,) if table == "workflow_aiprovider" else (0,) + for table in _PRIVATE_CONFIG_TABLES + ] + + with self.assertRaisesRegex( + RuntimeError, + r"workflow_aiprovider=2", + ) as raised: + _assert_private_config_removed() + + self.assertNotIn("api_key", str(raised.exception)) + + +class ScrubErrorPersistenceTests(SimpleTestCase): + @patch("apps.workflow.services.db_scrubber._assert_scrub_alias_is_safe") + @patch("apps.workflow.services.db_scrubber.transaction.atomic") + @patch("apps.workflow.services.db_scrubber._scrub_staff") + @patch("apps.workflow.services.db_scrubber.persist_app_error") + def test_new_failure_is_persisted_and_wrapped_once( + self, + persist_app_error: MagicMock, + scrub_staff: MagicMock, + _atomic: MagicMock, + _assert_safe: MagicMock, + ) -> None: + failure = RuntimeError("scrub failed") + scrub_staff.side_effect = failure + persist_app_error.return_value.id = "error-123" + + with self.assertRaises(AlreadyLoggedException) as raised: + scrub() + + self.assertIs(raised.exception.original, failure) + self.assertEqual(raised.exception.app_error_id, "error-123") + persist_app_error.assert_called_once_with(failure) + + @patch("apps.workflow.services.db_scrubber._assert_scrub_alias_is_safe") + @patch("apps.workflow.services.db_scrubber.transaction.atomic") + @patch("apps.workflow.services.db_scrubber._scrub_staff") + @patch("apps.workflow.services.db_scrubber.persist_app_error") + def test_prelogged_failure_passes_through_unchanged( + self, + persist_app_error: MagicMock, + scrub_staff: MagicMock, + _atomic: MagicMock, + _assert_safe: MagicMock, + ) -> None: + failure = AlreadyLoggedException(RuntimeError("scrub failed"), "error-123") + scrub_staff.side_effect = failure + + with self.assertRaises(AlreadyLoggedException) as raised: + scrub() + + self.assertIs(raised.exception, failure) + persist_app_error.assert_not_called() diff --git a/apps/workflow/tests/test_dev_demo_export.py b/apps/workflow/tests/test_dev_demo_export.py index 48b50472a..d4fc1013c 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 Company, ContactMethod 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, - method_type=ClientContactMethod.MethodType.PHONE, + ContactMethod.objects.create( + company=company, + method_type=ContactMethod.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", ) @@ -146,12 +146,12 @@ 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"}, + domain=SearchTelemetryEvent.Domain.COMPANY, + 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 == {} @@ -224,7 +224,7 @@ def test_dev_demo_scrub_preserves_business_signal_and_redacts_risk(): assert chunk.path == "/redacted" telemetry = SearchTelemetryEvent.objects.get() - assert telemetry.domain == SearchTelemetryEvent.Domain.CLIENT + assert telemetry.domain == SearchTelemetryEvent.Domain.COMPANY assert telemetry.query == "" assert telemetry.metadata == {} diff --git a/apps/workflow/tests/test_localdate_regression.py b/apps/workflow/tests/test_localdate_regression.py index f953d2073..73670ce23 100644 --- a/apps/workflow/tests/test_localdate_regression.py +++ b/apps/workflow/tests/test_localdate_regression.py @@ -23,18 +23,19 @@ FROZEN_UTC_MOMENT = "2026-04-27T23:30:00Z" NZ_DATE = datetime.date(2026, 4, 28) UTC_DATE = datetime.date(2026, 4, 27) +DOCUMENT_THEME_ID = "00000000-0000-0000-0000-000000000286" -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 +146,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 +222,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) @@ -239,32 +240,38 @@ def test_build_payload_uses_nz_local_date(self): freeze_time(FROZEN_UTC_MOMENT), patch.object(manager, "get_line_items", return_value=[]), ): - payload = manager.build_payload() + payload = manager.build_payload( + document_theme_external_id=DOCUMENT_THEME_ID + ) 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 ( freeze_time(FROZEN_UTC_MOMENT), patch.object(manager, "get_line_items", return_value=[]), ): - payload = manager.build_payload() + payload = manager.build_payload( + document_theme_external_id=DOCUMENT_THEME_ID + ) # NZ "today" is 2026-04-28; 20th of next month = 2026-05-20. 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 ( freeze_time(FROZEN_UTC_MOMENT), patch.object(manager, "get_line_items", return_value=[]), ): - payload = manager.build_payload() + payload = manager.build_payload( + document_theme_external_id=DOCUMENT_THEME_ID + ) self.assertEqual(payload.due_date, NZ_DATE) self.assertEqual(payload.date, payload.due_date) @@ -282,7 +289,9 @@ def test_account_customer_due_date_at_31_day_month_boundary(self): freeze_time("2026-05-01T00:00:00Z"), patch.object(manager, "get_line_items", return_value=[]), ): - payload = manager.build_payload() + payload = manager.build_payload( + document_theme_external_id=DOCUMENT_THEME_ID + ) self.assertEqual(payload.date, datetime.date(2026, 5, 1)) self.assertEqual(payload.due_date, datetime.date(2026, 6, 20)) @@ -296,7 +305,9 @@ def test_account_customer_due_date_rolls_over_year_in_december(self): freeze_time("2026-12-14T13:00:00Z"), patch.object(manager, "get_line_items", return_value=[]), ): - payload = manager.build_payload() + payload = manager.build_payload( + document_theme_external_id=DOCUMENT_THEME_ID + ) # NZDT is UTC+13 in December, so 2026-12-14 13:00 UTC = 2026-12-15 02:00 NZDT. self.assertEqual(payload.date, datetime.date(2026, 12, 15)) @@ -309,20 +320,22 @@ 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), patch.object(manager, "get_line_items", return_value=[]), ): - payload = manager.build_payload() + payload = manager.build_payload( + document_theme_external_id=DOCUMENT_THEME_ID + ) self.assertEqual(payload.date, NZ_DATE) @@ -337,8 +350,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 +381,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..8d3a54237 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 Company, ContactMethod 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, - method_type=ClientContactMethod.MethodType.PHONE, + ContactMethod.objects.create( + company=company, + method_type=ContactMethod.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() @@ -96,31 +96,31 @@ def test_existing_client_calls_update_contact_with_sdk_contact( @patch("time.sleep") @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.""" +class CreateCompanyContactInXeroTests(TestCase): + """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..caf556abc --- /dev/null +++ b/apps/workflow/tests/test_relabel_client_app.py @@ -0,0 +1,161 @@ +"""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_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_restore_migration_state.py b/apps/workflow/tests/test_restore_migration_state.py new file mode 100644 index 000000000..1609aa85a --- /dev/null +++ b/apps/workflow/tests/test_restore_migration_state.py @@ -0,0 +1,58 @@ +import importlib.util +import types +from pathlib import Path +from typing import ClassVar + +from django.test import TestCase + +REPO_ROOT = Path(__file__).resolve().parents[3] +POST_CHECK = REPO_ROOT / "scripts" / "restore_checks" / "check_post_migration_state.py" + + +def load_post_check() -> types.ModuleType: + spec = importlib.util.spec_from_file_location( + "check_post_migration_state", POST_CHECK + ) + if spec is None or spec.loader is None: + raise RuntimeError("Could not load check_post_migration_state.py") + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +class RestoreMigrationStateTests(TestCase): + check: ClassVar[types.ModuleType] + + @classmethod + def setUpClass(cls) -> None: + super().setUpClass() + cls.check = load_post_check() + + def counts(self) -> dict[str, int]: + return { + "companies": 10, + "contacts": 8, + "contact_methods": 7, + "jobs": 20, + "calls": 5, + "jobs_with_contact": 12, + "calls_with_contact": 4, + } + + def test_count_comparison_accepts_preservation_and_cleanup(self) -> None: + before = self.counts() + after = {**before, "contacts": 6, "contact_methods": 5} + + self.assertEqual(self.check.comparison_errors(before, after), []) + + def test_count_comparison_reports_lost_business_references(self) -> None: + before = self.counts() + after = {**before, "jobs_with_contact": 11} + + self.assertEqual( + self.check.comparison_errors(before, after), + ["jobs_with_contact: before=12, after=11"], + ) + + def test_current_test_schema_satisfies_structural_invariants(self) -> None: + self.assertEqual(self.check.structural_errors(), []) diff --git a/apps/workflow/tests/test_search_telemetry.py b/apps/workflow/tests/test_search_telemetry.py index 45126a92d..a08f07c6f 100644 --- a/apps/workflow/tests/test_search_telemetry.py +++ b/apps/workflow/tests/test_search_telemetry.py @@ -23,13 +23,13 @@ def test_search_click_endpoint_records_generic_event(db): resp = api.post( "/api/search-events/click/", { - "domain": "client", + "domain": "company", "query": "FUME", "selected_result_id": "client-123", "selected_label": "Fumecare Ltd", "selected_rank": 1, "result_count": 7, - "source": "client_lookup", + "source": "company_lookup", "metadata": {"extra": "future-safe"}, }, format="json", @@ -38,13 +38,14 @@ def test_search_click_endpoint_records_generic_event(db): assert resp.status_code == 200, resp.content event = SearchTelemetryEvent.objects.get() assert event.event_type == SearchTelemetryEvent.EventType.CLICK - assert event.domain == SearchTelemetryEvent.Domain.CLIENT + assert event.domain == SearchTelemetryEvent.Domain.COMPANY assert event.query == "FUME" assert event.normalized_query == "fume" assert event.selected_result_id == "client-123" assert event.selected_label == "Fumecare Ltd" assert event.selected_rank == 1 assert event.result_count == 7 + assert event.source == "company_lookup" assert event.metadata == {"extra": "future-safe"} diff --git a/apps/workflow/tests/test_search_telemetry_migration.py b/apps/workflow/tests/test_search_telemetry_migration.py new file mode 100644 index 000000000..ee49efc15 --- /dev/null +++ b/apps/workflow/tests/test_search_telemetry_migration.py @@ -0,0 +1,85 @@ +from typing import ClassVar + +from django.db import connection +from django.db.migrations.executor import MigrationExecutor +from django.test import TransactionTestCase + + +class SearchTelemetryTerminologyMigrationTests(TransactionTestCase): + migrate_from: ClassVar[tuple[tuple[str, str], ...]] = ( + ("workflow", "0007_rename_search_telemetry_client_domain"), + ) + migrate_to: ClassVar[tuple[tuple[str, str], ...]] = ( + ("workflow", "0009_rename_remaining_crm_telemetry_sources"), + ) + + def setUp(self) -> None: + super().setUp() + self.executor = MigrationExecutor(connection) + self.executor.migrate(self.migrate_from) + self.old_apps = self.executor.loader.project_state(self.migrate_from).apps + + def tearDown(self) -> None: + self.executor.loader.build_graph() + self.executor.migrate(self.executor.loader.graph.leaf_nodes()) + super().tearDown() + + def test_forward_and_reverse_rename_company_lookup_source(self) -> None: + SearchTelemetryEvent = self.old_apps.get_model( + "workflow", "SearchTelemetryEvent" + ) + event = SearchTelemetryEvent.objects.create( + event_type="click", + domain="company", + source="client_lookup", + query="Acme", + normalized_query="acme", + ) + companies_table = SearchTelemetryEvent.objects.create( + event_type="click", + domain="company", + source="crm_clients_table", + query="Beta", + normalized_query="beta", + ) + company_detail = SearchTelemetryEvent.objects.create( + event_type="click", + domain="company", + source="crm_client_detail_phone_numbers", + query="Gamma", + normalized_query="gamma", + ) + + self.executor.loader.build_graph() + self.executor.migrate(self.migrate_to) + new_apps = self.executor.loader.project_state(self.migrate_to).apps + SearchTelemetryEvent = new_apps.get_model("workflow", "SearchTelemetryEvent") + + event = SearchTelemetryEvent.objects.get(pk=event.pk) + self.assertEqual(event.source, "company_lookup") + self.assertEqual( + SearchTelemetryEvent.objects.get(pk=companies_table.pk).source, + "crm_companies_table", + ) + self.assertEqual( + SearchTelemetryEvent.objects.get(pk=company_detail.pk).source, + "crm_company_detail_phone_numbers", + ) + + self.executor.loader.build_graph() + self.executor.migrate(self.migrate_from) + reversed_apps = self.executor.loader.project_state(self.migrate_from).apps + SearchTelemetryEvent = reversed_apps.get_model( + "workflow", "SearchTelemetryEvent" + ) + + event = SearchTelemetryEvent.objects.get(pk=event.pk) + self.assertEqual(event.source, "client_lookup") + self.assertEqual( + SearchTelemetryEvent.objects.get(pk=companies_table.pk).source, + "crm_clients_table", + ) + self.assertEqual( + SearchTelemetryEvent.objects.get(pk=company_detail.pk).source, + "crm_client_detail_phone_numbers", + ) 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..c223ca7d9 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,19 +8,24 @@ from django.test.utils import CaptureQueriesContext from django.utils import timezone -from apps.client.models import Client, ClientContactMethod +from apps.company.models import Company, CompanyPersonLink, ContactMethod, Person 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 from apps.workflow.models import AppError -def _make_raw_json(contact_id, name, status="ACTIVE", merged_to=None): +def _make_raw_json( + contact_id: str, + name: str, + status: str = "ACTIVE", + merged_to: str | None = None, +) -> dict[str, object]: """Return raw_json shaped like real Xero contact data stored in the DB. Based on actual production records — includes the full set of underscore- @@ -133,7 +138,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 +155,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 +174,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 +208,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 +244,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) @@ -257,14 +262,47 @@ def test_archived_contact_with_existing_xero_id_updates_in_place( self.assertTrue(self.existing_client.xero_archived) +class XeroPersonIdentityTests(TestCase): + def test_contact_person_payload_does_not_create_or_update_people(self) -> None: + """Xero reads must not undo DocketWorks-owned Person cleanup.""" + raw_json = _make_raw_json("xero-company", "Acme") + raw_json["_contact_persons"] = [ + { + "_first_name": "Jane", + "_last_name": "Smith", + "_email_address": "xero@example.com", + } + ] + company = Company.objects.create( + name="Acme", + xero_last_modified=timezone.now(), + raw_json=raw_json, + ) + person = Person.objects.create( + name="Jane Smith", email="local@example.com", is_active=False + ) + link = CompanyPersonLink.objects.create( + company=company, person=person, is_active=False + ) + + set_company_fields(company) + + person.refresh_from_db() + link.refresh_from_db() + self.assertEqual(Person.objects.count(), 1) + self.assertEqual(person.email, "local@example.com") + self.assertFalse(person.is_active) + self.assertFalse(link.is_active) + + class XeroPhoneMethodSyncTests(TestCase): def _client_with_phone( self, 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,14 +318,14 @@ 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, - method_type=ClientContactMethod.MethodType.PHONE, + ContactMethod.objects.create( + company=existing, + method_type=ContactMethod.MethodType.PHONE, value="021 555 123", ) before = AppError.objects.count() @@ -301,37 +339,35 @@ 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, - method_type=ClientContactMethod.MethodType.PHONE, + legacy = ContactMethod( + company=other, + method_type=ContactMethod.MethodType.PHONE, value="021 555 123", ) - legacy.normalized_value = ClientContactMethod.normalize_phone("021 555 123") - ClientContactMethod.objects.bulk_create([legacy]) + legacy.normalized_value = ContactMethod.normalize_phone("021 555 123") + ContactMethod.objects.bulk_create([legacy]) # owner already stores the number too (its own row). - owner_method = ClientContactMethod( - client=owner, - method_type=ClientContactMethod.MethodType.PHONE, + owner_method = ContactMethod( + company=owner, + method_type=ContactMethod.MethodType.PHONE, value="021 555 123", ) - owner_method.normalized_value = ClientContactMethod.normalize_phone( - "021 555 123" - ) - ClientContactMethod.objects.bulk_create([owner_method]) + owner_method.normalized_value = ContactMethod.normalize_phone("021 555 123") + ContactMethod.objects.bulk_create([owner_method]) created = sync_xero_phone_methods(owner) # must not raise @@ -344,20 +380,20 @@ 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 = ContactMethod.objects.get(company=owner) self.assertEqual(imported_method.label, "DEFAULT") self.assertTrue(imported_method.is_primary) # CRM user relabels the imported number and picks a LOCAL primary. imported_method.label = "Reception" imported_method.save() - local_primary = ClientContactMethod.objects.create( - client=owner, - method_type=ClientContactMethod.MethodType.PHONE, + local_primary = ContactMethod.objects.create( + company=owner, + method_type=ContactMethod.MethodType.PHONE, value="09 555 000", label="After hours", is_primary=True, - source=ClientContactMethod.Source.LOCAL, + source=ContactMethod.Source.LOCAL, ) self.assertEqual(sync_xero_phone_methods(owner), []) @@ -367,12 +403,12 @@ def test_user_edited_label_and_primary_survive_resync(self) -> None: self.assertEqual(imported_method.label, "Reception") self.assertFalse(imported_method.is_primary) self.assertTrue(local_primary.is_primary) - self.assertEqual(imported_method.source, ClientContactMethod.Source.IMPORTED) + self.assertEqual(imported_method.source, ContactMethod.Source.IMPORTED) 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 = ContactMethod.objects.get(company=owner) updated_at_before = method.updated_at with CaptureQueriesContext(connection) as ctx: @@ -389,7 +425,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 +443,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_verify_scrubbed_backup.py b/apps/workflow/tests/test_verify_scrubbed_backup.py new file mode 100644 index 000000000..ec3758cd2 --- /dev/null +++ b/apps/workflow/tests/test_verify_scrubbed_backup.py @@ -0,0 +1,164 @@ +import importlib.util +import subprocess +import types +from pathlib import Path +from unittest.mock import MagicMock, patch + +from django.test import SimpleTestCase + +SCRIPT = Path(__file__).resolve().parents[3] / "scripts" / "verify_scrubbed_backup.py" + + +def load_verifier() -> types.ModuleType: + spec = importlib.util.spec_from_file_location("verify_scrubbed_backup", SCRIPT) + if spec is None or spec.loader is None: + raise RuntimeError("Could not load verify_scrubbed_backup.py") + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +def copy_sql(*rows: str) -> str: + body = "\n".join(rows) + return f"COPY public.test (id) FROM stdin;\n{body}\n\\.\n" + + +class VerifyScrubbedBackupTests(SimpleTestCase): + def setUp(self) -> None: + self.verifier = load_verifier() + self.archive = Path("scrubbed.dump") + + def completed( + self, args: list[str], stdout: str + ) -> subprocess.CompletedProcess[str]: + return subprocess.CompletedProcess(args, 0, stdout=stdout, stderr="") + + def successful_pg_restore( + self, args: list[str], **kwargs: object + ) -> subprocess.CompletedProcess[str]: + if "--table=django_migrations" in args: + return self.completed( + args, + copy_sql("1\tcompany\t0001_baseline\t2026-07-06 00:00:00+12"), + ) + return self.completed(args, copy_sql()) + + @patch.object(Path, "is_file", return_value=True) + @patch("subprocess.run") + def test_accepts_readable_post_squash_archive_without_private_rows( + self, + run: MagicMock, + _is_file: object, + ) -> None: + run.side_effect = self.successful_pg_restore + + self.verifier.verify_backup(self.archive) + + self.assertIn("--file=/dev/null", run.call_args_list[0].args[0]) + + @patch.object(Path, "is_file", return_value=True) + @patch("subprocess.run") + def test_rejects_private_rows_without_printing_their_contents( + self, + run: MagicMock, + _is_file: object, + ) -> None: + secret = "do-not-print-this-secret" + + def pg_restore( + args: list[str], **kwargs: object + ) -> subprocess.CompletedProcess[str]: + result = self.successful_pg_restore(args, **kwargs) + if "--table=workflow_aiprovider" in args: + return self.completed(args, copy_sql(f"1\tGemini\t{secret}")) + return result + + run.side_effect = pg_restore + + with self.assertRaisesRegex( + RuntimeError, + r"workflow_aiprovider=1", + ) as raised: + self.verifier.verify_backup(self.archive) + + self.assertNotIn(secret, str(raised.exception)) + + @patch.object(Path, "is_file", return_value=True) + @patch("subprocess.run") + def test_rejects_pre_squash_archive(self, run: MagicMock, _is_file: object) -> None: + def pg_restore( + args: list[str], **kwargs: object + ) -> subprocess.CompletedProcess[str]: + return self.completed(args, copy_sql()) + + run.side_effect = pg_restore + + with self.assertRaisesRegex(RuntimeError, "predates the July migration squash"): + self.verifier.verify_backup(self.archive) + + @patch.object(Path, "is_file", return_value=True) + @patch("subprocess.run") + def test_legacy_baseline_requires_temporary_cutover_flag( + self, run: MagicMock, _is_file: object + ) -> None: + def pg_restore( + args: list[str], **kwargs: object + ) -> subprocess.CompletedProcess[str]: + if "--table=django_migrations" in args: + return self.completed( + args, + copy_sql("1\tclient\t0001_baseline\t2026-07-06 00:00:00+12"), + ) + return self.completed(args, copy_sql()) + + run.side_effect = pg_restore + + with self.assertRaisesRegex(RuntimeError, "temporary pre-KAN-278"): + self.verifier.verify_backup(self.archive) + + self.verifier.verify_backup( + self.archive, + allow_legacy_client_baseline=True, + ) + + @patch.object(Path, "is_file", return_value=True) + @patch("subprocess.run") + def test_rejects_mixed_client_and_company_baselines( + self, run: MagicMock, _is_file: object + ) -> None: + def pg_restore( + args: list[str], **kwargs: object + ) -> subprocess.CompletedProcess[str]: + if "--table=django_migrations" in args: + return self.completed( + args, + copy_sql( + "1\tclient\t0001_baseline\t2026-07-06 00:00:00+12", + "2\tcompany\t0001_baseline\t2026-07-06 00:01:00+12", + ), + ) + return self.completed(args, copy_sql()) + + run.side_effect = pg_restore + + with self.assertRaisesRegex(RuntimeError, "mixed client/company"): + self.verifier.verify_backup( + self.archive, + allow_legacy_client_baseline=True, + ) + + @patch.object(Path, "is_file", return_value=True) + @patch("subprocess.run") + def test_reports_pg_restore_failure_without_stderr_contents( + self, run: MagicMock, _is_file: object + ) -> None: + secret = "pg-restore-secret" + run.return_value = subprocess.CompletedProcess( + ["pg_restore"], 1, stdout="", stderr=secret + ) + + with self.assertRaisesRegex(RuntimeError, "exit code 1") as raised: + self.verifier.verify_backup(self.archive) + + self.assertNotIn(secret, str(raised.exception)) + self.assertIn("--file=/dev/null", run.call_args.args[0]) diff --git a/apps/workflow/tests/test_xero_app_active.py b/apps/workflow/tests/test_xero_app_active.py index 34a75e96f..e0a030118 100644 --- a/apps/workflow/tests/test_xero_app_active.py +++ b/apps/workflow/tests/test_xero_app_active.py @@ -186,7 +186,6 @@ def test_wipes_token_and_quota_fields(self) -> None: row = _row( client_id="a1", - tenant_id="t", access_token="aaa", refresh_token="rrr", token_type="Bearer", @@ -200,7 +199,6 @@ def test_wipes_token_and_quota_fields(self) -> None: wipe_tokens_and_quota(row) row.refresh_from_db() for field in [ - "tenant_id", "access_token", "refresh_token", "token_type", @@ -222,7 +220,7 @@ def test_wipe_invalidates_tenant_id_cache(self) -> None: from apps.workflow.api.xero.active_app import wipe_tokens_and_quota from apps.workflow.api.xero.constants import TENANT_ID_CACHE_KEY - row = _row(client_id="a1", tenant_id="t-1") + row = _row(client_id="a1") cache.set(TENANT_ID_CACHE_KEY, "stale-tenant") wipe_tokens_and_quota(row) self.assertIsNone(cache.get(TENANT_ID_CACHE_KEY)) diff --git a/apps/workflow/tests/test_xero_app_api.py b/apps/workflow/tests/test_xero_app_api.py index a9e93c0f2..65ef55f22 100644 --- a/apps/workflow/tests/test_xero_app_api.py +++ b/apps/workflow/tests/test_xero_app_api.py @@ -79,7 +79,6 @@ def test_list_returns_safe_fields_only(self): is_active=True, access_token="SECRET-DO-NOT-LEAK", refresh_token="ALSO-SECRET", - tenant_id="tenant-a", expires_at=expires, day_remaining=4321, minute_remaining=55, @@ -94,7 +93,6 @@ def test_list_returns_safe_fields_only(self): self.assertEqual(row["client_id"], "c-a") self.assertTrue(row["is_active"]) self.assertTrue(row["has_tokens"]) - self.assertEqual(row["tenant_id"], "tenant-a") self.assertEqual(row["day_remaining"], 4321) self.assertEqual(row["minute_remaining"], 55) # Forbidden surface — none of these may appear in the response: @@ -195,7 +193,6 @@ def test_patch_label_only_does_not_wipe_tokens(self): client_id="c-a", access_token="aaa", refresh_token="rrr", - tenant_id="t", expires_at=expires, day_remaining=42, ) @@ -215,7 +212,6 @@ def test_patch_client_id_wipes_tokens_and_quota(self): client_id="c-a", access_token="aaa", refresh_token="rrr", - tenant_id="t", expires_at=expires, day_remaining=42, minute_remaining=10, @@ -231,7 +227,6 @@ def test_patch_client_id_wipes_tokens_and_quota(self): self.assertEqual(row.client_id, "c-a-new") self.assertIsNone(row.access_token) self.assertIsNone(row.refresh_token) - self.assertIsNone(row.tenant_id) self.assertIsNone(row.day_remaining) self.assertIsNone(row.snapshot_at) diff --git a/apps/workflow/tests/test_xero_branding_themes.py b/apps/workflow/tests/test_xero_branding_themes.py new file mode 100644 index 000000000..2147f8818 --- /dev/null +++ b/apps/workflow/tests/test_xero_branding_themes.py @@ -0,0 +1,423 @@ +import uuid +from datetime import date +from decimal import Decimal +from importlib import import_module +from types import SimpleNamespace +from unittest.mock import Mock, patch + +from django.apps import apps as django_apps +from django.utils import timezone +from rest_framework.test import APIClient + +from apps.company.models import Company +from apps.job.models import Job +from apps.testing import BaseTestCase +from apps.workflow.accounting.document_theme_service import ( + resolve_sales_branding_theme, +) +from apps.workflow.accounting.types import ( + DocumentLineItem, + DocumentTheme, + InvoicePayload, + QuotePayload, +) +from apps.workflow.accounting.xero.provider import XeroAccountingProvider +from apps.workflow.models import CompanyDefaults, XeroApp +from apps.workflow.views.xero.xero_invoice_manager import XeroInvoiceManager +from apps.workflow.views.xero.xero_quote_manager import XeroQuoteManager +from apps.workflow.views.xero.xero_view import XeroAuthenticationResult + +THEME_ID = "11111111-2222-3333-4444-555555555555" + + +class XeroBrandingThemeProviderTests(BaseTestCase): + """Xero receives the exact theme selected by the DocketWorks operator.""" + + @patch("apps.workflow.accounting.xero.provider.process_xero_data", return_value={}) + @patch.object(XeroAccountingProvider, "_get_api") + def test_invoice_create_payload_includes_branding_theme_id( + self, mock_get_api: Mock, _mock_process: Mock + ) -> None: + api = Mock() + api.create_invoices.return_value = ( + SimpleNamespace( + invoices=[ + SimpleNamespace( + invoice_id=uuid.uuid4(), invoice_number="INV-THEME-1" + ) + ] + ), + 200, + {}, + ) + mock_get_api.return_value = (api, "tenant-id") + + payload = InvoicePayload( + client_external_id=str(uuid.uuid4()), + company_name="Theme Test Company", + line_items=[ + DocumentLineItem( + description="Theme test", + quantity=Decimal("1"), + unit_amount=Decimal("100"), + ) + ], + date=date(2026, 7, 16), + due_date=date(2026, 7, 16), + document_theme_external_id=THEME_ID, + ) + + result = XeroAccountingProvider().create_invoice(payload) + + self.assertTrue(result.success) + sent = api.create_invoices.call_args.kwargs["invoices"]["Invoices"][0] + self.assertEqual(sent["BrandingThemeID"], THEME_ID) + self.assertEqual(sent["Contact"]["ContactID"], payload.client_external_id) + + @patch("apps.workflow.accounting.xero.provider.process_xero_data", return_value={}) + @patch.object(XeroAccountingProvider, "_get_api") + def test_quote_create_payload_includes_branding_theme_without_terms( + self, mock_get_api: Mock, _mock_process: Mock + ) -> None: + api = Mock() + api.create_quotes.return_value = ( + SimpleNamespace( + quotes=[ + SimpleNamespace(quote_id=uuid.uuid4(), quote_number="QU-THEME-1") + ] + ), + 200, + {}, + ) + mock_get_api.return_value = (api, "tenant-id") + + payload = QuotePayload( + client_external_id=str(uuid.uuid4()), + company_name="Theme Test Company", + line_items=[ + DocumentLineItem( + description="Theme test", + quantity=Decimal("1"), + unit_amount=Decimal("100"), + ) + ], + date=date(2026, 7, 16), + expiry_date=date(2026, 8, 15), + document_theme_external_id=THEME_ID, + ) + + result = XeroAccountingProvider().create_quote(payload) + + self.assertTrue(result.success) + sent = api.create_quotes.call_args.kwargs["quotes"]["Quotes"][0] + self.assertEqual(sent["BrandingThemeID"], THEME_ID) + self.assertNotIn("Terms", sent) + + @patch.object(XeroAccountingProvider, "_get_api") + def test_list_document_themes_preserves_xero_order_and_default( + self, mock_get_api: Mock + ) -> None: + api = Mock() + api.get_branding_themes.return_value = SimpleNamespace( + branding_themes=[ + SimpleNamespace( + branding_theme_id="aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa", + name="Secondary", + sort_order=2, + ), + SimpleNamespace( + branding_theme_id="bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb", + name="Sales Terms", + sort_order=0, + ), + ] + ) + mock_get_api.return_value = (api, "tenant-id") + + themes = XeroAccountingProvider().list_document_themes() + + self.assertEqual([theme.name for theme in themes], ["Sales Terms", "Secondary"]) + self.assertTrue(themes[0].is_default) + self.assertFalse(themes[1].is_default) + + +class XeroBrandingThemeConfigurationTests(BaseTestCase): + """Document creation consumes configuration without changing it.""" + + def setUp(self) -> None: + defaults = CompanyDefaults.get_solo() + CompanyDefaults.objects.filter(pk=defaults.pk).update( + xero_sales_branding_theme_id=None + ) + CompanyDefaults.clear_cache() + + self.company = Company.objects.create( + name="Missing Theme Company", + xero_contact_id=str(uuid.uuid4()), + xero_last_modified=timezone.now(), + ) + self.job = Job( + company=self.company, + name="Missing Theme Job", + pricing_methodology="fixed_price", + ) + self.job.save(staff=self.test_staff) + + def test_invoice_creation_stops_when_theme_is_unconfigured(self) -> None: + """An incomplete Xero setup must fail before any invoice API request.""" + manager = XeroInvoiceManager( + company=self.company, job=self.job, staff=self.test_staff + ) + manager.provider = Mock() + + result = manager.create_document(total_amount=Decimal("100")) + + self.assertFalse(result["success"]) + self.assertEqual(result["status"], 400) + self.assertEqual(result["error_type"], "configuration_error") + error = result["error"] + assert error is not None + self.assertIn("Configure the Xero sales branding theme", error) + manager.provider.list_document_themes.assert_not_called() + manager.provider.create_invoice.assert_not_called() + self.assertIsNone(CompanyDefaults.get_solo().xero_sales_branding_theme_id) + + def test_quote_creation_stops_when_theme_is_unconfigured(self) -> None: + """An incomplete Xero setup must fail before any quote API request.""" + manager = XeroQuoteManager( + company=self.company, job=self.job, staff=self.test_staff + ) + manager.provider = Mock() + + result = manager.create_document() + + self.assertFalse(result["success"]) + self.assertEqual(result["status"], 400) + self.assertEqual(result["error_type"], "configuration_error") + error = result["error"] + assert error is not None + self.assertIn("Configure the Xero sales branding theme", error) + manager.provider.list_document_themes.assert_not_called() + manager.provider.create_quote.assert_not_called() + self.assertIsNone(CompanyDefaults.get_solo().xero_sales_branding_theme_id) + + def test_configured_theme_does_not_add_a_xero_read(self) -> None: + """Normal document creation must not spend quota revalidating stable config.""" + configured_id = uuid.UUID(THEME_ID) + defaults = CompanyDefaults.get_solo() + CompanyDefaults.objects.filter(pk=defaults.pk).update( + xero_sales_branding_theme_id=configured_id + ) + CompanyDefaults.clear_cache() + manager = XeroQuoteManager( + company=self.company, job=self.job, staff=self.test_staff + ) + manager.provider = Mock() + + selected_id = manager.get_xero_sales_branding_theme_id() + + self.assertEqual(selected_id, THEME_ID) + manager.provider.list_document_themes.assert_not_called() + + +class SalesBrandingThemeResolutionTests(BaseTestCase): + """Migration and setup share one provider-order selection contract.""" + + def setUp(self) -> None: + self.provider = Mock() + + def test_unset_configuration_selects_first_theme(self) -> None: + """Multiple valid themes must not reintroduce a unique-default requirement.""" + first_theme = DocumentTheme( + external_id=THEME_ID, + name="First by Xero sort order", + is_default=False, + ) + self.provider.list_document_themes.return_value = [ + first_theme, + DocumentTheme( + external_id="aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa", + name="Also valid", + is_default=False, + ), + ] + + selected = resolve_sales_branding_theme(self.provider, None) + + self.assertEqual(selected, first_theme) + + def test_live_custom_selection_is_preserved(self) -> None: + """Running setup again must not replace an operator's live custom theme.""" + custom_id = uuid.UUID("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa") + custom_theme = DocumentTheme( + external_id=str(custom_id), name="Terms", is_default=False + ) + self.provider.list_document_themes.return_value = [ + DocumentTheme(external_id=THEME_ID, name="Standard", is_default=True), + custom_theme, + ] + + selected = resolve_sales_branding_theme(self.provider, custom_id) + + self.assertEqual(selected, custom_theme) + + def test_stale_selection_is_replaced_by_first_live_theme(self) -> None: + """A restored cross-tenant UUID must not leak into new Xero documents.""" + stale_id = uuid.UUID("aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa") + first_theme = DocumentTheme( + external_id=THEME_ID, name="Destination theme", is_default=True + ) + self.provider.list_document_themes.return_value = [first_theme] + + selected = resolve_sales_branding_theme(self.provider, stale_id) + + self.assertEqual(selected, first_theme) + + def test_empty_theme_list_leaves_configuration_unset(self) -> None: + """No arbitrary UUID may be invented when the connected tenant has no theme.""" + self.provider.list_document_themes.return_value = [] + + selected = resolve_sales_branding_theme(self.provider, None) + + self.assertIsNone(selected) + + +class SalesBrandingThemeMigrationTests(BaseTestCase): + """The data migration configures only genuinely connected installations.""" + + def setUp(self) -> None: + self.defaults = CompanyDefaults.get_solo() + CompanyDefaults.objects.filter(pk=self.defaults.pk).update( + xero_tenant_id="tenant-123", + xero_sales_branding_theme_id=None, + po_prefix="UNCHANGED-", + ) + CompanyDefaults.clear_cache() + XeroApp.objects.create( + label="Migration test", + client_id="migration-client-id", + client_secret="migration-client-secret", + redirect_uri="https://example.test/xero/callback/", + is_active=True, + refresh_token="migration-refresh-token", + ) + self.migration = import_module( + "apps.workflow.migrations." + "0011_companydefaults_xero_sales_branding_theme_id" + ) + + @patch("apps.workflow.accounting.registry.get_provider") + @patch( + "apps.workflow.accounting.document_theme_service." + "resolve_sales_branding_theme" + ) + def test_connected_install_is_backfilled_without_broad_save( + self, + mock_resolve_theme: Mock, + mock_get_provider: Mock, + ) -> None: + """Existing deployments must be ready before services restart.""" + selected_theme = DocumentTheme( + external_id=THEME_ID, + name="First Xero theme", + is_default=True, + ) + mock_resolve_theme.return_value = selected_theme + + self.migration.populate_connected_install_theme(django_apps, Mock()) + self.defaults.refresh_from_db() + + mock_resolve_theme.assert_called_once_with(mock_get_provider.return_value, None) + self.assertEqual( + self.defaults.xero_sales_branding_theme_id, + uuid.UUID(THEME_ID), + ) + self.assertEqual(self.defaults.po_prefix, "UNCHANGED-") + + @patch( + "apps.workflow.accounting.document_theme_service." + "resolve_sales_branding_theme" + ) + def test_unconnected_install_is_left_for_xero_setup( + self, mock_resolve_theme: Mock + ) -> None: + """Fresh and scrubbed installs have no durable OAuth token at migrate time.""" + XeroApp.objects.filter(is_active=True).update(refresh_token=None) + + self.migration.populate_connected_install_theme(django_apps, Mock()) + self.defaults.refresh_from_db() + + mock_resolve_theme.assert_not_called() + self.assertIsNone(self.defaults.xero_sales_branding_theme_id) + + @patch( + "apps.workflow.accounting.document_theme_service." + "resolve_sales_branding_theme" + ) + def test_install_without_tenant_is_left_for_xero_setup( + self, mock_resolve_theme: Mock + ) -> None: + """The migration must not contact Xero before onboarding chooses a tenant.""" + CompanyDefaults.objects.filter(pk=self.defaults.pk).update(xero_tenant_id=None) + + self.migration.populate_connected_install_theme(django_apps, Mock()) + + mock_resolve_theme.assert_not_called() + + @patch("apps.workflow.accounting.registry.get_provider") + @patch( + "apps.workflow.accounting.document_theme_service." + "resolve_sales_branding_theme" + ) + def test_connected_install_without_themes_fails_migration( + self, + mock_resolve_theme: Mock, + _mock_get_provider: Mock, + ) -> None: + """A connected deployment must not restart with incomplete configuration.""" + mock_resolve_theme.return_value = None + + with self.assertRaisesRegex(RuntimeError, "returned no branding themes"): + self.migration.populate_connected_install_theme(django_apps, Mock()) + + self.assertIsNone( + CompanyDefaults.objects.values_list( + "xero_sales_branding_theme_id", flat=True + ).get(pk=self.defaults.pk) + ) + + +class XeroBrandingThemeAPITests(BaseTestCase): + """Office staff can populate the settings selector from live Xero themes.""" + + def setUp(self) -> None: + self.test_staff.is_office_staff = True + self.test_staff.save(update_fields=["is_office_staff"]) + self.client = APIClient() + self.client.force_authenticate(user=self.test_staff) + + @patch("apps.workflow.views.xero.xero_view.get_provider") + @patch("apps.workflow.views.xero.xero_view.ensure_xero_authentication") + def test_list_branding_themes_returns_selector_contract( + self, mock_auth: Mock, mock_get_provider: Mock + ) -> None: + mock_auth.return_value = XeroAuthenticationResult() + provider = Mock() + provider.list_document_themes.return_value = [ + DocumentTheme(external_id=THEME_ID, name="Sales Terms", is_default=True) + ] + mock_get_provider.return_value = provider + + response = self.client.get("/api/xero/branding-themes/") + + self.assertEqual(response.status_code, 200) + self.assertEqual( + response.json(), + [ + { + "branding_theme_id": THEME_ID, + "name": "Sales Terms", + "is_default": True, + } + ], + ) diff --git a/apps/workflow/tests/test_xero_document_error_handling.py b/apps/workflow/tests/test_xero_document_error_handling.py new file mode 100644 index 000000000..5591e6d04 --- /dev/null +++ b/apps/workflow/tests/test_xero_document_error_handling.py @@ -0,0 +1,160 @@ +"""Error contract for Xero document managers. + +The managers are service objects, not the HTTP boundary (ADR 0001): an +unexpected exception is persisted once and re-raised as AlreadyLoggedException +for the view to convert into a 500 carrying ``error_id``. Only *expected* +business outcomes come back as ``success: False`` dicts. +""" + +import uuid +from decimal import Decimal +from unittest.mock import Mock, patch + +from django.utils import timezone + +from apps.company.models import Company +from apps.job.models import Job +from apps.testing import BaseTestCase +from apps.workflow.exceptions import AlreadyLoggedException +from apps.workflow.models import AppError, CompanyDefaults +from apps.workflow.views.xero.xero_invoice_manager import XeroInvoiceManager +from apps.workflow.views.xero.xero_quote_manager import XeroQuoteManager + +THEME_ID = "11111111-2222-3333-4444-555555555555" + + +class XeroDocumentManagerErrorContractTests(BaseTestCase): + """Unexpected provider failures re-raise once; they never become a dict.""" + + def setUp(self) -> None: + defaults = CompanyDefaults.get_solo() + CompanyDefaults.objects.filter(pk=defaults.pk).update( + xero_sales_branding_theme_id=uuid.UUID(THEME_ID) + ) + CompanyDefaults.clear_cache() + + self.company = Company.objects.create( + name="Error Contract Company", + xero_contact_id=str(uuid.uuid4()), + xero_last_modified=timezone.now(), + ) + self.job = Job( + company=self.company, + name="Error Contract Job", + pricing_methodology="fixed_price", + ) + self.job.save(staff=self.test_staff) + + def _invoice_manager(self) -> tuple[XeroInvoiceManager, Mock]: + """Return the manager plus a handle on its mocked provider.""" + manager = XeroInvoiceManager( + company=self.company, job=self.job, staff=self.test_staff + ) + provider = Mock() + manager.provider = provider + return manager, provider + + def _quote_manager(self) -> tuple[XeroQuoteManager, Mock]: + manager = XeroQuoteManager( + company=self.company, job=self.job, staff=self.test_staff + ) + provider = Mock() + manager.provider = provider + return manager, provider + + # --- create ----------------------------------------------------------- + + def test_invoice_create_reraises_already_logged(self) -> None: + """A Xero blow-up must reach the view as AlreadyLoggedException, not a dict.""" + manager, provider = self._invoice_manager() + provider.create_invoice.side_effect = RuntimeError("Xero exploded") + + with patch.object(XeroInvoiceManager, "build_payload", return_value=Mock()): + with self.assertRaises(AlreadyLoggedException) as caught: + manager.create_document(total_amount=Decimal("100")) + + self.assertIsNotNone(caught.exception.app_error_id) + self.assertEqual(str(caught.exception), "Xero exploded") + self.assertEqual(AppError.objects.count(), 1) + + def test_quote_create_reraises_already_logged(self) -> None: + manager, provider = self._quote_manager() + provider.create_quote.side_effect = RuntimeError("Xero exploded") + + with patch.object(XeroQuoteManager, "build_payload", return_value=Mock()): + with self.assertRaises(AlreadyLoggedException) as caught: + manager.create_document() + + self.assertIsNotNone(caught.exception.app_error_id) + self.assertEqual(AppError.objects.count(), 1) + + # --- delete ----------------------------------------------------------- + + def test_invoice_delete_reraises_already_logged(self) -> None: + manager, provider = self._invoice_manager() + provider.delete_invoice.side_effect = RuntimeError("Xero exploded") + + with patch.object(XeroInvoiceManager, "get_xero_id", return_value="xero-1"): + with self.assertRaises(AlreadyLoggedException) as caught: + manager.delete_document() + + self.assertIsNotNone(caught.exception.app_error_id) + self.assertEqual(AppError.objects.count(), 1) + + def test_quote_delete_reraises_already_logged(self) -> None: + manager, provider = self._quote_manager() + provider.delete_quote.side_effect = RuntimeError("Xero exploded") + + with patch.object(XeroQuoteManager, "get_xero_id", return_value="xero-1"): + with self.assertRaises(AlreadyLoggedException) as caught: + manager.delete_document() + + self.assertIsNotNone(caught.exception.app_error_id) + self.assertEqual(AppError.objects.count(), 1) + + # --- dedup regression guard ------------------------------------------ + + def test_invoice_delete_does_not_double_persist(self) -> None: + """The delete path once lacked the pass-through arm and re-persisted.""" + original = RuntimeError("Persisted upstream") + already_logged = AlreadyLoggedException(original, uuid.uuid4()) + + manager, provider = self._invoice_manager() + provider.delete_invoice.side_effect = already_logged + + with patch.object(XeroInvoiceManager, "get_xero_id", return_value="xero-1"): + with self.assertRaises(AlreadyLoggedException) as caught: + manager.delete_document() + + self.assertIs(caught.exception, already_logged) + self.assertEqual(AppError.objects.count(), 0) + + def test_quote_delete_does_not_double_persist(self) -> None: + original = RuntimeError("Persisted upstream") + already_logged = AlreadyLoggedException(original, uuid.uuid4()) + + manager, provider = self._quote_manager() + provider.delete_quote.side_effect = already_logged + + with patch.object(XeroQuoteManager, "get_xero_id", return_value="xero-1"): + with self.assertRaises(AlreadyLoggedException) as caught: + manager.delete_document() + + self.assertIs(caught.exception, already_logged) + self.assertEqual(AppError.objects.count(), 0) + + # --- expected failures still return a dict ---------------------------- + + def test_expected_provider_failure_still_returns_dict(self) -> None: + """A declined Xero call is a business outcome, not an exception.""" + manager, provider = self._invoice_manager() + provider.create_invoice.return_value = Mock( + success=False, error="Contact is archived", status_code=400 + ) + + with patch.object(XeroInvoiceManager, "build_payload", return_value=Mock()): + result = manager.create_document(total_amount=Decimal("100")) + + self.assertFalse(result["success"]) + self.assertEqual(result["status"], 400) + self.assertEqual(AppError.objects.count(), 0) diff --git a/apps/workflow/tests/test_xero_document_raw_json.py b/apps/workflow/tests/test_xero_document_raw_json.py index 5009ef9bb..bf8359d1e 100644 --- a/apps/workflow/tests/test_xero_document_raw_json.py +++ b/apps/workflow/tests/test_xero_document_raw_json.py @@ -3,10 +3,11 @@ 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 +from apps.workflow.models import CompanyDefaults from apps.workflow.views.xero.xero_invoice_manager import XeroInvoiceManager from apps.workflow.views.xero.xero_quote_manager import XeroQuoteManager @@ -33,21 +34,28 @@ 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, ) + # Sales documents require a configured branding theme before any + # provider call; without it create_document stops at the config guard. + defaults = CompanyDefaults.get_solo() + CompanyDefaults.objects.filter(pk=defaults.pk).update( + xero_sales_branding_theme_id=uuid.uuid4() + ) + CompanyDefaults.clear_cache() 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 +74,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 +86,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 +98,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 +115,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 +126,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_readonly_provider.py b/apps/workflow/tests/test_xero_readonly_provider.py index ad550edaf..e90f57316 100644 --- a/apps/workflow/tests/test_xero_readonly_provider.py +++ b/apps/workflow/tests/test_xero_readonly_provider.py @@ -13,14 +13,14 @@ 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 from apps.testing import BaseTestCase from apps.workflow.accounting.registry import get_provider from apps.workflow.accounting.types import DocumentLineItem, POPayload from apps.workflow.accounting.xero.provider import XeroAccountingProvider from apps.workflow.accounting.xero.readonly_provider import XeroReadOnlyProvider -from apps.workflow.models import AppError, XeroAccount +from apps.workflow.models import AppError, CompanyDefaults, XeroAccount, XeroApp from apps.workflow.views.xero.xero_invoice_manager import XeroInvoiceManager from apps.workflow.views.xero.xero_quote_manager import XeroQuoteManager @@ -68,16 +68,46 @@ def test_ping_reports_readonly_true(self) -> None: def test_ping_reports_readonly_false(self) -> None: self.assertIs(self._ping_data()["xero_readonly"], False) + @override_settings( + PRODUCTION_XERO_CLIENT_IDS=["prod-client"], + XERO_READONLY=False, + ) + def test_ping_reports_production_xero_client_true(self) -> None: + XeroApp.objects.create( + label="Production", + client_id="prod-client", + client_secret="secret", + redirect_uri="https://example.test/callback", + is_active=True, + ) + + self.assertIs(self._ping_data()["xero_production_client"], True) + + @override_settings( + PRODUCTION_XERO_CLIENT_IDS=["prod-client"], + XERO_READONLY=False, + ) + def test_ping_reports_production_xero_client_false(self) -> None: + XeroApp.objects.create( + label="Development", + client_id="dev-client", + client_secret="secret", + redirect_uri="https://example.test/callback", + is_active=True, + ) + + self.assertIs(self._ping_data()["xero_production_client"], False) + @override_settings(XERO_READONLY=True) class XeroReadOnlyProviderTests(BaseTestCase): def setUp(self) -> None: super().setUp() - self.client_obj = Client.objects.create( - name="[TEST] Readonly Client", xero_last_modified=timezone.now() + self.company = Company.objects.create( + name="[TEST] Readonly Company", xero_last_modified=timezone.now() ) self.job = Job.objects.create( - client=self.client_obj, + company=self.company, name="[TEST] Readonly Job", pricing_methodology="fixed_price", staff=self.test_staff, @@ -89,6 +119,14 @@ def setUp(self) -> None: xero_last_modified=timezone.now(), raw_json={}, ) + # Sales documents require a configured branding theme before any + # provider call; without it create_document stops at the config guard. + defaults = CompanyDefaults.get_solo() + CompanyDefaults.objects.filter(pk=defaults.pk).update( + xero_sales_branding_theme_id=uuid.uuid4() + ) + CompanyDefaults.clear_cache() + self._api_patcher = patch.object( XeroAccountingProvider, "_get_api", side_effect=_API_FORBIDDEN ) @@ -102,48 +140,48 @@ def _assert_no_app_errors(self) -> None: # --- Contacts --- def test_create_contact_persists_fake_id_and_succeeds(self) -> None: - result = self.provider.create_contact(self.client_obj) + result = self.provider.create_contact(self.company) self.assertTrue(result.success) - self.client_obj.refresh_from_db() - self.assertEqual(result.external_id, self.client_obj.xero_contact_id) + self.company.refresh_from_db() + self.assertEqual(result.external_id, self.company.xero_contact_id) # Must be a well-formed UUID: the frontend Xero badge keys off it - uuid.UUID(self.client_obj.xero_contact_id) - self.assertEqual(result.name, self.client_obj.name) + uuid.UUID(self.company.xero_contact_id) + self.assertEqual(result.name, self.company.name) self._assert_no_app_errors() def test_update_contact_succeeds_without_api(self) -> None: - self.client_obj.xero_contact_id = str(uuid.uuid4()) - self.client_obj.save(update_fields=["xero_contact_id"]) + self.company.xero_contact_id = str(uuid.uuid4()) + self.company.save(update_fields=["xero_contact_id"]) - result = self.provider.update_contact(self.client_obj) + result = self.provider.update_contact(self.company) self.assertTrue(result.success) - self.assertEqual(result.external_id, self.client_obj.xero_contact_id) + self.assertEqual(result.external_id, self.company.xero_contact_id) self._assert_no_app_errors() def test_update_contact_without_id_upserts_like_real_provider(self) -> None: - """sync_client_to_xero creates the contact when no ID exists; the + """sync_company_to_xero creates the contact when no ID exists; the readonly provider must mirror that upsert, never succeed with a missing external_id.""" - self.assertIsNone(self.client_obj.xero_contact_id) + self.assertIsNone(self.company.xero_contact_id) - result = self.provider.update_contact(self.client_obj) + result = self.provider.update_contact(self.company) self.assertTrue(result.success) self.assertIsNotNone(result.external_id) - self.client_obj.refresh_from_db() - self.assertEqual(result.external_id, self.client_obj.xero_contact_id) + self.company.refresh_from_db() + self.assertEqual(result.external_id, self.company.xero_contact_id) self._assert_no_app_errors() # --- Documents through the real managers --- def test_create_invoice_stub_drives_invoice_manager(self) -> None: - self.client_obj.xero_contact_id = str(uuid.uuid4()) - self.client_obj.save(update_fields=["xero_contact_id"]) + self.company.xero_contact_id = str(uuid.uuid4()) + self.company.save(update_fields=["xero_contact_id"]) manager = XeroInvoiceManager( - client=self.client_obj, job=self.job, staff=self.test_staff + company=self.company, job=self.job, staff=self.test_staff ) manager.provider = self.provider # PDF generation is not under test (see test_xero_document_raw_json.py) @@ -164,13 +202,13 @@ def test_create_invoice_stub_drives_invoice_manager(self) -> None: self._assert_no_app_errors() def test_create_quote_stub_drives_quote_manager(self) -> None: - self.client_obj.xero_contact_id = str(uuid.uuid4()) - self.client_obj.save(update_fields=["xero_contact_id"]) + self.company.xero_contact_id = str(uuid.uuid4()) + self.company.save(update_fields=["xero_contact_id"]) self.job.latest_quote.summary = {"cost": 0.0, "rev": 250.0, "hours": 0.0} self.job.latest_quote.save(update_fields=["summary"]) manager = XeroQuoteManager( - client=self.client_obj, job=self.job, staff=self.test_staff + company=self.company, job=self.job, staff=self.test_staff ) manager.provider = self.provider diff --git a/apps/workflow/tests/test_xero_setup_command.py b/apps/workflow/tests/test_xero_setup_command.py index 4abd69aa2..8e33923b6 100644 --- a/apps/workflow/tests/test_xero_setup_command.py +++ b/apps/workflow/tests/test_xero_setup_command.py @@ -1,10 +1,14 @@ +import json from decimal import Decimal +from pathlib import Path from types import SimpleNamespace from unittest.mock import Mock, patch +from uuid import UUID from django.core.management.base import CommandError from django.test import TestCase +from apps.workflow.accounting.types import DocumentTheme from apps.workflow.management.commands.xero import Command from apps.workflow.models import XeroPayItem @@ -171,6 +175,8 @@ def test_skips_destination_items_that_already_exist_by_name( class RunSetupTests(TestCase): + @patch("apps.workflow.management.commands.xero.resolve_sales_branding_theme") + @patch("apps.workflow.management.commands.xero.get_provider") @patch("apps.workflow.management.commands.xero.cache.set") @patch("apps.workflow.management.commands.xero.get_payroll_calendars") @patch("apps.workflow.management.commands.xero.AccountingApi") @@ -183,11 +189,14 @@ def test_run_setup_always_calls_demo_item_provisioning( mock_accounting_api_cls, mock_get_payroll_calendars, _mock_cache_set, + mock_get_provider, + mock_resolve_theme, ): company = SimpleNamespace( xero_tenant_id=None, xero_payroll_calendar_name="Weekly Testing", xero_shortcode=None, + xero_sales_branding_theme_id=None, xero_payroll_calendar_id=None, save=Mock(), ) @@ -205,6 +214,12 @@ def test_run_setup_always_calls_demo_item_provisioning( mock_get_payroll_calendars.return_value = [ {"name": "Weekly Testing", "id": "calendar-123"} ] + selected_theme = DocumentTheme( + external_id="11111111-2222-3333-4444-555555555555", + name="Standard", + is_default=True, + ) + mock_resolve_theme.return_value = selected_theme cmd = Command() cmd._ensure_demo_xero_items_exist = Mock() @@ -214,8 +229,40 @@ def test_run_setup_always_calls_demo_item_provisioning( cmd._ensure_demo_xero_items_exist.assert_called_once_with( "Weekly Testing", "tenant-123" ) + mock_resolve_theme.assert_called_once_with(mock_get_provider.return_value, None) + self.assertEqual( + company.xero_sales_branding_theme_id, + UUID(selected_theme.external_id), + ) + self.assertEqual( + company.save.call_args_list[-1].kwargs["update_fields"], + [ + "xero_shortcode", + "xero_sales_branding_theme_id", + "xero_payroll_calendar_id", + ], + ) def test_removed_create_missing_xero_items_flag_is_rejected(self): parser = Command().create_parser("manage.py", "xero") with self.assertRaises(CommandError): parser.parse_args(["--setup", "--create-missing-xero-items"]) + + +class CompanyDefaultsBrandingThemeFixtureTests(TestCase): + def test_shared_fixtures_do_not_ship_tenant_specific_theme_ids(self) -> None: + fixture_dir = Path(__file__).parents[1] / "fixtures" + + for fixture_name in ( + "company_defaults.json", + "company_defaults_prospect.json", + ): + fixture = json.loads((fixture_dir / fixture_name).read_text()) + company_defaults_records = [ + item for item in fixture if item["model"] == "workflow.companydefaults" + ] + self.assertEqual(len(company_defaults_records), 1, fixture_name) + self.assertIsNone( + company_defaults_records[0]["fields"]["xero_sales_branding_theme_id"], + fixture_name, + ) 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/urls.py b/apps/workflow/urls.py index f954dfb1b..23d5783f1 100644 --- a/apps/workflow/urls.py +++ b/apps/workflow/urls.py @@ -104,6 +104,11 @@ xero_view.stream_xero_sync, name="stream_xero_sync", ), + path( + "xero/branding-themes/", + xero_view.list_xero_branding_themes, + name="xero_branding_themes_list", + ), path( "xero/create_invoice/", xero_view.create_xero_invoice, diff --git a/apps/workflow/views/data_versions_view.py b/apps/workflow/views/data_versions_view.py index e332ed5f1..04761d090 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 Company, CompanyPersonLink, Person from apps.crm.models import PhoneCallRecord, PhoneCallRecording from apps.job.models import Job from apps.purchasing.models import Stock @@ -48,8 +48,9 @@ def _kanban_version() -> str: return "|".join( [ _model_version(Job, "updated_at"), - _model_version(Client, "django_updated_at"), - _model_version(ClientContact, "updated_at"), + _model_version(Company, "django_updated_at"), + _model_version(CompanyPersonLink, "updated_at"), + _model_version(Person, "updated_at"), _model_version(Staff, "updated_at"), ] ) @@ -62,8 +63,9 @@ def _crm_calls_version() -> str: [ _model_version(PhoneCallRecord, "updated_at"), _model_version(PhoneCallRecording, "updated_at"), - _model_version(Client, "django_updated_at"), - _model_version(ClientContact, "updated_at"), + _model_version(Company, "django_updated_at"), + _model_version(CompanyPersonLink, "updated_at"), + _model_version(Person, "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/__init__.py b/apps/workflow/views/xero/__init__.py index 3183f5727..ffc5d528f 100644 --- a/apps/workflow/views/xero/__init__.py +++ b/apps/workflow/views/xero/__init__.py @@ -21,6 +21,7 @@ ensure_xero_authentication, generate_xero_sync_events, get_xero_sync_info, + list_xero_branding_themes, refresh_xero_data, refresh_xero_token, start_xero_sync, @@ -39,7 +40,7 @@ from django.apps import apps if apps.ready: - from .xero_base_manager import XeroDocumentManager + from .xero_base_manager import XeroDocumentManager, XeroDocumentResponse from .xero_invoice_manager import XeroInvoiceManager from .xero_po_manager import XeroPurchaseOrderManager from .xero_quote_manager import XeroQuoteManager @@ -50,6 +51,7 @@ __all__ = [ "XeroAuthenticationResult", "XeroDocumentManager", + "XeroDocumentResponse", "XeroErrorDetailAPIView", "XeroErrorListAPIView", "XeroIndexView", @@ -68,6 +70,7 @@ "format_date", "generate_xero_sync_events", "get_xero_sync_info", + "list_xero_branding_themes", "parse_xero_api_error_message", "refresh_xero_data", "refresh_xero_token", diff --git a/apps/workflow/views/xero/xero_base_manager.py b/apps/workflow/views/xero/xero_base_manager.py index eec3cc374..fad2a5d80 100644 --- a/apps/workflow/views/xero/xero_base_manager.py +++ b/apps/workflow/views/xero/xero_base_manager.py @@ -1,18 +1,43 @@ # workflow/views/xero/xero_base_manager.py import logging from abc import ABC, abstractmethod +from typing import TypedDict 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 from apps.workflow.accounting.registry import get_provider +from apps.workflow.models import CompanyDefaults from apps.workflow.services.error_persistence import persist_app_error logger = logging.getLogger("xero") +class XeroDocumentResponse(TypedDict, total=False): + """Outcome of a document operation, as consumed by the Xero views. + + Only *expected* outcomes travel as a value: success, or a business failure + the caller renders as a 4xx. Unexpected exceptions are persisted once and + re-raised as ``AlreadyLoggedException`` (ADR 0001) — they never appear here. + ``success`` is present on every response. + """ + + success: bool + error: str | None + error_type: str + status: int + invoice_id: str + xero_id: str | None + company: str + total_excl_tax: str + total_incl_tax: str + online_url: str | None + message: str + messages: list[str] + + class XeroDocumentManager(ABC): """ Base class for managing Xero Documents (Invoices, Quotes, Purchase Orders). @@ -20,15 +45,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 +61,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 +118,23 @@ def _get_account_code(self, account_name: str = "Sales") -> str | None: """ return self.provider.get_account_code(account_name) - def validate_client(self): + @staticmethod + def get_xero_sales_branding_theme_id() -> str | None: + """Return the configured Xero theme used for sales documents.""" + theme_id = CompanyDefaults.get_solo().xero_sales_branding_theme_id + if theme_id is None: + return None + return str(theme_id) + + 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_helpers.py b/apps/workflow/views/xero/xero_helpers.py index 61978a1f2..03649ff67 100644 --- a/apps/workflow/views/xero/xero_helpers.py +++ b/apps/workflow/views/xero/xero_helpers.py @@ -1,6 +1,5 @@ # workflow/views/xero_helpers.py import json -import re from datetime import date from typing import Any, Dict, List, Optional @@ -31,16 +30,20 @@ def convert_to_pascal_case(obj): Recursively converts dictionary keys from snake_case to PascalCase. Handles keys starting with an underscore. """ + + def convert_key(key: str) -> str: + leading_underscore = key.startswith("_") + source = key[1:] if leading_underscore else key + converted = "".join( + "ID" if part == "id" else part[:1].upper() + part[1:] + for part in source.split("_") + ) + return f"_{converted}" if leading_underscore else converted + if isinstance(obj, dict): new_dict = {} for key, value in obj.items(): - # Handle potential leading underscores before converting - if key.startswith("_"): - pascal_key = "_" + re.sub( - r"(?:^|_)(.)", lambda x: x.group(1).upper(), key[1:] - ) - else: - pascal_key = re.sub(r"(?:^|_)(.)", lambda x: x.group(1).upper(), key) + pascal_key = convert_key(key) new_dict[pascal_key] = convert_to_pascal_case(value) return new_dict elif isinstance(obj, list): diff --git a/apps/workflow/views/xero/xero_invoice_manager.py b/apps/workflow/views/xero/xero_invoice_manager.py index 85fc57156..ce1101255 100644 --- a/apps/workflow/views/xero/xero_invoice_manager.py +++ b/apps/workflow/views/xero/xero_invoice_manager.py @@ -11,15 +11,16 @@ # 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 from apps.workflow.accounting.types import DocumentLineItem, InvoicePayload +from apps.workflow.exceptions import AlreadyLoggedException from apps.workflow.services.error_persistence import persist_app_error # Import base class and helpers -from .xero_base_manager import XeroDocumentManager +from .xero_base_manager import XeroDocumentManager, XeroDocumentResponse from .xero_helpers import sanitize_for_xero logger = logging.getLogger("xero") @@ -32,7 +33,7 @@ class XeroInvoiceManager(XeroDocumentManager): def __init__( self, - client: Client, + company: Company, job: Job, staff: Staff, xero_invoice_id: str | None = None, @@ -40,15 +41,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 +132,24 @@ 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.""" + def build_payload( + self, + total_amount: Decimal | None = None, + *, + document_theme_external_id: str, + ) -> InvoicePayload: + """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,11 +157,12 @@ 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, + document_theme_external_id=document_theme_external_id, reference=( self.job.order_number if hasattr(self.job, "order_number") and self.job.order_number @@ -193,7 +200,7 @@ def create_document( self, total_amount: Decimal | None = None, billing_metadata: dict | None = None, - ): + ) -> XeroDocumentResponse: """Creates an invoice via the provider, processes result, and stores locally. Args: @@ -202,12 +209,28 @@ 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.") - payload = self.build_payload(total_amount) + document_theme_external_id = self.get_xero_sales_branding_theme_id() + if document_theme_external_id is None: + return { + "success": False, + "error": ( + "Configure the Xero sales branding theme by running Xero " + "setup or selecting it in Company Settings before " + "creating an invoice." + ), + "error_type": "configuration_error", + "status": 400, + } + + payload = self.build_payload( + total_amount, + document_theme_external_id=document_theme_external_id, + ) result = self.provider.create_invoice(payload) if not result.success: @@ -222,7 +245,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, @@ -290,11 +313,11 @@ def create_document( f"Failed to create job event for invoice creation: {exc}" ) - result_dict = { + result_dict: XeroDocumentResponse = { "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, @@ -303,23 +326,20 @@ def create_document( result_dict["messages"] = messages_list return result_dict + except AlreadyLoggedException: + raise except Exception as exc: - persist_app_error(exc) job_id = self.job.id if self.job else "Unknown" logger.exception( f"Unexpected error during invoice creation for job {job_id}" ) - return { - "success": False, - "error": f"An unexpected error occurred ({str(exc)}) while creating " - f"the invoice. Please contact support to check the data sent.", - "status": 500, - } + err = persist_app_error(exc, job_id=str(job_id)) + raise AlreadyLoggedException(exc, err.id) from exc - def delete_document(self): + def delete_document(self) -> XeroDocumentResponse: """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.") @@ -366,15 +386,12 @@ def delete_document(self): "message": "Invoice deleted successfully.", } + except AlreadyLoggedException: + raise except Exception as exc: - persist_app_error(exc) job_id = self.job.id if self.job else "Unknown" logger.exception( f"Unexpected error during invoice deletion for job {job_id}" ) - return { - "success": False, - "error": f"An unexpected error occurred ({str(exc)}) while deleting " - f"the invoice. Please contact support.", - "status": 500, - } + err = persist_app_error(exc, job_id=str(job_id)) + raise AlreadyLoggedException(exc, err.id) from exc 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 7dafe5e39..9187960e4 100644 --- a/apps/workflow/views/xero/xero_quote_manager.py +++ b/apps/workflow/views/xero/xero_quote_manager.py @@ -8,7 +8,7 @@ if TYPE_CHECKING: 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.accounting.enums import QuoteStatus @@ -17,10 +17,11 @@ from apps.accounting.models import Quote from apps.job.models.costing import CostSet from apps.workflow.accounting.types import DocumentLineItem, QuotePayload +from apps.workflow.exceptions import AlreadyLoggedException from apps.workflow.services.error_persistence import persist_app_error # Import base class and helpers -from .xero_base_manager import XeroDocumentManager +from .xero_base_manager import XeroDocumentManager, XeroDocumentResponse from .xero_helpers import sanitize_for_xero logger = logging.getLogger("xero") @@ -31,18 +32,18 @@ class XeroQuoteManager(XeroDocumentManager): Handles Quote creation and syncing via the accounting provider. """ - def __init__(self, client: "Client", job: "Job", staff: "Staff") -> None: + def __init__(self, company: "Company", job: "Job", staff: "Staff") -> None: """ - 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 ( @@ -127,8 +128,13 @@ 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.""" + def build_payload( + self, + breakdown: bool = True, + *, + document_theme_external_id: str, + ) -> QuotePayload: + """Build a provider-agnostic quote payload from the job and company.""" if not self.job: raise ValueError("Job is required to build quote payload.") @@ -136,11 +142,12 @@ 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), + document_theme_external_id=document_theme_external_id, reference=( self.job.order_number if hasattr(self.job, "order_number") and self.job.order_number @@ -148,7 +155,7 @@ def build_payload(self, breakdown: bool = True) -> QuotePayload: ), ) - def create_document(self, breakdown: bool = True): + def create_document(self, breakdown: bool = True) -> XeroDocumentResponse: """ Creates a quote via the provider, processes result, stores locally. @@ -156,12 +163,28 @@ 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.") - payload = self.build_payload(breakdown=breakdown) + document_theme_external_id = self.get_xero_sales_branding_theme_id() + if document_theme_external_id is None: + return { + "success": False, + "error": ( + "Configure the Xero sales branding theme by running Xero " + "setup or selecting it in Company Settings before " + "creating a quote." + ), + "error_type": "configuration_error", + "status": 400, + } + + payload = self.build_payload( + breakdown=breakdown, + document_theme_external_id=document_theme_external_id, + ) result = self.provider.create_quote(payload) if not result.success: @@ -176,7 +199,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, @@ -215,25 +238,22 @@ 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, } + except AlreadyLoggedException: + raise except Exception as exc: - persist_app_error(exc) job_id = self.job.id if self.job else "Unknown" logger.exception(f"Unexpected error during quote creation for job {job_id}") - return { - "success": False, - "error": f"An unexpected error occurred ({str(exc)}) while creating " - f"the quote. Please contact support.", - "status": 500, - } + err = persist_app_error(exc, job_id=str(job_id)) + raise AlreadyLoggedException(exc, err.id) from exc - def delete_document(self): + def delete_document(self) -> XeroDocumentResponse: """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.") @@ -253,10 +273,7 @@ def delete_document(self): "success": True, "xero_id": xero_id, "messages": [ - { - "level": "info", - "message": "No local quote to delete, remote operation succeeded.", - } + "No local quote to delete, remote operation succeeded." ], } @@ -293,13 +310,10 @@ def delete_document(self): "messages": ["Quote deleted successfully."], } + except AlreadyLoggedException: + raise except Exception as exc: - persist_app_error(exc) job_id = self.job.id if self.job else "Unknown" logger.exception(f"Unexpected error during quote deletion for job {job_id}") - return { - "success": False, - "error": f"An unexpected error occurred ({str(exc)}) while deleting " - f"the quote. Please contact support.", - "status": 500, - } + err = persist_app_error(exc, job_id=str(job_id)) + raise AlreadyLoggedException(exc, err.id) from exc diff --git a/apps/workflow/views/xero/xero_view.py b/apps/workflow/views/xero/xero_view.py index 282be861f..169c92f5d 100644 --- a/apps/workflow/views/xero/xero_view.py +++ b/apps/workflow/views/xero/xero_view.py @@ -41,6 +41,7 @@ from apps.job.models import Job from apps.job.permissions import IsOfficeStaff from apps.purchasing.models import PurchaseOrder +from apps.workflow.accounting.registry import get_provider from apps.workflow.api.pagination import FiftyPerPagePagination from apps.workflow.api.xero.active_app import ( NoActiveXeroApp, @@ -62,6 +63,7 @@ from apps.workflow.models import XeroError, XeroPayItem from apps.workflow.serializers import ( XeroAuthenticationErrorResponseSerializer, + XeroBrandingThemeSerializer, XeroDocumentErrorResponseSerializer, XeroDocumentSuccessResponseSerializer, XeroErrorSerializer, @@ -95,6 +97,25 @@ class XeroAuthenticationResult: status_code: int = status.HTTP_200_OK +def _xero_production_client() -> bool: + try: + active = get_active_app() + except NoActiveXeroApp: + return False + production_client_ids = { + client_id.strip().upper() for client_id in settings.PRODUCTION_XERO_CLIENT_IDS + } + return active.client_id.strip().upper() in production_client_ids + + +def _xero_ping_payload(*, connected: bool) -> dict[str, bool]: + return { + "connected": connected, + "xero_readonly": settings.XERO_READONLY, + "xero_production_client": _xero_production_client(), + } + + def _build_xero_error_payload( exc: Exception, *, @@ -396,6 +417,46 @@ def ensure_xero_authentication() -> XeroAuthenticationResult: return XeroAuthenticationResult(tenant_id=tenant_id) +@extend_schema( + operation_id="xero_branding_themes_list", + tags=["Xero"], + request=None, + responses={ + 200: XeroBrandingThemeSerializer(many=True), + 401: XeroAuthenticationErrorResponseSerializer, + 500: XeroDocumentErrorResponseSerializer, + }, + description="Lists Xero branding themes available for quotes and sales invoices.", +) +@api_view(["GET"]) +@permission_classes([IsAuthenticated, IsOfficeStaff]) +def list_xero_branding_themes(request: Request) -> Response: + """Return selectable document themes from the connected Xero organisation.""" + auth_result = ensure_xero_authentication() + if auth_result.error_data is not None: + return Response(auth_result.error_data, status=auth_result.status_code) + + try: + themes = get_provider().list_document_themes() + serialized_themes = [ + XeroBrandingThemeSerializer(theme).data for theme in themes + ] + return Response(serialized_themes, status=status.HTTP_200_OK) + except AlreadyLoggedException as exc: + return Response( + _build_xero_error_payload(exc), + status=status.HTTP_500_INTERNAL_SERVER_ERROR, + ) + except Exception as exc: + try: + persist_and_raise(exc, user_id=str(request.user.id)) + except AlreadyLoggedException as logged_exc: + return Response( + _build_xero_error_payload(logged_exc), + status=status.HTTP_500_INTERNAL_SERVER_ERROR, + ) + + @csrf_exempt @extend_schema( tags=["Xero"], @@ -448,7 +509,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, @@ -479,7 +540,11 @@ def create_xero_invoice(request: Request, job_id: uuid.UUID) -> Response: messages.error(request, str(exc)) return Response(error_data, status=status.HTTP_400_BAD_REQUEST) except AlreadyLoggedException as exc: - error_data = _build_xero_error_payload(exc) + error_data = _build_xero_error_payload( + exc, + message=f"An unexpected error occurred ({exc}) while creating " + "the invoice. Please contact support to check the data sent.", + ) messages.error(request, "An unexpected error occurred while creating invoice.") return Response(error_data, status=status.HTTP_500_INTERNAL_SERVER_ERROR) except Exception as exc: @@ -694,10 +759,10 @@ def create_xero_quote(request: Request, job_id: uuid.UUID) -> Response: try: job = Job.objects.get(id=job_id) - if job.client is None: - raise ValueError(f"Job {job_id} has no client; cannot create a quote") + if job.company is None: + raise ValueError(f"Job {job_id} has no company; cannot create a quote") assert isinstance(request.user, Staff) # IsAuthenticated guarantees Staff - 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"): @@ -717,7 +782,11 @@ def create_xero_quote(request: Request, job_id: uuid.UUID) -> Response: error_data = {"success": False, "error": f"Job with ID {job_id} not found."} return Response(error_data, status=status.HTTP_404_NOT_FOUND) except AlreadyLoggedException as exc: - error_data = _build_xero_error_payload(exc) + error_data = _build_xero_error_payload( + exc, + message=f"An unexpected error occurred ({exc}) while creating " + "the quote. Please contact support.", + ) return Response(error_data, status=status.HTTP_500_INTERNAL_SERVER_ERROR) except Exception as exc: try: @@ -770,12 +839,12 @@ 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, ) - result_data: dict = manager.delete_document() + result_data = manager.delete_document() if result_data.get("success"): messages.success(request, "Invoice deleted successfully.") @@ -800,7 +869,11 @@ def delete_xero_invoice(request: Request, job_id: uuid.UUID) -> Response: } return Response(error_data, status=status.HTTP_404_NOT_FOUND) except AlreadyLoggedException as exc: - error_data = _build_xero_error_payload(exc) + error_data = _build_xero_error_payload( + exc, + message=f"An unexpected error occurred ({exc}) while deleting " + "the invoice. Please contact support.", + ) return Response(error_data, status=status.HTTP_500_INTERNAL_SERVER_ERROR) except Exception as exc: try: @@ -831,11 +904,11 @@ def delete_xero_quote(request: Request, job_id: uuid.UUID) -> Response: try: job = Job.objects.get(id=job_id) - if job.client is None: - raise ValueError(f"Job {job_id} has no client; cannot delete its quote") + if job.company is None: + raise ValueError(f"Job {job_id} has no company; cannot delete its quote") assert isinstance(request.user, Staff) # IsAuthenticated guarantees Staff - manager = XeroQuoteManager(client=job.client, job=job, staff=request.user) - result_data: dict = manager.delete_document() + manager = XeroQuoteManager(company=job.company, job=job, staff=request.user) + result_data = manager.delete_document() if result_data.get("success"): messages.success(request, "Quote deleted successfully.") @@ -856,7 +929,11 @@ def delete_xero_quote(request: Request, job_id: uuid.UUID) -> Response: return Response(error_data, status=status.HTTP_404_NOT_FOUND) except AlreadyLoggedException as exc: logger.exception(f"Error in delete_xero_quote view for job {job_id}") - error_data = _build_xero_error_payload(exc) + error_data = _build_xero_error_payload( + exc, + message=f"An unexpected error occurred ({exc}) while deleting " + "the quote. Please contact support.", + ) messages.error(request, "An unexpected error occurred while deleting quote.") return Response(error_data, status=status.HTTP_500_INTERNAL_SERVER_ERROR) except Exception as exc: @@ -937,6 +1014,7 @@ def delete_xero_purchase_order( error_data = _build_xero_error_payload(logged_exc) messages.error(request, f"An unexpected error occurred: {str(exc)}") return Response(error_data, status=status.HTTP_500_INTERNAL_SERVER_ERROR) + raise AssertionError("persist_and_raise returned without raising") from exc @extend_schema( @@ -960,13 +1038,13 @@ def xero_disconnect(request): except NoActiveXeroApp: logger.info("xero_disconnect: no active XeroApp; nothing to do") return Response( - {"connected": False, "xero_readonly": settings.XERO_READONLY}, + _xero_ping_payload(connected=False), status=status.HTTP_200_OK, ) wipe_tokens_and_quota(active) logger.info(f"Disconnected XeroApp {active.id} ({active.label})") return Response( - {"connected": False, "xero_readonly": settings.XERO_READONLY}, + _xero_ping_payload(connected=False), status=status.HTTP_200_OK, ) except AlreadyLoggedException: @@ -1159,7 +1237,7 @@ def xero_ping(request): is_connected = bool(token) logger.info(f"Xero ping: connected={is_connected}") return Response( - {"connected": is_connected, "xero_readonly": settings.XERO_READONLY}, + _xero_ping_payload(connected=is_connected), status=status.HTTP_200_OK, ) except AlreadyLoggedException as exc: diff --git a/docketworks/settings.py b/docketworks/settings.py index 4fb65fb9e..4804c4d3e 100644 --- a/docketworks/settings.py +++ b/docketworks/settings.py @@ -188,7 +188,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", @@ -338,8 +338,7 @@ def require_positive_int_env(name: str) -> int: "VERSION": "1.0.0", "SERVE_INCLUDE_SCHEMA": False, # Split request/response schemas so code generators don't require readOnly fields - # on request bodies. This creates e.g. ClientContact (response) and - # ClientContactRequest (request) schemas automatically. + # on request bodies. This creates separate response/request schemas automatically. "COMPONENT_SPLIT_REQUEST": True, # ENUM_NAME_OVERRIDES: key = desired schema name, value = choices tuple. # Resolves collisions where multiple models share a field name (e.g. "kind", @@ -359,6 +358,16 @@ def require_positive_int_env(name: str) -> int: ("quote", "Quote"), ("actual", "Actual"), ), + "ContactMethodTypeEnum": ( + ("phone", "Phone"), + ("email", "Email"), + ), + "PhoneOwnershipStatusEnum": ( + ("available", "available"), + ("people", "people"), + ("company", "company"), + ("internal", "internal"), + ), "InvoiceStatusEnum": ( ("DRAFT", "Draft"), ("SUBMITTED", "Submitted"), @@ -416,7 +425,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"), @@ -828,6 +837,9 @@ def require_positive_int_env(name: str) -> int: # Hardcoded production Xero tenant ID PRODUCTION_XERO_TENANT_ID = "75e57cfd-302d-4f84-8734-8aae354e76a7" +# Hardcoded production Xero app client IDs. +PRODUCTION_XERO_CLIENT_IDS = ["DB22E7201251487F83D98B130946DAC1"] + # Hardcoded production machine ID PRODUCTION_MACHINE_ID = "19d6339c35f7416b9f41d9a35dba6111" diff --git a/docketworks/urls.py b/docketworks/urls.py index 382e28118..9da2af4d9 100644 --- a/docketworks/urls.py +++ b/docketworks/urls.py @@ -11,7 +11,8 @@ 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/people/", include("apps.company.urls_people_rest", namespace="people")), 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/adr/0001-exception-already-logged-dedup.md b/docs/adr/0001-exception-already-logged-dedup.md index 11a177337..5c33ce188 100644 --- a/docs/adr/0001-exception-already-logged-dedup.md +++ b/docs/adr/0001-exception-already-logged-dedup.md @@ -10,6 +10,8 @@ Exceptions travel integration → service → view → scheduler. Every layer ha `AlreadyLoggedException` (in `apps/workflow/exceptions.py`) wraps the original exception plus the persisted `AppError.id`. Every handler is two-arm: re-raise `AlreadyLoggedException` unchanged; otherwise persist once, wrap, re-raise. `persist_app_error()` returns the `AppError` instance so callers can carry the id forward. +The chain terminates at the HTTP boundary: the outermost view handler catches `AlreadyLoggedException` and converts it to a response (carrying `error_id`, per ADR 0013) instead of re-raising. Service objects invoked by views always re-raise; they never shape responses. A service object returns a failure value only for an *expected* business outcome, never for an unexpected exception. + ## Why A marker exception is in-band — it works identically in views, services, schedulers, and management commands, so every handler in the codebase follows the same two-arm template. Reviewers and new handlers have one rule to remember. The id carries forward so an outer handler can correlate without re-querying. diff --git a/docs/adr/0028-type-annotations-are-data-contracts.md b/docs/adr/0028-type-annotations-are-data-contracts.md index 895d7e910..8a1152352 100644 --- a/docs/adr/0028-type-annotations-are-data-contracts.md +++ b/docs/adr/0028-type-annotations-are-data-contracts.md @@ -8,7 +8,7 @@ The app relies on strict data contracts to stay bug-free. A type shortcut can we ## Decision -When touching Python code, improve its mypy state by preserving or tightening the real contract. Do not add `Any`, broad `object`, fake optionality, broad unions, casts, or ignores to avoid local typing work. `Any` and `object` are allowed only at unavoidable external or dynamic boundaries, and must be immediately validated or converted into a typed shape. `T | None` is allowed only when `None` is genuinely valid and intentionally handled. If a type becomes complex enough to hide domain meaning, introduce a named type: dataclass for internal domain values, `TypedDict` for dict-shaped payloads, protocol for behaviour, or a simple alias for readable composition. +When touching Python code, improve its mypy state by preserving or tightening the real contract. Do not add `Any`, containers of `Any`, broad `object`, fake optionality, broad unions, casts, or ignores to avoid local typing work. `Any` and `object` are allowed only at unavoidable external or dynamic boundaries, and must be immediately validated or converted into a typed shape. `T | None` is allowed only when `None` is genuinely valid and intentionally handled. If a type becomes complex enough to hide domain meaning, introduce a named type: dataclass for internal domain values, `TypedDict` for dict-shaped payloads, protocol for behaviour, or a simple alias for readable composition. Contract discipline applies to control flow as well as annotations. Fallback-style `dict.get()`, `hasattr()` probes, and happy-case-first branching are not banned, but they are smells in application code because they often mean the contract is unclear. Use them at genuine dynamic boundaries only, then validate or convert into a named shape. Once code indexes, mutates, or reads attributes from a value, give that value a real type. For required data, prefer direct access after validation so malformed input fails loudly instead of becoming a default. @@ -18,7 +18,7 @@ Types are executable documentation for the data model. A helper typed as accepti Readable named types also make review possible. `dict[str, list[tuple[str, list[tuple[str, int, float]]]]]` forces readers to decode positional meaning. `OrdersByCustomer = dict[str, list[Order]]`, with `Order` and `OrderLine` named, states the model directly and gives mypy stable structure to enforce. -Small code-shape choices carry the same signal. Use `payload["job_id"]` after serializer or schema validation instead of `payload.get("job_id", "")`. Introduce `JobPayload` as a `TypedDict` instead of passing `dict[str, object]` once callers read keys from it. Load the required annotation or relation before rendering instead of probing with `hasattr()`. Check `if missing_required_value: raise ...` before the main path instead of nesting the whole function under `if value:`. +Small code-shape choices carry the same signal. Use `payload["job_id"]` after serializer or schema validation instead of `payload.get("job_id", "")`. Introduce `JobPayload` as a `TypedDict` instead of passing `dict[str, object]` or `dict[str, Any]` once callers read keys from it. Load the required annotation or relation before rendering instead of probing with `hasattr()`. Check `if missing_required_value: raise ...` before the main path instead of nesting the whole function under `if value:`. ## Alternatives considered diff --git a/docs/adr/0030-first-class-people-and-company-links.md b/docs/adr/0030-first-class-people-and-company-links.md new file mode 100644 index 000000000..0a8c44d85 --- /dev/null +++ b/docs/adr/0030-first-class-people-and-company-links.md @@ -0,0 +1,40 @@ +# 0030: First-class People and Company links + +## Status + +Accepted + +## Context + +The CRM originally treated a company contact as a row owned by one company. That +matched Xero's contact-person payload but made humans second-class: jobs, calls, +and phone numbers pointed at a company-specific contact shape instead of the +person. It also mixed two different concepts in one row: identity +(`name`, `email`) and relationship-at-company (`position`, `is_primary`, notes, +Xero import key). + +## Decision + +People are first-class records. `Person` owns human identity and person-owned +contact methods. `CompanyPersonLink` owns relationship-at-company data. + +Jobs and phone call records point to `Person`. Company contact APIs expose link +rows with embedded person identity fields. Contact methods are owned by exactly +one `Company` or one `Person`. Phone sharing is allowed only when all owners +trace to at least one common company; unchanged legacy rows are grandfathered +until edited. + +Xero company `contact_id` and `xero_contact_id` terminology remains unchanged +because those are external Xero identifiers, not CRM people. + +## Consequences + +- A person may have links to multiple companies; deduplicating equivalent people + remains a separate data-quality task. +- DocketWorks owns Person identity. Xero company contact-person payloads do not + create, reactivate, or update Person rows, and Person identity is not written + back to Xero. +- Company merge moves company-owned contact methods, company links, jobs, and + call company ownership. It does not move person-owned contact methods. +- API callers use `person_id` and `person_name` for jobs, calls, Kanban, and + search. Legacy `contact_id` survives only where it refers to Xero. diff --git a/docs/adr/README.md b/docs/adr/README.md index 5cce90040..936b02040 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -39,3 +39,4 @@ See [`_template.md`](_template.md). Copy, renumber, fill in. | 0027 | A capability deploys with the means to operate it | | 0028 | Type annotations are data contracts | | 0029 | Servers run the production branch | +| 0030 | First-class People and Company links | 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/client_onboarding.md b/docs/client_onboarding.md index 400fb4543..5ed2eee3c 100644 --- a/docs/client_onboarding.md +++ b/docs/client_onboarding.md @@ -89,6 +89,14 @@ The client needs a Xero subscription. DocketWorks handles jobs and delegates inv - Create a contact named "[Company Name] Shop" (e.g., "Morris Sheetmetal Shop") - Used for leave, admin time, training, etc. +**Sales Branding Theme** (Settings > Invoice settings): +- Ensure one branding theme contains the terms and conditions required on both + quotes and invoices +- Prefer making that theme first in Xero's branding-theme order; DocketWorks + imports the first theme during `xero --setup` +- If another theme must remain the Xero default, select the terms-bearing theme + later in DocketWorks Company Settings + ### 2b. You create the Xero Developer App 1. Go to https://developer.xero.com/app/manage @@ -238,6 +246,10 @@ Once the instance is running: python manage.py start_xero_sync ``` +`xero --setup` imports the first sales branding theme returned in the connected +organisation's Xero order. It preserves a previously selected theme when that +theme still exists in the connected organisation. + ### 7b. Company Settings In Admin > Settings, configure: @@ -249,6 +261,8 @@ In Admin > Settings, configure: - Starting job/PO numbers and PO prefix - Google Drive folder IDs (Shared Drive, How We Work, SOPs, Reference Library) - Quote template ID and quotes folder ID (if applicable) +- Xero sales branding theme (confirm the selected theme contains the required + quote and invoice terms) - KPI thresholds (optional, can be tuned later) ### 7c. Create Shop Jobs diff --git a/docs/instance-setup-demo.md b/docs/instance-setup-demo.md index c78ba5b9e..4affd4dc6 100644 --- a/docs/instance-setup-demo.md +++ b/docs/instance-setup-demo.md @@ -86,6 +86,10 @@ Upload logos: Admin > Settings > Company > Logo and Logo Wide. scripts/server/dw-run.sh -uat python manage.py xero --setup ``` +`xero --setup` stores the first sales branding theme in the Demo Company's Xero +order. In Admin > Settings, change it to a terms-bearing theme if the selected +theme is not the one the prospect should see. + **Note:** `xero --setup` creates the "Weekly Testing" payroll calendar in the Demo Company if it's missing (a weekly calendar anchored to a **Monday** — Docketworks payroll posting requires Mon→Sun periods). If you ever create one by hand instead (Payroll > Settings > Payroll Calendars), its period **must start on a Monday**; `xero --setup` fails loudly if the calendar it just created came back on any other day. ## Step 6: Sync Xero Data @@ -129,6 +133,7 @@ scripts/server/dw-run.sh -uat python manage.py start_xero_sync - [ ] Shop jobs visible on Kanban board - [ ] Admin > Xero shows "Connected" - [ ] Can create a new job and add time/materials +- [ ] A DocketWorks quote and invoice use the selected Xero branding theme - [ ] Create a test timesheet entry ## Login Credentials diff --git a/docs/instance-setup-production.md b/docs/instance-setup-production.md index 9f347a20f..12f1f7e64 100644 --- a/docs/instance-setup-production.md +++ b/docs/instance-setup-production.md @@ -64,7 +64,9 @@ existence check and doesn't read tokens.) scripts/server/dw-run.sh -prod python manage.py xero --setup ``` -Sets xero_tenant_id, xero_shortcode, and xero_payroll_calendar_id. +Sets `xero_tenant_id`, `xero_shortcode`, `xero_payroll_calendar_id`, and the +first sales branding theme in the connected organisation's Xero order. A valid +existing custom theme selection is preserved. **Requires:** The payroll calendar must already exist in Xero (created during client onboarding Phase 2a). @@ -79,6 +81,8 @@ In Admin > Settings, set all values collected in Phase 1 of client_onboarding.md - Shop client name (must match the Xero contact from Phase 2a) - Google Drive folder IDs (Shared Drive, How We Work, SOPs, Reference Library) - Quote template ID and quotes folder ID (if applicable) +- Xero sales branding theme — select the terms-bearing theme if it is not the + first theme imported by `xero --setup` Upload logos: Admin > Settings > Company > Logo and Logo Wide. @@ -149,6 +153,8 @@ scripts/server/dw-run.sh -prod python manage.py start_xero_sync - [ ] Shop jobs visible on Kanban board - [ ] Create a test timesheet entry - [ ] Admin > Xero shows "Connected" status +- [ ] A quote created from DocketWorks shows the required terms in its Xero PDF +- [ ] An invoice created from DocketWorks shows the required terms in its Xero PDF - [ ] Password reset email works (test with a staff member) ## Post-Setup diff --git a/docs/plans/_template.md b/docs/plans/_template.md new file mode 100644 index 000000000..827f637a9 --- /dev/null +++ b/docs/plans/_template.md @@ -0,0 +1,31 @@ +# Title + +## Context + +Why this change is being made — the problem or need it addresses, what prompted it, and the intended outcome. + +## Feature Parity Inventory + +**Required for any work that replaces or rewrites an existing component, page, model, or endpoint. Delete this +section only if the change adds net-new behaviour that replaces nothing.** + +Enumerate every capability the OLD version exposed (buttons, actions, fields, shortcuts, edge cases — sourced +from its template/emits, its tests, E2E specs, ADRs). One row per capability. Default decision is **keep**; +**drop** requires explicit user sign-off; **defer** requires a ticket. + +| Old capability | New location | Status (keep / drop+signoff / defer) | +|---|---|---| +| … | … | … | + +## Approach + +The recommended implementation. Name the critical files to be modified. For a pattern repeated across many +files, describe it once with a few representative paths. + +## Files to modify + +- … + +## Verification + +How to test end-to-end: run the code / drive the real flow, plus the tests to run. diff --git a/docs/restore-prod-to-hotfix.md b/docs/restore-prod-to-hotfix.md index 1b53c4b2d..f2f205e62 100644 --- a/docs/restore-prod-to-hotfix.md +++ b/docs/restore-prod-to-hotfix.md @@ -1,6 +1,6 @@ # Restore Production to the Hotfix Checkout -This checkout (`~/src/docketworks_prod`) is the MSM **hotfix environment**: its +This checkout (`~/src/docketworks_hotfix`) is the MSM **hotfix environment**: its database is refreshed by restoring the production DB into it, it is served via the `docketworks-msm-hotfix` ngrok domain, and the E2E suite runs here to verify prod hotfixes. @@ -10,6 +10,21 @@ Xero org) is [restore-prod-to-nonprod.md](restore-prod-to-nonprod.md). A hotfix restore keeps real production data; the safety concern is different: the copy must never **act on** production's external systems. +## Database role + +This checkout connects as the **`dw_msm_prod`** role (`.env` `DB_USER`), matching the +owner recorded in every production dump. Keeping the role name identical to production +means prod dumps restore **verbatim** — no ownership rewriting — and the app operates as +the table owner exactly as production does. One-time setup (needs superuser): + +```bash +sudo -u postgres psql \ + -c "CREATE ROLE dw_msm_prod LOGIN CREATEDB PASSWORD '';" +``` + +The DB is owned by this role, so re-restores (drop + recreate `docketworks_prod`) need no +further superuser access. + ## Mandatory steps when restoring production into this checkout 1. **Back up the production DB.** Take and retain a fresh backup of production @@ -28,17 +43,21 @@ must never **act on** production's external systems. 4. **Restore production-owned files referenced by the DB.** The DB restore brings file paths, not the files. Copy the mutable production instance - directories into this checkout before testing anything that renders files. - This includes at least `mediafiles/`, `phone-recordings/`, and - `session-replays/`. - - Source: `/opt/docketworks/instances/msm-prod/` on MSM. Targets: - `MEDIA_ROOT=/home/corrin/src/docketworks_prod/mediafiles`, - `PHONE_RECORDING_STORAGE_ROOT=/home/corrin/src/docketworks_prod/.local/phone-recordings`, - and - `SESSION_REPLAY_STORAGE_ROOT=/home/corrin/src/docketworks_prod/.local/session-replays`. - Do not point any of these at `~/src/docketworks`. Verify representative - DB-backed files, especially logos and phone recordings, before running E2E. + directories into this checkout before testing anything that renders files: + `mediafiles/`, `phone-recordings/`, and `session-replays/`. + + Run `scripts/pull_prod_files.sh` (defaults to `MSM dw_msm_prod`). It + incrementally rsyncs those three dirs from the instance user's home + (`/opt/docketworks/instances/msm-prod/` on MSM) into the local storage roots + read from `.env` — `MEDIA_ROOT`, `PHONE_RECORDING_STORAGE_ROOT`, and + `SESSION_REPLAY_STORAGE_ROOT`. The files are instance-user-owned, so the + remote rsync escalates via `sudo -iu dw_msm_prod`. Re-runs copy only + new/changed files, so it is cheap to run after every DB restore. + + Keep those `.env` roots inside this checkout (`.local/...` and + `mediafiles/`); never point them at `~/src/docketworks`. Verify + representative DB-backed files, especially logos and phone recordings, + before running E2E. 5. **Run the hotfix processes with `XERO_READONLY=True`** so that even a reconnected Xero cannot write to MSM's real organisation. This is diff --git a/docs/restore-prod-to-nonprod.md b/docs/restore-prod-to-nonprod.md index 1d3a7b62d..bb287b033 100644 --- a/docs/restore-prod-to-nonprod.md +++ b/docs/restore-prod-to-nonprod.md @@ -21,6 +21,19 @@ Sections must run in the order written. The Connect to Xero OAuth section is a h ```bash scp prod-server:/path/to/docketworks/restore/scrubbed__.dump restore/ ``` +- The fetched archive must pass the credential and migration-ledger verifier: + ```bash + python scripts/verify_scrubbed_backup.py \ + --allow-legacy-client-baseline \ + restore/scrubbed__.dump + ``` + This fails if the archive is unreadable, predates the July migration squash, + or contains DB-backed external-system credentials. Do not restore a failing + archive. +- **TEMPORARY KAN-278:** `--allow-legacy-client-baseline` exists only while + production still uses the pre-cutover `client` app label. Remove this flag and + the pre-migration cutover sections below after every production instance has + migrated and produced a verified company-schema backup. - The dump must be from a prod release at or after the July 2026 migration squash (baseline `*_baseline` migrations). Older dumps carry a `django_migrations` ledger the current graph cannot migrate — restore those under a matching pre-squash checkout instead (see `docs/updating.md`). - Celery Beat stopped. Beat ticks against the DB and Xero on a timer; if it fires during the reset/restore it will block `DROP SCHEMA` or race `seed_xero_from_database`. Stop it before Reset Database; the Celery Beat section restarts it. The worker can stay running — it has nothing to do without Beat dispatches. - Dev: kill the `Celery Beat` task in its VS Code terminal. @@ -40,10 +53,12 @@ python -c "import django; print(f'django {django.__version__} from {django.__fil **Check `.env` is loaded:** ```bash -grep -E "^(DB_NAME|DB_USER|DB_PASSWORD)=" .env +grep -E "^(DB_NAME|DB_USER|DB_HOST)=" .env +grep -q '^DB_PASSWORD=.' .env && echo "DB_PASSWORD is set" export DB_PASSWORD=$(grep DB_PASSWORD .env | cut -d= -f2) export DB_NAME=$(grep DB_NAME .env | cut -d= -f2) export DB_USER=$(grep DB_USER .env | cut -d= -f2) +export DB_HOST=$(grep DB_HOST .env | cut -d= -f2) ``` **Must show:** @@ -51,7 +66,8 @@ export DB_USER=$(grep DB_USER .env | cut -d= -f2) ``` DB_NAME=dw__ DB_USER=dw__ -DB_PASSWORD=your_password +DB_HOST=/var/run/postgresql +DB_PASSWORD is set ``` Note: If you're using Claude or similar, you need to specify these explicitly on all subsequent command lines rather than use environment variables. @@ -78,48 +94,41 @@ The scrubbed dump carries schema, data, and `django_migrations` together. `pg_re ```bash PGPASSWORD="$DB_PASSWORD" pg_restore --no-owner --no-privileges --exit-on-error \ -h "$DB_HOST" -U "$DB_USER" -d "$DB_NAME" \ - ./restore/scrubbed__.dump + ./restore/scrubbed__.dump ``` -**Check:** +#### Capture Pre-Migration State + +**TEMPORARY KAN-278 CUTOVER STEP:** Remove this section after every production +instance has completed the client-to-company migration and produced a verified +company-schema backup. + +The restored dump may use the schema of the production release while the +checkout contains newer models. Do not run current ORM code against that old +schema. Capture count-only evidence through raw SQL instead: ```bash -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 'job_costset', COUNT(*) FROM job_costset -UNION ALL SELECT 'job_costline', COUNT(*) FROM job_costline; -" +python scripts/restore_checks/capture_pre_migration_state.py ``` -**Expected output (approximate):** +This requires the pre-KAN-278 `client_*` schema, verifies the squashed migration +ledger, and writes only aggregate counts to +`restore/pre_migration_state.json`. A missing table, unexpected new-schema +table, or empty production dataset is a hard stop. -``` - table_name | count -----------------+------- - job_job | 1054 - accounts_staff | 20 - client_client | 3739 - job_costset | 3162 - job_costline | 10334 -``` +#### Relabel the Legacy Client App -Then smoke-test the Django ORM against the restored data — fail fast here rather than letting a broken ORM surface later in the validator loop: +**TEMPORARY KAN-278 CUTOVER STEP:** The restored ledger and tables still use the +legacy `client` app label. Apply the same one-time, idempotent surgery that the +deployment workflow runs before Django migrations: ```bash -python scripts/restore_checks/check_django_orm.py +python manage.py relabel_client_app ``` -**Expected output:** - -``` -Jobs: ~1400 -Staff: ~22 -Clients: ~4800 -Sample job: [any real job name] (#XXXXX) -Contact: [any real contact name] -``` +This must complete successfully before `migrate`. It keeps the squashed +baseline, removes obsolete pre-squash ledger rows, and changes the app label and +table prefixes without creating a second baseline. #### Apply Django Migrations @@ -132,10 +141,21 @@ python manage.py migrate **Check:** ```bash +python scripts/restore_checks/check_post_migration_state.py +python scripts/restore_checks/check_django_orm.py python manage.py showmigrations | grep '\[ \]' # Expect no output. ``` +The post-migration check compares against the captured counts and verifies the +client→company table cutover, Person/link ownership, job and call references, +merge structure, and persisted terminology. Any mismatch is a migration +failure; do not continue. + +The branding-theme migration deliberately skips a scrubbed restore because its +Xero OAuth tokens have been removed. The destination theme is populated later +by the required `xero --setup` step after destination OAuth is connected. + #### Load Company Defaults Fixture For demo restores only, this replaces your real company name and logos with the @@ -147,11 +167,24 @@ instead of the shared repo fixture. python manage.py loaddata apps/workflow/fixtures/company_defaults.json ``` -#### Reload Private Instance Config +#### Reload Private Configuration + +The scrubbed archive contains no DB-backed external-system configuration. +Restore only configuration owned by this non-production target. + +**Local dev:** load the ignored local AI and Xero fixtures. Phone-provider +configuration intentionally remains absent so local Celery cannot contact the +production phone system. + +```bash +python manage.py loaddata apps/workflow/fixtures/ai_providers.json +python manage.py loaddata apps/workflow/fixtures/xero_apps.json +python scripts/restore_checks/check_xero_app.py +python manage.py shell -c "from apps.crm.models import PhoneProviderSettings; assert not PhoneProviderSettings.objects.exists(), 'phone provider must be unconfigured on local dev'" +``` -The DB reset wiped private DB-backed config. Re-run instance reconfiguration to -regenerate and load the per-instance private fixtures for AI providers, Xero app -credentials, and phone-provider settings from the root-owned credentials file: +**Server instance:** regenerate and load the instance-owned private fixtures +from the root-owned credentials file: ```bash sudo scripts/server/instance.sh reconfigure @@ -175,21 +208,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 @@ -212,7 +245,10 @@ python manage.py xero --setup Configures all required Xero settings in CompanyDefaults: 1. Sets `xero_tenant_id` from connected organisation 2. Sets `xero_shortcode` for deep linking -3. Looks up payroll calendar by name and sets `xero_payroll_calendar_id` +3. Preserves a live sales branding theme selection or replaces a restored, + cross-tenant ID with the first theme in the destination organisation's Xero + order +4. Looks up payroll calendar by name and sets `xero_payroll_calendar_id` **Expected output:** @@ -220,6 +256,7 @@ Configures all required Xero settings in CompanyDefaults: Using organisation: [Tenant Name] Tenant ID: [tenant-id-uuid] Shortcode: [shortcode] +Sales Branding Theme: [theme name] ([theme-uuid]) Payroll Calendar: Weekly Testing ([calendar-uuid]) Xero setup complete. ``` @@ -246,10 +283,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 +334,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. @@ -321,6 +358,10 @@ python scripts/restore_checks/test_kanban_api.py API working: 174 active jobs, 23 archived ``` +Open one recreated quote and one recreated invoice in Xero and confirm their +PDFs use the selected destination branding theme and contain the required +terms. A successful API seed alone does not verify document presentation. + #### Snapshot Verified Database The DB is now in a known-good state: loaded from prod, migrated, fixtures applied, Xero synced, validators and smoke tests green. Capture this state as a baseline so the Playwright run — or any later test run — can be recovered from it without re-running this entire runbook. @@ -350,17 +391,25 @@ ls -lh backups/post_restore_*.sql.gz | tail -1 #### Run Playwright Tests +Before E2E, restart the backend, Celery worker, and Celery Beat with +`XERO_READONLY=True`. The user starts these long-running services; the agent +does not. All three processes must use the flag because it is process-scoped. + ```bash -cd frontend && npx playwright test +cd frontend +PATH="$PWD/../.venv/bin:$PATH" npm run test:e2e ``` -**Expected:** All tests pass. +Run in the foreground without a shell timeout or SIGTERM. Global teardown must +finish so it can restore the database, preserve/reinject the Xero token, remove +the lock, and run integrity checks. **Expected:** all tests and teardown pass. ## Cleanup -```bash -rm -rf restore/ -``` +Retain the source dump, `restore/pre_migration_state.json`, and the verified +post-restore snapshot through E2E and release verification. After explicit +operator approval, remove only the named source dump. Never recursively remove +`restore/`; it also contains E2E recovery artifacts. ## Troubleshooting @@ -389,8 +438,9 @@ gunzip -c "$LATEST" | PGPASSWORD="$DB_PASSWORD" psql \ ## File Locations -- **Scrubbed dump (consumer-side):** `restore/scrubbed__.dump` +- **Scrubbed dump (consumer-side):** `restore/scrubbed__.dump` - **Scrubbed dump (producer-side, on prod):** `/restore/scrubbed__.dump` +- **Pre-migration count artifact:** `restore/pre_migration_state.json` - **Baseline snapshot:** `backups/post_restore_.sql.gz` ## First-time setup (existing instances only) diff --git a/docs/server_setup.md b/docs/server_setup.md index 52f2f8ae3..fe4b13fef 100644 --- a/docs/server_setup.md +++ b/docs/server_setup.md @@ -213,6 +213,15 @@ For a prospect trying DocketWorks with their own Xero: scripts/server/dw-run.sh python manage.py start_xero_sync --entity accounts ``` + `xero --setup` stores the first sales branding theme in the connected + organisation's Xero order. In Admin > Settings, select the terms-bearing + theme if the organisation uses a different theme for customer documents. + + Existing connected installations do not need to rerun setup when they + receive the branding-theme migration: the migration selects and stores the + first live Xero theme before services restart. If Xero is unavailable, the + migration fails and the deployment must be retried. + 5. **Import staff from Xero** ```bash @@ -226,7 +235,9 @@ For a prospect trying DocketWorks with their own Xero: This pulls employees from Xero Payroll and creates Staff records with their wage rates and working hours. All imported staff get `password_needs_reset=True`. -6. **Verify** — log in as admin, check Staff list, mark office staff via admin UI +6. **Verify** — log in as admin, check Staff list, mark office staff via admin + UI, then create a quote and invoice and confirm their Xero PDFs contain the + required terms --- diff --git a/docs/superpowers/plans/2026-07-16-person-archive.md b/docs/superpowers/plans/2026-07-16-person-archive.md new file mode 100644 index 000000000..9f95391aa --- /dev/null +++ b/docs/superpowers/plans/2026-07-16-person-archive.md @@ -0,0 +1,717 @@ +# Person Archive Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Let users retire departed people so they drop out of everyday views (directory search, person selectors) without hard-deleting, reversibly. + +**Architecture:** `Person.is_active` is the retirement flag. Removing a person's last active company link archives them; adding/restoring any link un-archives them. Archived people stay viewable so the CRM restore-link lifecycle keeps working. An explicit PersonDetail "Archive person" action and a directory "Show archived" filter round it out. Backend-first (5 tasks), then regen the client and do the frontend (4 tasks). + +**Tech Stack:** Django REST Framework + DRF-spectacular (backend), Vue 3 + TS + zodios generated client + Playwright/vitest (frontend). + +## Global Constraints + +- Every exception handler persists once via `persist_app_error(exc)` and re-raises through the `AlreadyLoggedException` two-arm pattern (ADR 0019/0001). Match surrounding service style. +- Backend authoritative type check: `bash scripts/check_mypy.sh`. New code must be fully type-clean; no `Any`/casts/ignores as shortcuts (ADR 0028). +- Frontend: generated `api` client only (`@/api/client`); immutable state updates; ` 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..4523f80e4 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/ContactSelector.vue b/frontend/src/components/ContactSelector.vue deleted file mode 100644 index 318d63eb9..000000000 --- a/frontend/src/components/ContactSelector.vue +++ /dev/null @@ -1,359 +0,0 @@ - - - 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 73e27c47d..8660db70b 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 @@
-
-
-