Spaces:
Sleeping
Sleeping
File size: 18,045 Bytes
311afd3 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 | 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
|