Spaces:
Sleeping
Sleeping
| """ | |
| 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 | |
| ), | |
| } | |