Spaces:
Sleeping
Sleeping
| 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 ββββββββββββββββββββββββββββββββββββββββββββββββββββββββ | |
| 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 | |