from __future__ import annotations import hashlib from collections.abc import Iterable, Mapping from dataclasses import asdict, dataclass from typing import Protocol from .netsuite_revenue_candidate import CandidateRow, CandidateStore class AdmissionError(ValueError): pass class AccountLookup(Protocol): def find_active_accounts(self, account_ids: list[str]) -> Mapping[str, list[str]]: ... @dataclass(frozen=True) class AdmittedRow: candidate: CandidateRow crm_account_id: str @dataclass(frozen=True) class AdmissionManifest: source_total: int admitted_exact_match: int excluded_blank_account_id: int excluded_missing_account: int excluded_ambiguous_account: int admitted_hash: str excluded_hashes: dict[str, str] def admit_candidates( rows: Iterable[CandidateRow], *, account_lookup: AccountLookup, store: CandidateStore, artifact_prefix: str = "admission", ) -> tuple[list[AdmittedRow], AdmissionManifest]: candidate_rows = list(rows) if not candidate_rows: raise AdmissionError("cannot admit an empty source candidate") if any(not row.admission_evidence_available for row in candidate_rows): raise AdmissionError("candidate lacks RESTlet raw/display Account ID evidence; fixture cannot be admitted") blank = [row for row in candidate_rows if row.account_id is None] account_ids = sorted({row.account_id for row in candidate_rows if row.account_id is not None}) matches = account_lookup.find_active_accounts(account_ids) admitted: list[AdmittedRow] = [] missing: list[CandidateRow] = [] ambiguous: list[CandidateRow] = [] for row in candidate_rows: account_id = row.account_id if account_id is None: continue found = list(matches.get(account_id, [])) if len(found) == 1 and found[0]: admitted.append(AdmittedRow(candidate=row, crm_account_id=found[0])) elif not found: missing.append(row) else: ambiguous.append(row) if len(candidate_rows) != len(admitted) + len(blank) + len(missing) + len(ambiguous): raise AssertionError("admission accounting invariant failed") rejection_rows = [ *(_rejection(row, "Blank") for row in blank), *(_rejection(row, "Missing") for row in missing), *(_rejection(row, "Ambiguous") for row in ambiguous), ] store.write_jsonl(f"{artifact_prefix}/admitted-candidate.jsonl", (admitted_row_to_dict(row) for row in admitted)) store.write_jsonl(f"{artifact_prefix}/rejection-ledger.jsonl", rejection_rows) manifest = AdmissionManifest( source_total=len(candidate_rows), admitted_exact_match=len(admitted), excluded_blank_account_id=len(blank), excluded_missing_account=len(missing), excluded_ambiguous_account=len(ambiguous), admitted_hash=_admitted_hash(admitted), excluded_hashes={ "blank": _rows_hash(blank), "missing": _rows_hash(missing), "ambiguous": _rows_hash(ambiguous), }, ) store.write_json(f"{artifact_prefix}/admission-manifest.json", asdict(manifest)) return admitted, manifest def admitted_row_to_dict(row: AdmittedRow) -> dict[str, object]: return {"candidate": asdict(row.candidate), "crm_account_id": row.crm_account_id} def admitted_rows_from_dicts(rows: Iterable[Mapping[str, object]]) -> list[AdmittedRow]: from .netsuite_revenue_candidate import candidate_rows_from_dicts admitted: list[AdmittedRow] = [] for row in rows: candidate_value = row.get("candidate") if not isinstance(candidate_value, Mapping): raise AdmissionError("admitted candidate row is invalid") candidate = candidate_rows_from_dicts([candidate_value])[0] crm_account_id = str(row.get("crm_account_id") or "").strip() if not crm_account_id: raise AdmissionError("admitted candidate has a blank CRM Account ID") admitted.append(AdmittedRow(candidate=candidate, crm_account_id=crm_account_id)) return admitted def _rejection(row: CandidateRow, reason: str) -> dict[str, str]: # This append-only ledger is stored under the restricted candidate prefix. # Console output uses only the manifest counts and hashes, never this payload. return {"source_key": row.source_key, "payload_hash": row.payload_hash, "reason": reason} def _rows_hash(rows: Iterable[CandidateRow]) -> str: digest = hashlib.sha256() for row in sorted(rows, key=lambda item: item.source_key): digest.update(row.source_key.encode()) digest.update(b"\0") digest.update(row.payload_hash.encode()) digest.update(b"\n") return digest.hexdigest() def _admitted_hash(rows: Iterable[AdmittedRow]) -> str: digest = hashlib.sha256() for row in sorted(rows, key=lambda item: item.candidate.source_key): digest.update(row.candidate.source_key.encode()) digest.update(b"\0") digest.update(row.candidate.payload_hash.encode()) digest.update(b"\0") digest.update(row.crm_account_id.encode()) digest.update(b"\n") return digest.hexdigest()