""" Top-level invoice processing pipeline. Flow: validation → routing → matching → final result. No business logic lives here. Each step delegates entirely to its own module: - Validation : Invoice Validation Functions/orchestrator.py - Routing : match_router.py - 2-way match : two_way_match.py - 3-way match : three_way_match.py (layers on top of 2-way result) - Contract : contract_match.py Caller responsibilities (not enforced here): - Acquire the advisory lock before calling (SDD §3.1). - Pre-fetch po, po_lines, gr_rows from the database. - Construct matching_invoice (InvoiceHeader) and matching_inv_lines. - Construct contract_invoice (ContractInvoiceHeader) when contract match is possible. - Handle prior-match supersession in the database after this call returns (SDD §3.5). """ from __future__ import annotations import importlib.util import json import os from typing import Any, Optional # ============================================================ # Load validation orchestrator by path # (the directory name contains spaces, so importlib is required) # ============================================================ _HERE = os.path.dirname(os.path.abspath(__file__)) _VALIDATION_DIR = os.path.normpath(os.path.join(_HERE, "..", "Invoice Validation Functions")) def _load_module_by_path(filepath: str, module_name: str): spec = importlib.util.spec_from_file_location(module_name, filepath) mod = importlib.util.module_from_spec(spec) spec.loader.exec_module(mod) return mod _val_orch = _load_module_by_path( os.path.join(_VALIDATION_DIR, "orchestrator.py"), "_validation_orchestrator", ) _run_validation_pipeline = _val_orch.run_invoice_validation_pipeline # ============================================================ # Matching layer imports # ============================================================ from match_router import ( MatchPreconditionError, RouteDecision, RoutingDocument, RoutingExtractedFields, route_invoice, ) from two_way_match import ( InvoiceHeader, InvoiceLine, MatchExceptionRecord, POHeader, POLine, run_two_way_match_core, ) from three_way_match import GRLineItem, run_three_way_match from contract_match import ( ContractInvoiceHeader, ContractMatchResult, run_contract_match_core, ) # ============================================================ # Config loader # ============================================================ def _load_matching_configs(path: Optional[str] = None) -> dict: if path is None: path = os.path.join(_HERE, "matching_configs.json") with open(path, encoding="utf-8") as fh: return json.load(fh) # ============================================================ # Internal helpers # ============================================================ def _stub_missing_contract_invoice(inv_lines: list[InvoiceLine]) -> ContractMatchResult: """Returned when contract route is selected but contract_invoice was not provided.""" res = ContractMatchResult( match_type="contract", overall_status="exception", match_zone="red", documents_status="pending_approval", lines_total=len(inv_lines), lines_matched=0, lines_in_exception=len(inv_lines), ) res.header_exceptions.append( MatchExceptionRecord( exception_type="cannot_match_missing_contract_invoice", exception_detail={"reason": "contract_invoice_not_provided_to_pipeline"}, ) ) return res def _collect_match_flags(match_result: Any) -> tuple[list, list, list]: """Extract risk_flags, advisory_flags, skipped_steps from a match result.""" if match_result is None: return [], [], [] return ( list(getattr(match_result, "risk_flags", None) or []), list(getattr(match_result, "advisory_flags", None) or []), list(getattr(match_result, "skipped_steps", None) or []), ) def _compute_final_status(validation_risk_flags: list, match_result: Any) -> str: """ Derive a single final status from both validation and match outcomes. Any validation risk flag is treated as an exception regardless of match result. """ if validation_risk_flags: return "exception" if match_result is None: return "exception" return getattr(match_result, "overall_status", "exception") # ============================================================ # Top-level pipeline # ============================================================ def run_invoice_pipeline( *, # --- Validation inputs --- document, extracted_fields, vendors, vendor_bank_accounts, vendor_tax_ids, entity, line_items, purchase_orders, contracts, tax_master_rows, currency_seed_map, platform_configs: Optional[dict] = None, route_contexts=None, actor_id=None, # --- Matching inputs --- matching_invoice: InvoiceHeader, matching_inv_lines: list[InvoiceLine], contract_invoice: Optional[ContractInvoiceHeader] = None, po: Optional[POHeader] = None, po_lines: Optional[list[POLine]] = None, gr_rows: Optional[list[GRLineItem]] = None, matching_configs: Optional[dict] = None, ) -> dict[str, Any]: """ Run the full invoice processing pipeline and return a single combined result. Parameters ---------- document : Invoice document object (.id, .tenant_id, .entity_id, .status, .doc_type required for routing). extracted_fields : VLM-extracted fields (.po_number_canonical used for routing). vendors : Vendor records for vendor validation. vendor_bank_accounts : Vendor bank account records. vendor_tax_ids : Vendor tax-ID records. entity : Routing entity (.country_code, .vat_id, .region_code). line_items : Invoice line-item records (for validation). purchase_orders : Purchase-order records (for payment-terms validation). contracts : Contract records (for payment-terms validation and contract match). tax_master_rows : Tax-master records. currency_seed_map : Dict mapping ISO currency code → minor-unit row. platform_configs : Validation config dict; loaded from platform_configs.json if None. route_contexts : Route-context strings forwarded to Vendor Validation. actor_id : Actor identifier forwarded to Vendor Validation for audit. matching_invoice : InvoiceHeader for 2-way / 3-way matching. matching_inv_lines : Invoice lines for all match types. contract_invoice : ContractInvoiceHeader for contract matching (optional). po : Pre-fetched POHeader; None if no PO number on the invoice. po_lines : Pre-fetched PO lines; None if no PO. gr_rows : Pre-fetched GR rows; None or [] triggers 2-way instead of 3-way. matching_configs : Matching config dict; loaded from matching_configs.json if None. Returns ------- dict with keys: validation_result — raw dict from the validation pipeline route — 'two_way' | 'three_way' | 'contract' | 'credit_note' | 'stub' | 'precondition_error' match_result — TwoWayMatchResult | ContractMatchResult | None risk_flags — combined validation + matching risk flags, in execution order advisory_flags — combined, in execution order skipped_steps — combined, in execution order ops_alerts — operational alerts from the validation layer resolved_vendor_id — from vendor validation final_status — 'matched' | 'partial_match' | 'exception' """ # ------------------------------------------------------------------ # Step 1 — Validation # ------------------------------------------------------------------ validation_result = _run_validation_pipeline( document=document, extracted_fields=extracted_fields, vendors=vendors, vendor_bank_accounts=vendor_bank_accounts, vendor_tax_ids=vendor_tax_ids, entity=entity, line_items=line_items, purchase_orders=purchase_orders, contracts=contracts, tax_master_rows=tax_master_rows, currency_seed_map=currency_seed_map, platform_configs=platform_configs, route_contexts=route_contexts, actor_id=actor_id, ) resolved_vendor_id = validation_result.get("resolved_vendor_id") risk_flags: list = list(validation_result.get("risk_flags") or []) advisory_flags: list = list(validation_result.get("advisory_flags") or []) skipped_steps: list = list(validation_result.get("skipped_steps") or []) # ------------------------------------------------------------------ # Step 2 — Routing # ------------------------------------------------------------------ if matching_configs is None: matching_configs = _load_matching_configs() routing_doc = RoutingDocument( id=getattr(document, "id", None), tenant_id=getattr(document, "tenant_id", None), entity_id=getattr(document, "entity_id", None), status=getattr(document, "status", "validated"), doc_type=getattr(document, "doc_type", "invoice"), ) routing_ef = RoutingExtractedFields( po_number_canonical=getattr(extracted_fields, "po_number_canonical", None), resolved_vendor_id=resolved_vendor_id, ) try: decision: RouteDecision = route_invoice( document=routing_doc, ef=routing_ef, inv_lines=matching_inv_lines, po=po, po_lines=po_lines, gr_rows=gr_rows, ) except MatchPreconditionError as exc: return { "validation_result": validation_result, "route": "precondition_error", "match_result": None, "risk_flags": risk_flags, "advisory_flags": advisory_flags, "skipped_steps": skipped_steps, "ops_alerts": list(validation_result.get("ops_alerts") or []), "resolved_vendor_id": resolved_vendor_id, "final_status": "exception", "error": str(exc), } route = decision.route # ------------------------------------------------------------------ # Step 3 — Matching # ------------------------------------------------------------------ match_result: Any = None if route == "stub": match_result = decision.stub_result elif route == "two_way": match_result = run_two_way_match_core( matching_invoice, matching_inv_lines, decision.po, decision.po_lines or [], matching_configs=matching_configs, ) elif route == "three_way": two_way_result = run_two_way_match_core( matching_invoice, matching_inv_lines, decision.po, decision.po_lines or [], matching_configs=matching_configs, ) match_result = run_three_way_match( two_way_result, matching_invoice, matching_inv_lines, decision.po_lines or [], decision.gr_rows or [], matching_configs=matching_configs, ) elif route == "contract": if contract_invoice is None: match_result = _stub_missing_contract_invoice(matching_inv_lines) else: match_result = run_contract_match_core( contract_invoice, matching_inv_lines, list(contracts), matching_configs=matching_configs, ) # ------------------------------------------------------------------ # Step 4 — Combine flags and return # ------------------------------------------------------------------ m_risk, m_advisory, m_skipped = _collect_match_flags(match_result) risk_flags.extend(m_risk) advisory_flags.extend(m_advisory) skipped_steps.extend(m_skipped) return { "validation_result": validation_result, "route": route, "match_result": match_result, "risk_flags": risk_flags, "advisory_flags": advisory_flags, "skipped_steps": skipped_steps, "ops_alerts": list(validation_result.get("ops_alerts") or []), "resolved_vendor_id": resolved_vendor_id, "final_status": _compute_final_status( validation_result.get("risk_flags") or [], match_result ), }