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