File size: 5,566 Bytes
71b0992
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
"""Checks facts supplied to it; makes no model calls or tax-policy decisions."""

import re
from decimal import ROUND_HALF_UP, Decimal

from .models import Document


def verify(invoice, purchase):
    inv = Document.model_validate(invoice)
    po = Document.model_validate(purchase) if purchase else None
    findings = []

    def flag(code, field, message):
        findings.append({"code": code, "field": field, "message": message})

    for name, doc in (("invoice", inv), ("purchase", po)):
        if doc is None:
            continue
        for field in ("vendor", "number", "date", "currency", "subtotal", "tax", "total"):
            if getattr(doc, field) is None:
                flag(
                    "missing_field",
                    f"{name}.{field}",
                    "Not extracted; review the original document.",
                )
        quantum = Decimal("1") if doc.currency == "JPY" else Decimal("0.01")
        if doc.currency not in {"INR", "USD", "EUR", "GBP", "JPY"}:
            flag(
                "unsupported_currency",
                f"{name}.currency",
                "Currency precision is not supported; review monetary calculations.",
            )
            arithmetic = False
        else:
            arithmetic = True
        if not doc.lines:
            flag("missing_lines", name, "No line items extracted.")
        for index, line in enumerate(doc.lines):
            if any(getattr(line, key) is None for key in ("quantity", "unit_price", "amount")):
                flag(
                    "missing_field",
                    f"{name}.lines.{index}",
                    "Line amount, quantity, or price is unknown.",
                )
            elif arithmetic and (line.quantity * line.unit_price).quantize(
                quantum, rounding=ROUND_HALF_UP
            ) != line.amount.quantize(quantum, rounding=ROUND_HALF_UP):
                flag(
                    "line_total_mismatch",
                    f"{name}.lines.{index}",
                    "Quantity × unit price does not equal line amount.",
                )
        if (
            arithmetic
            and doc.lines
            and all(line.amount is not None for line in doc.lines)
            and doc.subtotal is not None
        ):
            expected = sum((line.amount for line in doc.lines), Decimal(0)).quantize(
                quantum, rounding=ROUND_HALF_UP
            )
            if expected != doc.subtotal.quantize(quantum, rounding=ROUND_HALF_UP):
                flag(
                    "subtotal_mismatch",
                    f"{name}.subtotal",
                    "Sum of line amounts differs from subtotal.",
                )
        if arithmetic and all(
            getattr(doc, field) is not None for field in ("subtotal", "tax", "total")
        ):
            if (doc.subtotal + doc.tax).quantize(
                quantum, rounding=ROUND_HALF_UP
            ) != doc.total.quantize(quantum, rounding=ROUND_HALF_UP):
                flag(
                    "total_mismatch",
                    f"{name}.total",
                    "Subtotal + stated tax differs from total. Check discounts or other charges manually.",
                )
    if po:
        currencies_match = bool(inv.currency and po.currency and inv.currency == po.currency)
        if not currencies_match:
            flag(
                "currency_mismatch",
                "currency",
                "Currencies differ or are unknown; monetary comparison skipped.",
            )
        used = set()
        for index, line in enumerate(inv.lines):

            def normalize(text):
                return re.sub(r"\s+", " ", (text or "").strip().casefold())

            matches = [
                (j, other)
                for j, other in enumerate(po.lines)
                if (line.sku and other.sku == line.sku)
                or (
                    not line.sku
                    and line.description
                    and normalize(other.description) == normalize(line.description)
                )
            ]
            if len(matches) != 1 or matches[0][0] in used:
                flag(
                    "ambiguous_match" if matches else "unmatched_line",
                    f"invoice.lines.{index}",
                    "Cannot establish a unique purchase-order line; review manually.",
                )
                continue
            matched, other = matches[0]
            used.add(matched)
            if (
                line.quantity is not None
                and other.quantity is not None
                and line.quantity != other.quantity
            ):
                flag(
                    "quantity_mismatch",
                    f"invoice.lines.{index}",
                    "Invoice quantity differs from purchase order.",
                )
            if (
                currencies_match
                and line.unit_price is not None
                and other.unit_price is not None
                and line.unit_price != other.unit_price
            ):
                flag(
                    "price_mismatch",
                    f"invoice.lines.{index}",
                    "Invoice unit price differs from purchase order.",
                )
        for index in set(range(len(po.lines))) - used:
            flag(
                "unmatched_purchase_line",
                f"purchase.lines.{index}",
                "Purchase-order line was not uniquely matched; partial fulfillment may be intentional.",
            )
    return findings