IDP-UI / src /Matching Functions /three_way_match.py
Bhuvi13's picture
Upload 17 files
311afd3 verified
Raw
History Blame Contribute Delete
18 kB
from __future__ import annotations
from collections import defaultdict
from dataclasses import dataclass
from datetime import date
from decimal import Decimal
from typing import Any, Optional
from two_way_match import (
InvoiceHeader,
InvoiceLine,
MatchExceptionRecord,
POLine,
TwoWayConfig,
TwoWayMatchResult,
_cfg_from_dict,
_qty_bands,
to_decimal,
)
D0 = Decimal("0")
# ── New input dataclass ────────────────────────────────────────────────────────
@dataclass
class GRLineItem:
id: int
po_line_id: Optional[int]
gr_date: date # DATE column, no timezone
inspection_status: str # 'accepted'|'partial_accept'|'rejected'|'pending'|'returned'
quantity_accepted: Optional[Decimal]
quantity_received: Optional[Decimal]
deleted_at: Optional[Any] = None # non-None β†’ soft-deleted; excluded everywhere
# ── Zone rank (adds 'critical' above 'red' for integrity signals) ──────────────
_ZONE_RANK: dict[str, int] = {"green": 0, "amber": 1, "red": 2, "critical": 3}
def _worse(a: Optional[str], b: str) -> str:
"""Worst-wins comparator. critical > red > amber > green."""
return b if _ZONE_RANK.get(b, 0) > _ZONE_RANK.get(a or "green", 0) else (a or "green")
def _push_zone(result: TwoWayMatchResult, inv_line_id: Optional[int], zone: str) -> None:
"""Update a specific matched line's zone and the overall match_zone, worst-wins."""
if inv_line_id is not None:
for mlr in result.match_line_results:
if mlr.invoice_line_id == inv_line_id:
mlr.line_zone = _worse(mlr.line_zone, zone)
result.match_zone = _worse(result.match_zone, zone)
# ── Private helpers ────────────────────────────────────────────────────────────
def _uninvoiced_qty(
po_line: POLine,
accepted_by_po: dict[int, list[GRLineItem]],
) -> Decimal:
"""
SUM(quantity_accepted for accepted/partial_accept GRs on po_line) βˆ’ po_line.invoiced_qty.
Raises RuntimeError on IDP17: quantity_accepted IS NULL on an accepted row.
Callers must NOT fall back to quantity_received β€” that would silently mask the violation.
"""
rows = accepted_by_po.get(po_line.id, [])
for g in rows:
if g.quantity_accepted is None:
raise RuntimeError(
f"IDP17 violation: GR line id={g.id} inspection_status={g.inspection_status!r} "
"has quantity_accepted IS NULL."
)
total_accepted = sum((g.quantity_accepted for g in rows), D0)
return total_accepted - (to_decimal(po_line.invoiced_qty) or D0)
def _band_verdict(
qty_over_pct: Decimal,
item_type: Optional[str],
cfg: TwoWayConfig,
) -> Optional[str]:
"""Return failing zone ('amber'|'red') if qty_over_pct exceeds tolerance, else None (pass)."""
qty_green, qty_amber = _qty_bands(item_type, cfg) # reuses existing 2-way helper
if qty_over_pct == D0 or qty_over_pct <= qty_green:
return None
return "amber" if qty_over_pct <= qty_amber else "red"
def _qty_check_single(
*,
inv_line: InvoiceLine,
po_line: POLine,
accepted_by_po: dict[int, list[GRLineItem]],
result: TwoWayMatchResult,
cfg: TwoWayConfig,
) -> None:
"""Β§5.3 quantity-over-received check for one Tier 1-3 (inv_line, po_line) direct match."""
# Pre-band guard 1: missing invoice qty β€” Cat 2 incomplete_fields already owns this flag
if inv_line.quantity is None:
result.skipped_steps.append({
"step": "qty_over_received",
"skip_reason": "inv_qty_null",
"line_id": inv_line.id,
"po_line_id": po_line.id,
})
return
uninvoiced = _uninvoiced_qty(po_line, accepted_by_po)
# Pre-band guard 2: negative uninvoiced β†’ IDP17 forensic exception, zone red
if uninvoiced < D0:
result.match_exceptions.append(MatchExceptionRecord(
exception_type="qty_over_received",
line_id=inv_line.id,
po_line_id=po_line.id,
exception_detail={
"reason": "uninvoiced_accepted_qty_negative",
"uninvoiced_accepted_qty": str(uninvoiced),
},
))
_push_zone(result, inv_line.id, "red")
result.overall_status = "exception"
return
inv_qty = inv_line.quantity
# Pre-band guard 3/4: uninvoiced == 0
if uninvoiced == D0:
if inv_qty > D0:
# All accepted GR qty already consumed by prior approved invoices
result.match_exceptions.append(MatchExceptionRecord(
exception_type="gr_fully_consumed",
line_id=inv_line.id,
po_line_id=po_line.id,
exception_detail={"reason": "gr_fully_consumed", "inv_qty": str(inv_qty)},
))
_push_zone(result, inv_line.id, "red")
result.overall_status = "exception"
# inv_qty == 0 β†’ noise line ($0 line), skip silently
return
# Band evaluation
qty_over_pct = max(D0, (inv_qty - uninvoiced) / uninvoiced * Decimal("100"))
zone = _band_verdict(qty_over_pct, po_line.item_type, cfg)
if zone:
result.match_exceptions.append(MatchExceptionRecord(
exception_type="qty_over_received",
line_id=inv_line.id,
po_line_id=po_line.id,
exception_detail={
"reason": "qty_over_received",
"qty_over_pct": str(qty_over_pct),
"inv_qty": str(inv_qty),
"uninvoiced_accepted_qty": str(uninvoiced),
},
))
_push_zone(result, inv_line.id, zone)
result.overall_status = "exception"
def _qty_check_tier4_group(
*,
gl_account: Optional[str],
group_inv_lines: list[InvoiceLine],
group_po_lines: list[POLine],
accepted_by_po: dict[int, list[GRLineItem]],
accepted_gr: list[GRLineItem], # full accepted list β€” needed to detect no-GR vs consumed
result: TwoWayMatchResult,
cfg: TwoWayConfig,
) -> None:
"""
Β§5.6 aggregate GR quantity check for one Tier-4 GL group.
Partitions PO lines into goods / services subsets.
NULL-item-type PO lines are excluded from both (no GR obligation by default).
The quantity comparison is conservative: ALL inv-line qty (goods+services) vs
ONLY the goods-subset uninvoiced accepted qty.
"""
po_goods = [pl for pl in group_po_lines if pl.item_type == "goods"]
po_services = [pl for pl in group_po_lines if pl.item_type == "services"]
if not po_goods:
return # 100 % services or all NULL item_type β†’ no GR obligation for this group
goods_ids = {pl.id for pl in po_goods}
# IDP17 violations surface here via RuntimeError from _uninvoiced_qty
uninvoiced_goods = sum((_uninvoiced_qty(pl, accepted_by_po) for pl in po_goods), D0)
# Total invoice qty over the WHOLE group (conservative: includes services portion)
inv_qty_total = sum(
(il.quantity for il in group_inv_lines if il.quantity is not None), D0
)
inv_ids = [il.id for il in group_inv_lines]
base_detail: dict[str, Any] = {
"group_gl_account": gl_account,
"po_lines_goods": sorted(goods_ids),
"po_lines_services": [pl.id for pl in po_services],
"uninvoiced_accepted_qty_goods": str(uninvoiced_goods),
"inv_qty_total": str(inv_qty_total),
}
# Pre-band guard: negative uninvoiced_goods β†’ IDP17 forensic
if uninvoiced_goods < D0:
result.match_exceptions.append(MatchExceptionRecord(
exception_type="qty_over_received",
exception_detail={**base_detail, "reason": "uninvoiced_accepted_qty_negative"},
))
for il_id in inv_ids:
_push_zone(result, il_id, "red")
result.overall_status = "exception"
return
# Pre-band guard: uninvoiced_goods == 0
if uninvoiced_goods == D0:
# Distinguish "no accepted GRs for goods lines yet" from "fully consumed"
has_accepted_gr_for_goods = any(g.po_line_id in goods_ids for g in accepted_gr)
exc_type = "gr_fully_consumed" if has_accepted_gr_for_goods else "no_accepted_gr"
if inv_qty_total > D0:
result.match_exceptions.append(MatchExceptionRecord(
exception_type=exc_type,
exception_detail={**base_detail, "reason": exc_type},
))
for il_id in inv_ids:
_push_zone(result, il_id, "red")
result.overall_status = "exception"
# inv_qty_total == 0 β†’ noise group, skip silently
return
# Band evaluation (always uses "goods" tolerance for mixed groups β€” Β§5.6 conservative choice)
qty_over_pct = max(D0, (inv_qty_total - uninvoiced_goods) / uninvoiced_goods * Decimal("100"))
zone = _band_verdict(qty_over_pct, "goods", cfg)
if zone:
result.match_exceptions.append(MatchExceptionRecord(
exception_type="qty_over_received",
exception_detail={
**base_detail,
"subset_breakdown": {"qty_over_pct": str(qty_over_pct)},
},
))
for il_id in inv_ids:
_push_zone(result, il_id, zone)
result.overall_status = "exception"
# ── Public entry point ─────────────────────────────────────────────────────────
def run_three_way_match(
two_way_result: TwoWayMatchResult,
invoice: InvoiceHeader, # must have invoice_date: Optional[date]
inv_lines: list[InvoiceLine],
po_lines: list[POLine],
gr_lines: list[GRLineItem],
*,
matching_configs: Optional[dict] = None,
) -> TwoWayMatchResult:
"""
Layers GR checks (Β§5.1–5.6) on top of an already-completed 2-way match result.
Always sets match_type='three_way'.
Check order (stop-on-fire for checks 1-3):
1. no_accepted_gr β€” stop if no accepted/partial_accept GRs exist
2. invoice_before_gr — stop if invoice pre-dates earliest accepted GR; zone→critical
3. gr_inspection_rejected β€” stop if any routed GR row has status='rejected'
4. qty_over_received β€” per matched PO line (Tier 1-3) or GL group (Tier 4)
2-way line counts (lines_total / lines_matched / lines_in_exception) are never modified;
only match_zone, individual line_zone fields, match_exceptions, and overall_status change.
"""
cfg = _cfg_from_dict(matching_configs or {})
result = two_way_result
result.match_type = "three_way"
inv_by_id: dict[int, InvoiceLine] = {il.id: il for il in inv_lines}
po_by_id: dict[int, POLine] = {pl.id: pl for pl in po_lines}
# Collect matched pairs from 2-way, split by tier
tier1_3: list[tuple[int, int]] = []
tier4: list[tuple[int, int]] = []
for mlr in result.match_line_results:
if mlr.line_status != "matched":
continue
(tier4 if mlr.match_tier == 4 else tier1_3).append(
(mlr.invoice_line_id, mlr.po_line_id)
)
all_po_ids: set[int] = {po_id for _, po_id in tier1_3 + tier4}
if not all_po_ids:
return result # nothing matched in 2-way; nothing to GR-check
# Route GR rows to matched PO lines.
# Exclude: soft-deleted rows, and 'returned' rows (those belong to credit-note matching Β§5.5).
routed = [
g for g in gr_lines
if g.po_line_id in all_po_ids
and g.deleted_at is None
and g.inspection_status != "returned"
]
# ── Check 1: no accepted GR ────────────────────────────────────────────────
# Timing-fraud source set: accepted / partial_accept only (Β§5.1)
accepted = [g for g in routed if g.inspection_status in ("accepted", "partial_accept")]
if not accepted:
result.match_exceptions.append(MatchExceptionRecord(
exception_type="no_accepted_gr",
exception_detail={"reason": "no_accepted_gr",
"matched_po_line_ids": sorted(all_po_ids)},
))
result.overall_status = "exception"
return result
# ── Check 2: timing fraud ─────────────────────────────────────────────────
# Skip if invoice_date IS NULL (Cat 2 incomplete_fields already owns missing date).
if invoice.invoice_date is not None:
earliest_gr_date = min(g.gr_date for g in accepted) # DATE-level comparison
if invoice.invoice_date < earliest_gr_date:
result.match_exceptions.append(MatchExceptionRecord(
exception_type="invoice_before_gr",
exception_detail={
"reason": "invoice_before_gr",
"invoice_date": str(invoice.invoice_date),
"earliest_gr_date": str(earliest_gr_date),
},
))
result.risk_flags.append("invoice_before_gr")
result.match_zone = "critical" # Β§8.3 β€” integrity signal overrides normal zones
result.overall_status = "exception"
return result
# ── Check 3: inspection rejection ─────────────────────────────────────────
# Source set: ALL routed non-returned GR rows (Β§5.1) β€” rejected status fires regardless of
# whether the PO line also has accepted GRs.
rejected = [g for g in routed if g.inspection_status == "rejected"]
if rejected:
result.match_exceptions.append(MatchExceptionRecord(
exception_type="gr_inspection_rejected",
exception_detail={
"reason": "gr_inspection_rejected",
"rejected_gr_ids": sorted(g.id for g in rejected),
},
))
result.overall_status = "exception"
return result
# ── Check 4: quantity over-received ───────────────────────────────────────
# Build accepted-GR index per PO line, FIFO-ordered (gr_date ASC, id ASC).
# This ordering is analytical only β€” nothing is physically decremented (Β§5.4).
accepted_by_po: dict[int, list[GRLineItem]] = defaultdict(list)
for g in accepted:
if g.po_line_id is not None:
accepted_by_po[g.po_line_id].append(g)
for rows in accepted_by_po.values():
rows.sort(key=lambda g: (g.gr_date, g.id))
# Tier 1-3: one check per direct (inv_line, po_line) match
for inv_id, po_id in tier1_3:
il = inv_by_id.get(inv_id)
pl = po_by_id.get(po_id)
if il is None or pl is None:
continue
if pl.item_type in (None, "services"):
continue # services and NULL-typed lines have no GR obligation
try:
_qty_check_single(
inv_line=il, po_line=pl,
accepted_by_po=accepted_by_po,
result=result, cfg=cfg,
)
except RuntimeError as exc:
result.match_exceptions.append(MatchExceptionRecord(
exception_type="data_integrity_violation",
line_id=inv_id,
po_line_id=po_id,
exception_detail={"reason": "idp17_quantity_accepted_null", "detail": str(exc)},
))
_push_zone(result, inv_id, "red")
result.overall_status = "exception"
# Tier 4: aggregate check per GL group (Β§5.6)
if tier4:
# Reconstruct GL groups from inv_line.gl_account
gl_groups: dict[str, dict[str, set]] = defaultdict(
lambda: {"inv": set(), "po": set()}
)
for inv_id, po_id in tier4:
il = inv_by_id.get(inv_id)
if il is None:
continue
key = il.gl_account or "__null__"
gl_groups[key]["inv"].add(inv_id)
gl_groups[key]["po"].add(po_id)
for gl_key, ids in gl_groups.items():
try:
_qty_check_tier4_group(
gl_account = None if gl_key == "__null__" else gl_key,
group_inv_lines= [inv_by_id[i] for i in ids["inv"] if i in inv_by_id],
group_po_lines = [po_by_id[i] for i in ids["po"] if i in po_by_id],
accepted_by_po = accepted_by_po,
accepted_gr = accepted,
result=result, cfg=cfg,
)
except RuntimeError as exc:
result.match_exceptions.append(MatchExceptionRecord(
exception_type="data_integrity_violation",
exception_detail={
"reason": "idp17_quantity_accepted_null",
"gl_account": None if gl_key == "__null__" else gl_key,
"detail": str(exc),
},
))
result.overall_status = "exception"
return result