IDP-UI / src /Matching Functions /invoice_pipeline.py
Bhuvi13's picture
Upload 17 files
311afd3 verified
Raw
History Blame Contribute Delete
12.8 kB
"""
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
),
}