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