Spaces:
Sleeping
Sleeping
Upload 15 files
Browse files- src/Invoice Validation Functions/Bank validation.py +438 -0
- src/Invoice Validation Functions/Tax - ID validation.py +556 -0
- src/Invoice Validation Functions/Vendor Validation.py +385 -0
- src/Invoice Validation Functions/completeness.py +169 -0
- src/Invoice Validation Functions/date_sanity.py +219 -0
- src/Invoice Validation Functions/orchestrator.py +282 -0
- src/Invoice Validation Functions/payment terms valdiation.py +264 -0
- src/Invoice Validation Functions/platform_configs.json +67 -0
- src/Invoice Validation Functions/tax validation.py +587 -0
- src/Matching Functions/contract_match.py +757 -0
- src/Matching Functions/invoice_pipeline.py +342 -0
- src/Matching Functions/match_router.py +273 -0
- src/Matching Functions/matching_configs.json +39 -0
- src/Matching Functions/three_way_match.py +416 -0
- src/Matching Functions/two_way_match.py +1025 -0
src/Invoice Validation Functions/Bank validation.py
ADDED
|
@@ -0,0 +1,438 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import re
|
| 2 |
+
from datetime import date, datetime
|
| 3 |
+
from hashlib import sha256
|
| 4 |
+
from unicodedata import normalize
|
| 5 |
+
from decimal import Decimal
|
| 6 |
+
|
| 7 |
+
from rapidfuzz import fuzz
|
| 8 |
+
|
| 9 |
+
|
| 10 |
+
def run_bank_validation(
|
| 11 |
+
*,
|
| 12 |
+
document,
|
| 13 |
+
extracted_fields,
|
| 14 |
+
vendor_bank_accounts,
|
| 15 |
+
resolved_vendor_id,
|
| 16 |
+
platform_configs=None,
|
| 17 |
+
name_match_fn=None,
|
| 18 |
+
):
|
| 19 |
+
"""
|
| 20 |
+
Bank validation logic based on Cat 2 section 7.4.
|
| 21 |
+
|
| 22 |
+
Expected document fields:
|
| 23 |
+
document.id
|
| 24 |
+
document.tenant_id
|
| 25 |
+
|
| 26 |
+
Expected extracted_fields fields:
|
| 27 |
+
invoice_date
|
| 28 |
+
currency
|
| 29 |
+
bank_iban
|
| 30 |
+
bank_acc_no
|
| 31 |
+
bank_swift
|
| 32 |
+
bank_acc_name
|
| 33 |
+
|
| 34 |
+
Expected vendor_bank_accounts row fields:
|
| 35 |
+
tenant_id
|
| 36 |
+
vendor_id
|
| 37 |
+
iban_canonical
|
| 38 |
+
acc_no_canonical
|
| 39 |
+
swift_bic
|
| 40 |
+
bank_acc_name
|
| 41 |
+
currency_code
|
| 42 |
+
bank_country
|
| 43 |
+
is_primary
|
| 44 |
+
effective_from
|
| 45 |
+
effective_to
|
| 46 |
+
deleted_at
|
| 47 |
+
|
| 48 |
+
name_match_fn:
|
| 49 |
+
Optional function for bank account name comparison.
|
| 50 |
+
Should return something like:
|
| 51 |
+
{"decision": "matched" | "suggestion" | "unmatched"}
|
| 52 |
+
"""
|
| 53 |
+
|
| 54 |
+
platform_configs = platform_configs or {}
|
| 55 |
+
|
| 56 |
+
iban_checksum_required = platform_configs.get(
|
| 57 |
+
"validation.iban_checksum_required",
|
| 58 |
+
True,
|
| 59 |
+
)
|
| 60 |
+
|
| 61 |
+
iban_accno_exempt_countries = set(
|
| 62 |
+
platform_configs.get(
|
| 63 |
+
"validation.iban_accno_substring_exempt_countries",
|
| 64 |
+
["LC", "MT"],
|
| 65 |
+
)
|
| 66 |
+
)
|
| 67 |
+
|
| 68 |
+
high_threshold = Decimal(str(platform_configs.get("validation.rapidfuzz_high_threshold", "0.90")))
|
| 69 |
+
medium_threshold = Decimal(str(platform_configs.get("validation.rapidfuzz_medium_threshold", "0.75")))
|
| 70 |
+
suggestion_threshold = Decimal(str(platform_configs.get("validation.rapidfuzz_suggestion_threshold", "0.60")))
|
| 71 |
+
|
| 72 |
+
def hash_prefix(value, length=8):
|
| 73 |
+
if not value:
|
| 74 |
+
return None
|
| 75 |
+
return sha256(str(value).encode("utf-8")).hexdigest()[:length]
|
| 76 |
+
|
| 77 |
+
def canonicalize_text(value):
|
| 78 |
+
if value is None:
|
| 79 |
+
return None
|
| 80 |
+
value = normalize("NFKC", str(value))
|
| 81 |
+
value = value.strip().upper()
|
| 82 |
+
return value or None
|
| 83 |
+
|
| 84 |
+
def canonicalize_iban(value):
|
| 85 |
+
if value is None:
|
| 86 |
+
return None
|
| 87 |
+
value = normalize("NFKC", str(value))
|
| 88 |
+
value = re.sub(r"[\s\-\.,/\\:;_]+", "", value)
|
| 89 |
+
value = value.upper()
|
| 90 |
+
return value or None
|
| 91 |
+
|
| 92 |
+
def canonicalize_acc_no(value):
|
| 93 |
+
if value is None:
|
| 94 |
+
return None
|
| 95 |
+
value = normalize("NFKC", str(value))
|
| 96 |
+
value = re.sub(r"[\s\-\.,/\\:;_]+", "", value)
|
| 97 |
+
value = value.upper()
|
| 98 |
+
return value or None
|
| 99 |
+
|
| 100 |
+
def canonicalize_swift(value):
|
| 101 |
+
if value is None:
|
| 102 |
+
return None
|
| 103 |
+
value = normalize("NFKC", str(value))
|
| 104 |
+
value = re.sub(r"\s+", "", value)
|
| 105 |
+
value = value.upper()
|
| 106 |
+
return value or None
|
| 107 |
+
|
| 108 |
+
def iban_country(iban):
|
| 109 |
+
if not iban or len(iban) < 2:
|
| 110 |
+
return None
|
| 111 |
+
return iban[:2]
|
| 112 |
+
|
| 113 |
+
def iban_checksum_mod97_valid(iban):
|
| 114 |
+
"""
|
| 115 |
+
Standard IBAN mod-97 validation.
|
| 116 |
+
|
| 117 |
+
Move first 4 chars to end.
|
| 118 |
+
Replace letters A=10 ... Z=35.
|
| 119 |
+
Result mod 97 must equal 1.
|
| 120 |
+
"""
|
| 121 |
+
if not iban or len(iban) < 4:
|
| 122 |
+
return False
|
| 123 |
+
|
| 124 |
+
rearranged = iban[4:] + iban[:4]
|
| 125 |
+
numeric = ""
|
| 126 |
+
|
| 127 |
+
for char in rearranged:
|
| 128 |
+
if char.isdigit():
|
| 129 |
+
numeric += char
|
| 130 |
+
elif "A" <= char <= "Z":
|
| 131 |
+
numeric += str(ord(char) - ord("A") + 10)
|
| 132 |
+
else:
|
| 133 |
+
return False
|
| 134 |
+
|
| 135 |
+
remainder = 0
|
| 136 |
+
for digit in numeric:
|
| 137 |
+
remainder = (remainder * 10 + int(digit)) % 97
|
| 138 |
+
|
| 139 |
+
return remainder == 1
|
| 140 |
+
|
| 141 |
+
def iban_acc_no_consistent(iban, acc_no):
|
| 142 |
+
"""
|
| 143 |
+
Basic consistency check:
|
| 144 |
+
remove country + check digits from IBAN,
|
| 145 |
+
then check whether account number appears inside BBAN.
|
| 146 |
+
|
| 147 |
+
Leading zeros are ignored for comparison.
|
| 148 |
+
"""
|
| 149 |
+
if not iban or not acc_no:
|
| 150 |
+
return True
|
| 151 |
+
|
| 152 |
+
country = iban_country(iban)
|
| 153 |
+
|
| 154 |
+
if country in iban_accno_exempt_countries:
|
| 155 |
+
return True
|
| 156 |
+
|
| 157 |
+
bban = iban[4:]
|
| 158 |
+
stripped_acc_no = acc_no.lstrip("0") or acc_no
|
| 159 |
+
|
| 160 |
+
return stripped_acc_no in bban
|
| 161 |
+
|
| 162 |
+
def is_effective(row, invoice_date):
|
| 163 |
+
if getattr(row, "deleted_at", None):
|
| 164 |
+
return False
|
| 165 |
+
|
| 166 |
+
if invoice_date is None:
|
| 167 |
+
return True
|
| 168 |
+
|
| 169 |
+
effective_from = getattr(row, "effective_from", None)
|
| 170 |
+
effective_to = getattr(row, "effective_to", None)
|
| 171 |
+
|
| 172 |
+
if effective_from is not None and invoice_date < effective_from:
|
| 173 |
+
return False
|
| 174 |
+
|
| 175 |
+
if effective_to and invoice_date >= effective_to:
|
| 176 |
+
return False
|
| 177 |
+
|
| 178 |
+
return True
|
| 179 |
+
|
| 180 |
+
def default_name_match(invoice_name, master_name):
|
| 181 |
+
if not invoice_name or not master_name:
|
| 182 |
+
return {"decision": "unmatched"}
|
| 183 |
+
|
| 184 |
+
score = Decimal(str(fuzz.WRatio(invoice_name, master_name) / 100)).quantize(
|
| 185 |
+
Decimal("0.0001")
|
| 186 |
+
)
|
| 187 |
+
|
| 188 |
+
if score >= high_threshold:
|
| 189 |
+
return {"decision": "matched", "score_tier": "high"}
|
| 190 |
+
if score >= medium_threshold:
|
| 191 |
+
return {"decision": "matched", "score_tier": "medium"}
|
| 192 |
+
if score >= suggestion_threshold:
|
| 193 |
+
return {"decision": "suggestion", "score_tier": "low"}
|
| 194 |
+
return {"decision": "unmatched", "score_tier": "low"}
|
| 195 |
+
|
| 196 |
+
def bank_account_name_matches(invoice_name, master_name):
|
| 197 |
+
matcher = name_match_fn or default_name_match
|
| 198 |
+
|
| 199 |
+
result = matcher(
|
| 200 |
+
query_name=invoice_name,
|
| 201 |
+
candidate_pool=[master_name],
|
| 202 |
+
context={"purpose": "bank_acc_name_check"},
|
| 203 |
+
) if name_match_fn else matcher(invoice_name, master_name)
|
| 204 |
+
|
| 205 |
+
return result.get("decision") == "matched"
|
| 206 |
+
|
| 207 |
+
def add_flag(code, *, field=None, weight=None, match_result="failed", invoice_value=None,
|
| 208 |
+
master_value=None, currency_invoice=None, currency_master=None,
|
| 209 |
+
primary_or_non_primary=None):
|
| 210 |
+
flag = {
|
| 211 |
+
"code": code,
|
| 212 |
+
"field": field,
|
| 213 |
+
"severity": "advisory",
|
| 214 |
+
}
|
| 215 |
+
|
| 216 |
+
if weight is not None:
|
| 217 |
+
flag["weight"] = weight
|
| 218 |
+
|
| 219 |
+
risk_flags.append(flag)
|
| 220 |
+
|
| 221 |
+
audit_entries.append(
|
| 222 |
+
{
|
| 223 |
+
"field": field,
|
| 224 |
+
"match_result": match_result,
|
| 225 |
+
"invoice_hash_prefix": hash_prefix(invoice_value),
|
| 226 |
+
"master_hash_prefix": hash_prefix(master_value),
|
| 227 |
+
"currency_invoice": currency_invoice,
|
| 228 |
+
"currency_master": currency_master,
|
| 229 |
+
"primary_or_non_primary": primary_or_non_primary,
|
| 230 |
+
}
|
| 231 |
+
)
|
| 232 |
+
|
| 233 |
+
risk_flags = []
|
| 234 |
+
skipped_steps = []
|
| 235 |
+
audit_entries = []
|
| 236 |
+
|
| 237 |
+
if resolved_vendor_id is None:
|
| 238 |
+
skipped_steps.append(
|
| 239 |
+
{
|
| 240 |
+
"step": "bank_validation",
|
| 241 |
+
"skip_reason": "vendor_unresolved",
|
| 242 |
+
}
|
| 243 |
+
)
|
| 244 |
+
|
| 245 |
+
return {
|
| 246 |
+
"risk_flags": risk_flags,
|
| 247 |
+
"skipped_steps": skipped_steps,
|
| 248 |
+
"audit_entries": audit_entries,
|
| 249 |
+
"match_result": "skipped",
|
| 250 |
+
}
|
| 251 |
+
|
| 252 |
+
invoice_date = getattr(extracted_fields, "invoice_date", None)
|
| 253 |
+
|
| 254 |
+
if isinstance(invoice_date, datetime):
|
| 255 |
+
invoice_date = invoice_date.date()
|
| 256 |
+
|
| 257 |
+
|
| 258 |
+
invoice_iban = canonicalize_iban(getattr(extracted_fields, "bank_iban", None))
|
| 259 |
+
invoice_acc_no = canonicalize_acc_no(getattr(extracted_fields, "bank_acc_no", None))
|
| 260 |
+
invoice_swift = canonicalize_swift(getattr(extracted_fields, "bank_swift", None))
|
| 261 |
+
invoice_bank_acc_name = getattr(extracted_fields, "bank_acc_name", None)
|
| 262 |
+
invoice_currency = canonicalize_text(getattr(extracted_fields, "currency", None))
|
| 263 |
+
|
| 264 |
+
# 1. Invoice-side pre-format checks.
|
| 265 |
+
if invoice_iban and iban_checksum_required:
|
| 266 |
+
if not iban_checksum_mod97_valid(invoice_iban):
|
| 267 |
+
add_flag(
|
| 268 |
+
"bank_iban_invalid_checksum",
|
| 269 |
+
field="bank_iban",
|
| 270 |
+
weight=Decimal("0.35"),
|
| 271 |
+
invoice_value=invoice_iban,
|
| 272 |
+
)
|
| 273 |
+
|
| 274 |
+
if invoice_iban and invoice_acc_no:
|
| 275 |
+
if not iban_acc_no_consistent(invoice_iban, invoice_acc_no):
|
| 276 |
+
add_flag(
|
| 277 |
+
"bank_iban_accno_inconsistent",
|
| 278 |
+
field="bank_iban/bank_acc_no",
|
| 279 |
+
weight=Decimal("0.35"),
|
| 280 |
+
invoice_value=f"{invoice_iban}|{invoice_acc_no}",
|
| 281 |
+
)
|
| 282 |
+
|
| 283 |
+
# 2. Build same-tenant, same-vendor, active candidate pool.
|
| 284 |
+
candidate_pool = [
|
| 285 |
+
row
|
| 286 |
+
for row in vendor_bank_accounts
|
| 287 |
+
if getattr(row, "tenant_id", None) == document.tenant_id
|
| 288 |
+
and getattr(row, "vendor_id", None) == resolved_vendor_id
|
| 289 |
+
and not getattr(row, "deleted_at", None)
|
| 290 |
+
and is_effective(row, invoice_date)
|
| 291 |
+
]
|
| 292 |
+
|
| 293 |
+
candidate_pool.sort(
|
| 294 |
+
key=lambda row: (
|
| 295 |
+
bool(getattr(row, "is_primary", False)),
|
| 296 |
+
getattr(row, "effective_from", None) or date.max,
|
| 297 |
+
),
|
| 298 |
+
reverse=True,
|
| 299 |
+
)
|
| 300 |
+
|
| 301 |
+
primary_rows = [
|
| 302 |
+
row for row in candidate_pool
|
| 303 |
+
if bool(getattr(row, "is_primary", False))
|
| 304 |
+
]
|
| 305 |
+
|
| 306 |
+
non_primary_rows = [
|
| 307 |
+
row for row in candidate_pool
|
| 308 |
+
if not bool(getattr(row, "is_primary", False))
|
| 309 |
+
]
|
| 310 |
+
|
| 311 |
+
primary = primary_rows[0] if primary_rows else None
|
| 312 |
+
|
| 313 |
+
invoice_has_any_bank_detail = any(
|
| 314 |
+
[
|
| 315 |
+
invoice_iban,
|
| 316 |
+
invoice_acc_no,
|
| 317 |
+
invoice_swift,
|
| 318 |
+
invoice_bank_acc_name,
|
| 319 |
+
]
|
| 320 |
+
)
|
| 321 |
+
|
| 322 |
+
if invoice_has_any_bank_detail and not candidate_pool:
|
| 323 |
+
add_flag(
|
| 324 |
+
"new_bank_details_no_master",
|
| 325 |
+
field="bank_details",
|
| 326 |
+
invoice_value="bank_details_present",
|
| 327 |
+
)
|
| 328 |
+
|
| 329 |
+
return {
|
| 330 |
+
"risk_flags": risk_flags,
|
| 331 |
+
"skipped_steps": skipped_steps,
|
| 332 |
+
"audit_entries": audit_entries,
|
| 333 |
+
"match_result": "no_master_bank_details",
|
| 334 |
+
}
|
| 335 |
+
|
| 336 |
+
# 3. Compare against primary account.
|
| 337 |
+
if primary:
|
| 338 |
+
primary_match = True
|
| 339 |
+
|
| 340 |
+
master_iban = getattr(primary, "iban_canonical", None)
|
| 341 |
+
master_acc_no = getattr(primary, "acc_no_canonical", None)
|
| 342 |
+
master_swift = canonicalize_swift(getattr(primary, "swift_bic", None))
|
| 343 |
+
master_bank_acc_name = getattr(primary, "bank_acc_name", None)
|
| 344 |
+
master_currency = canonicalize_text(getattr(primary, "currency_code", None))
|
| 345 |
+
|
| 346 |
+
if invoice_iban:
|
| 347 |
+
if not master_iban:
|
| 348 |
+
primary_match = False
|
| 349 |
+
add_flag(
|
| 350 |
+
"new_bank_field_type_no_master",
|
| 351 |
+
field="bank_iban",
|
| 352 |
+
invoice_value=invoice_iban,
|
| 353 |
+
primary_or_non_primary="primary",
|
| 354 |
+
)
|
| 355 |
+
elif invoice_iban != master_iban:
|
| 356 |
+
primary_match = False
|
| 357 |
+
add_flag(
|
| 358 |
+
"bank_iban_mismatch",
|
| 359 |
+
field="bank_iban",
|
| 360 |
+
invoice_value=invoice_iban,
|
| 361 |
+
master_value=master_iban,
|
| 362 |
+
primary_or_non_primary="primary",
|
| 363 |
+
)
|
| 364 |
+
|
| 365 |
+
if invoice_acc_no and master_acc_no and invoice_acc_no != master_acc_no:
|
| 366 |
+
primary_match = False
|
| 367 |
+
add_flag(
|
| 368 |
+
"bank_acc_no_mismatch",
|
| 369 |
+
field="bank_acc_no",
|
| 370 |
+
invoice_value=invoice_acc_no,
|
| 371 |
+
master_value=master_acc_no,
|
| 372 |
+
primary_or_non_primary="primary",
|
| 373 |
+
)
|
| 374 |
+
|
| 375 |
+
if invoice_swift and master_swift and invoice_swift != master_swift:
|
| 376 |
+
primary_match = False
|
| 377 |
+
add_flag(
|
| 378 |
+
"bank_swift_mismatch",
|
| 379 |
+
field="bank_swift",
|
| 380 |
+
invoice_value=invoice_swift,
|
| 381 |
+
master_value=master_swift,
|
| 382 |
+
primary_or_non_primary="primary",
|
| 383 |
+
)
|
| 384 |
+
|
| 385 |
+
if invoice_bank_acc_name and master_bank_acc_name:
|
| 386 |
+
if not bank_account_name_matches(invoice_bank_acc_name, master_bank_acc_name):
|
| 387 |
+
primary_match = False
|
| 388 |
+
add_flag(
|
| 389 |
+
"bank_acc_name_mismatch",
|
| 390 |
+
field="bank_acc_name",
|
| 391 |
+
weight=Decimal("0.30"),
|
| 392 |
+
invoice_value=invoice_bank_acc_name,
|
| 393 |
+
master_value=master_bank_acc_name,
|
| 394 |
+
primary_or_non_primary="primary",
|
| 395 |
+
)
|
| 396 |
+
|
| 397 |
+
if invoice_currency and master_currency and invoice_currency != master_currency:
|
| 398 |
+
primary_match = False
|
| 399 |
+
add_flag(
|
| 400 |
+
"bank_currency_mismatch",
|
| 401 |
+
field="currency",
|
| 402 |
+
weight=Decimal("0.20"),
|
| 403 |
+
currency_invoice=invoice_currency,
|
| 404 |
+
currency_master=master_currency,
|
| 405 |
+
primary_or_non_primary="primary",
|
| 406 |
+
)
|
| 407 |
+
|
| 408 |
+
match_result = "primary_match" if primary_match else "primary_mismatch"
|
| 409 |
+
|
| 410 |
+
else:
|
| 411 |
+
match_result = "no_primary_bank_account"
|
| 412 |
+
|
| 413 |
+
secondary_match = None
|
| 414 |
+
for row in non_primary_rows:
|
| 415 |
+
if invoice_iban and getattr(row, "iban_canonical", None) == invoice_iban:
|
| 416 |
+
secondary_match = row
|
| 417 |
+
break
|
| 418 |
+
|
| 419 |
+
if secondary_match:
|
| 420 |
+
add_flag(
|
| 421 |
+
"bank_non_primary_match",
|
| 422 |
+
field="bank_iban",
|
| 423 |
+
weight=Decimal("0.25"),
|
| 424 |
+
match_result="matched_non_primary",
|
| 425 |
+
invoice_value=invoice_iban,
|
| 426 |
+
master_value=getattr(secondary_match, "iban_canonical", None),
|
| 427 |
+
primary_or_non_primary="non_primary",
|
| 428 |
+
)
|
| 429 |
+
|
| 430 |
+
return {
|
| 431 |
+
"risk_flags": risk_flags,
|
| 432 |
+
"skipped_steps": skipped_steps,
|
| 433 |
+
"audit_entries": audit_entries,
|
| 434 |
+
"match_result": match_result,
|
| 435 |
+
"candidate_count": len(candidate_pool),
|
| 436 |
+
"primary_candidate_count": len(primary_rows),
|
| 437 |
+
"non_primary_candidate_count": len(non_primary_rows),
|
| 438 |
+
}
|
src/Invoice Validation Functions/Tax - ID validation.py
ADDED
|
@@ -0,0 +1,556 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import re
|
| 2 |
+
from datetime import datetime, date, timezone
|
| 3 |
+
from hashlib import sha256
|
| 4 |
+
from unicodedata import normalize
|
| 5 |
+
|
| 6 |
+
|
| 7 |
+
def _h_gb_vat_mod97(value, **_kw):
|
| 8 |
+
# UK VAT mod-97. Tries Method A then Method B (new-style) per HMRC spec.
|
| 9 |
+
# Value: 9 or 12 digits, GB prefix already stripped.
|
| 10 |
+
digits = re.sub(r"\D", "", value)
|
| 11 |
+
if len(digits) not in (9, 12):
|
| 12 |
+
return False
|
| 13 |
+
d = [int(c) for c in digits[:9]]
|
| 14 |
+
weights = [8, 7, 6, 5, 4, 3, 2]
|
| 15 |
+
s = sum(weights[i] * d[i] for i in range(7))
|
| 16 |
+
check = d[7] * 10 + d[8]
|
| 17 |
+
return (s - check) % 97 == 0 or (s + 55 - check) % 97 == 0
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
def _h_de_vat_mod11(value, **_kw):
|
| 21 |
+
# German VAT recursive mod-11 check digit.
|
| 22 |
+
# Value: 9 digits, DE prefix already stripped.
|
| 23 |
+
digits = re.sub(r"\D", "", value)
|
| 24 |
+
if len(digits) != 9:
|
| 25 |
+
return False
|
| 26 |
+
product = 10
|
| 27 |
+
for c in digits[:8]:
|
| 28 |
+
s = (int(c) + product) % 10
|
| 29 |
+
if s == 0:
|
| 30 |
+
s = 10
|
| 31 |
+
product = (2 * s) % 11
|
| 32 |
+
check = 11 - product
|
| 33 |
+
if check == 10:
|
| 34 |
+
check = 0
|
| 35 |
+
return int(digits[8]) == check
|
| 36 |
+
|
| 37 |
+
|
| 38 |
+
def _h_fr_vat_mod97(value, **_kw):
|
| 39 |
+
# French TVA key check.
|
| 40 |
+
# Value: 11 chars (FR prefix stripped): 2-char key + 9-digit SIREN.
|
| 41 |
+
# Numeric keys use the standard formula; alphanumeric keys use base-36 encoding.
|
| 42 |
+
if len(value) != 11:
|
| 43 |
+
return False
|
| 44 |
+
siren_str = value[2:]
|
| 45 |
+
if not siren_str.isdigit():
|
| 46 |
+
return False
|
| 47 |
+
siren = int(siren_str)
|
| 48 |
+
key = value[:2]
|
| 49 |
+
if key.isdigit():
|
| 50 |
+
return int(key) == (12 + 3 * (siren % 97)) % 97
|
| 51 |
+
def _char_val(c):
|
| 52 |
+
return int(c) if c.isdigit() else ord(c.upper()) - 55
|
| 53 |
+
k = _char_val(key[0]) * 36 + _char_val(key[1])
|
| 54 |
+
return k == (24 + 25 * (siren % 97)) % 97
|
| 55 |
+
|
| 56 |
+
|
| 57 |
+
def _h_it_vat_luhn(value, **_kw):
|
| 58 |
+
# Italian Partita IVA Luhn-style check digit.
|
| 59 |
+
# Value: 11 digits, IT prefix already stripped.
|
| 60 |
+
digits = re.sub(r"\D", "", value)
|
| 61 |
+
if len(digits) != 11:
|
| 62 |
+
return False
|
| 63 |
+
odd_sum = sum(int(digits[i]) for i in range(0, 10, 2))
|
| 64 |
+
even_sum = 0
|
| 65 |
+
for i in range(1, 10, 2):
|
| 66 |
+
d = int(digits[i]) * 2
|
| 67 |
+
if d > 9:
|
| 68 |
+
d -= 9
|
| 69 |
+
even_sum += d
|
| 70 |
+
total = odd_sum + even_sum
|
| 71 |
+
return int(digits[10]) == (10 - total % 10) % 10
|
| 72 |
+
|
| 73 |
+
|
| 74 |
+
def _h_es_nif(value, **_kw):
|
| 75 |
+
# Spanish NIF/CIF check character.
|
| 76 |
+
# Value: 9 chars (ES prefix stripped): [A-Z0-9][0-9]{7}[A-Z0-9].
|
| 77 |
+
if len(value) != 9:
|
| 78 |
+
return False
|
| 79 |
+
middle = value[1:8]
|
| 80 |
+
if not middle.isdigit():
|
| 81 |
+
return False
|
| 82 |
+
total = 0
|
| 83 |
+
for i, c in enumerate(middle):
|
| 84 |
+
d = int(c)
|
| 85 |
+
if i % 2 == 0:
|
| 86 |
+
d *= 2
|
| 87 |
+
if d > 9:
|
| 88 |
+
d -= 9
|
| 89 |
+
total += d
|
| 90 |
+
control = (10 - total % 10) % 10
|
| 91 |
+
check = value[8]
|
| 92 |
+
if check.isdigit():
|
| 93 |
+
return int(check) == control
|
| 94 |
+
return check.upper() == "JABCDEFGHI"[control]
|
| 95 |
+
|
| 96 |
+
|
| 97 |
+
def _h_gstin_crc(value, **_kw):
|
| 98 |
+
# Indian GSTIN Luhn-mod36 check digit (official GSTN algorithm).
|
| 99 |
+
# Value: 15-char GSTIN (no prefix removed — GSTIN has no country prefix in its pattern).
|
| 100 |
+
if len(value) != 15:
|
| 101 |
+
return False
|
| 102 |
+
charset = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ"
|
| 103 |
+
total = 0
|
| 104 |
+
factor = 2
|
| 105 |
+
for c in value[:14]:
|
| 106 |
+
if c not in charset:
|
| 107 |
+
return False
|
| 108 |
+
pos = charset.index(c)
|
| 109 |
+
product = factor * pos
|
| 110 |
+
total += product // 36 + product % 36
|
| 111 |
+
factor = 3 - factor
|
| 112 |
+
check_pos = (36 - total % 36) % 36
|
| 113 |
+
return value[14] == charset[check_pos]
|
| 114 |
+
|
| 115 |
+
|
| 116 |
+
def _h_abn_mod89(value, **_kw):
|
| 117 |
+
# Australian Business Number mod-89 weighted check.
|
| 118 |
+
# Value: 11 digits (no prefix removed).
|
| 119 |
+
digits = re.sub(r"\D", "", value)
|
| 120 |
+
if len(digits) != 11:
|
| 121 |
+
return False
|
| 122 |
+
weights = [10, 1, 3, 5, 7, 9, 11, 13, 15, 17, 19]
|
| 123 |
+
d = [int(c) for c in digits]
|
| 124 |
+
d[0] -= 1
|
| 125 |
+
return sum(d[i] * weights[i] for i in range(11)) % 89 == 0
|
| 126 |
+
|
| 127 |
+
|
| 128 |
+
def _h_bn_luhn(value, **_kw):
|
| 129 |
+
# Canadian Business Number Luhn check on the 9-digit registration number.
|
| 130 |
+
# The optional 6-char program account suffix (if present) is ignored.
|
| 131 |
+
nine = value[:9]
|
| 132 |
+
if len(nine) != 9 or not nine.isdigit():
|
| 133 |
+
return False
|
| 134 |
+
total = 0
|
| 135 |
+
for i in range(9):
|
| 136 |
+
d = int(nine[i])
|
| 137 |
+
if i % 2 == 1:
|
| 138 |
+
d *= 2
|
| 139 |
+
if d > 9:
|
| 140 |
+
d -= 9
|
| 141 |
+
total += d
|
| 142 |
+
return total % 10 == 0
|
| 143 |
+
|
| 144 |
+
|
| 145 |
+
def _h_sg_nric_fin(value, **_kw):
|
| 146 |
+
# Singapore NRIC/FIN check character.
|
| 147 |
+
# Value: 9 chars — [STFG][0-9]{7}[A-Z] (no prefix removed).
|
| 148 |
+
value = value.upper()
|
| 149 |
+
if len(value) != 9 or value[0] not in "STFG" or not value[1:8].isdigit():
|
| 150 |
+
return False
|
| 151 |
+
weights = [2, 7, 6, 5, 4, 3, 2]
|
| 152 |
+
total = sum(int(value[i + 1]) * weights[i] for i in range(7))
|
| 153 |
+
if value[0] in "TG":
|
| 154 |
+
total += 4
|
| 155 |
+
check_chars = "JZIHGFEDCBA" if value[0] in "ST" else "XWUTRQPNMLK"
|
| 156 |
+
return value[8] == check_chars[total % 11]
|
| 157 |
+
|
| 158 |
+
|
| 159 |
+
_BUILTIN_CHECKSUM_HANDLERS = {
|
| 160 |
+
"gb_vat_mod97": _h_gb_vat_mod97,
|
| 161 |
+
"de_vat_mod11": _h_de_vat_mod11,
|
| 162 |
+
"fr_vat_mod97": _h_fr_vat_mod97,
|
| 163 |
+
"it_vat_luhn": _h_it_vat_luhn,
|
| 164 |
+
"es_nif": _h_es_nif,
|
| 165 |
+
"gstin_crc": _h_gstin_crc,
|
| 166 |
+
"abn_mod89": _h_abn_mod89,
|
| 167 |
+
"bn_luhn": _h_bn_luhn,
|
| 168 |
+
"sg_nric_fin": _h_sg_nric_fin,
|
| 169 |
+
}
|
| 170 |
+
|
| 171 |
+
|
| 172 |
+
def run_tax_id_validation(
|
| 173 |
+
*,
|
| 174 |
+
document,
|
| 175 |
+
extracted_fields,
|
| 176 |
+
vendor,
|
| 177 |
+
vendor_tax_ids,
|
| 178 |
+
resolved_vendor_id,
|
| 179 |
+
platform_configs=None,
|
| 180 |
+
):
|
| 181 |
+
"""
|
| 182 |
+
Runs Tax ID Validation after vendor resolution.
|
| 183 |
+
|
| 184 |
+
Expected document fields:
|
| 185 |
+
document.id
|
| 186 |
+
document.tenant_id
|
| 187 |
+
|
| 188 |
+
Expected extracted_fields fields:
|
| 189 |
+
sender_tax_id
|
| 190 |
+
invoice_date
|
| 191 |
+
|
| 192 |
+
Expected vendor fields:
|
| 193 |
+
vendor.id
|
| 194 |
+
vendor.billing_country
|
| 195 |
+
vendor.tax_registration_number
|
| 196 |
+
|
| 197 |
+
Expected vendor_tax_ids row fields:
|
| 198 |
+
tenant_id
|
| 199 |
+
vendor_id
|
| 200 |
+
tax_id_canonical
|
| 201 |
+
tax_id_type
|
| 202 |
+
verified_at
|
| 203 |
+
deleted_at
|
| 204 |
+
effective_from
|
| 205 |
+
effective_to
|
| 206 |
+
"""
|
| 207 |
+
|
| 208 |
+
platform_configs = platform_configs or {}
|
| 209 |
+
|
| 210 |
+
tax_id_registry = platform_configs.get(
|
| 211 |
+
"validation.tax_id_family_registry",
|
| 212 |
+
{
|
| 213 |
+
"VAT-GB": {"pattern": r"^(GB)?[0-9]{9}([0-9]{3})?$", "checksum": "gb_vat_mod97"},
|
| 214 |
+
"VAT-DE": {"pattern": r"^(DE)?[0-9]{9}$", "checksum": "de_vat_mod11"},
|
| 215 |
+
"VAT-FR": {"pattern": r"^(FR)?[A-Z0-9]{2}[0-9]{9}$", "checksum": "fr_vat_mod97"},
|
| 216 |
+
"VAT-IT": {"pattern": r"^(IT)?[0-9]{11}$", "checksum": "it_vat_luhn"},
|
| 217 |
+
"VAT-ES": {"pattern": r"^(ES)?[A-Z0-9][0-9]{7}[A-Z0-9]$", "checksum": "es_nif"},
|
| 218 |
+
"VAT-NL": {"pattern": r"^(NL)?[0-9]{9}B[0-9]{2}$", "checksum": None},
|
| 219 |
+
"GSTIN-IN": {
|
| 220 |
+
"pattern": r"^[0-9]{2}[A-Z]{5}[0-9]{4}[A-Z][1-9A-Z]Z[0-9A-Z]$",
|
| 221 |
+
"checksum": "gstin_crc",
|
| 222 |
+
},
|
| 223 |
+
"PAN-IN": {"pattern": r"^[A-Z]{5}[0-9]{4}[A-Z]$", "checksum": None},
|
| 224 |
+
"EIN-US": {"pattern": r"^[0-9]{2}-?[0-9]{7}$", "checksum": None},
|
| 225 |
+
"ABN-AU": {"pattern": r"^[0-9]{11}$", "checksum": "abn_mod89"},
|
| 226 |
+
"BN-CA": {"pattern": r"^[0-9]{9}([A-Z]{2}[0-9]{4})?$", "checksum": "bn_luhn"},
|
| 227 |
+
"TIN-SG": {"pattern": r"^(?:[STFG][0-9]{7}[A-Z]|[0-9]{9}[A-Z])$", "checksum": "sg_nric_fin"},
|
| 228 |
+
},
|
| 229 |
+
)
|
| 230 |
+
|
| 231 |
+
# Platform can override built-in handlers via validation.tax_id_checksum_handlers.
|
| 232 |
+
# Each handler: callable(value: str, tax_id_type: str | None, country_code: str | None) -> bool
|
| 233 |
+
checksum_handlers = platform_configs.get("validation.tax_id_checksum_handlers", _BUILTIN_CHECKSUM_HANDLERS)
|
| 234 |
+
|
| 235 |
+
def hash_prefix(value, length=8):
|
| 236 |
+
if not value:
|
| 237 |
+
return None
|
| 238 |
+
return sha256(str(value).encode("utf-8")).hexdigest()[:length]
|
| 239 |
+
|
| 240 |
+
def normalise_tax_id(value):
|
| 241 |
+
if value is None:
|
| 242 |
+
return None
|
| 243 |
+
|
| 244 |
+
value = normalize("NFKC", str(value))
|
| 245 |
+
value = re.sub(r"[\s\-\.,/\\:;_]+", "", value)
|
| 246 |
+
value = value.upper().strip()
|
| 247 |
+
return value or None
|
| 248 |
+
|
| 249 |
+
def country_from_tax_id_type(tax_id_type):
|
| 250 |
+
if not tax_id_type or "-" not in tax_id_type:
|
| 251 |
+
return None
|
| 252 |
+
return tax_id_type.split("-")[-1]
|
| 253 |
+
|
| 254 |
+
def registry_entries_for_country(country_code):
|
| 255 |
+
if not country_code:
|
| 256 |
+
return list(tax_id_registry.items())
|
| 257 |
+
|
| 258 |
+
country_code = str(country_code).upper()
|
| 259 |
+
return [
|
| 260 |
+
(tax_id_type, config)
|
| 261 |
+
for tax_id_type, config in tax_id_registry.items()
|
| 262 |
+
if country_from_tax_id_type(tax_id_type) == country_code
|
| 263 |
+
]
|
| 264 |
+
|
| 265 |
+
def remove_country_prefix(value, tax_id_type):
|
| 266 |
+
country_code = country_from_tax_id_type(tax_id_type)
|
| 267 |
+
if country_code and value.startswith(country_code):
|
| 268 |
+
return value[len(country_code):]
|
| 269 |
+
return value
|
| 270 |
+
|
| 271 |
+
def checksum_valid(value, checksum_name, tax_id_type=None, country_code=None):
|
| 272 |
+
"""
|
| 273 |
+
Real checksum gate:
|
| 274 |
+
- if checksum is not configured, treat as valid
|
| 275 |
+
- if a handler is provided by platform configs, use it
|
| 276 |
+
- otherwise fail closed (False) so invalid IDs do not silently pass
|
| 277 |
+
"""
|
| 278 |
+
if checksum_name is None:
|
| 279 |
+
return True
|
| 280 |
+
|
| 281 |
+
handler = checksum_handlers.get(checksum_name)
|
| 282 |
+
if callable(handler):
|
| 283 |
+
return bool(handler(value, tax_id_type=tax_id_type, country_code=country_code))
|
| 284 |
+
|
| 285 |
+
return False
|
| 286 |
+
|
| 287 |
+
def canonicalise_tax_id(value, id_type=None, country_code=None):
|
| 288 |
+
stripped = normalise_tax_id(value)
|
| 289 |
+
|
| 290 |
+
if not stripped:
|
| 291 |
+
return {
|
| 292 |
+
"canonical": None,
|
| 293 |
+
"inferred_id_type": None,
|
| 294 |
+
"country_code": None,
|
| 295 |
+
"format_valid": None,
|
| 296 |
+
"checksum_valid": None,
|
| 297 |
+
"validation_passed": None,
|
| 298 |
+
}
|
| 299 |
+
|
| 300 |
+
resolved_id_type = None
|
| 301 |
+
registry_config = None
|
| 302 |
+
|
| 303 |
+
# Master path: direct lookup on (id_type, country_code).
|
| 304 |
+
if id_type:
|
| 305 |
+
resolved_id_type = id_type
|
| 306 |
+
registry_config = tax_id_registry.get(id_type)
|
| 307 |
+
# Cross-check: country embedded in id_type must match supplied country_code.
|
| 308 |
+
if registry_config and country_code:
|
| 309 |
+
embedded = country_from_tax_id_type(id_type)
|
| 310 |
+
if embedded and str(embedded).upper() != str(country_code).upper():
|
| 311 |
+
registry_config = None
|
| 312 |
+
|
| 313 |
+
# Invoice path: infer by trying registry patterns for the supplied country.
|
| 314 |
+
else:
|
| 315 |
+
for candidate_id_type, candidate_config in registry_entries_for_country(country_code):
|
| 316 |
+
if re.match(candidate_config["pattern"], stripped):
|
| 317 |
+
resolved_id_type = candidate_id_type
|
| 318 |
+
registry_config = candidate_config
|
| 319 |
+
break
|
| 320 |
+
|
| 321 |
+
# No registry entry matched: return stripped canonical value, no format/checksum enforcement.
|
| 322 |
+
if not registry_config:
|
| 323 |
+
return {
|
| 324 |
+
"canonical": stripped,
|
| 325 |
+
"inferred_id_type": None,
|
| 326 |
+
"country_code": None,
|
| 327 |
+
"format_valid": None,
|
| 328 |
+
"checksum_valid": None,
|
| 329 |
+
"validation_passed": None,
|
| 330 |
+
}
|
| 331 |
+
|
| 332 |
+
canonical = remove_country_prefix(stripped, resolved_id_type)
|
| 333 |
+
format_valid = bool(re.match(registry_config["pattern"], stripped))
|
| 334 |
+
checksum_ok = checksum_valid(
|
| 335 |
+
canonical,
|
| 336 |
+
registry_config.get("checksum"),
|
| 337 |
+
tax_id_type=resolved_id_type,
|
| 338 |
+
country_code=country_from_tax_id_type(resolved_id_type),
|
| 339 |
+
)
|
| 340 |
+
|
| 341 |
+
return {
|
| 342 |
+
"canonical": canonical,
|
| 343 |
+
"inferred_id_type": resolved_id_type,
|
| 344 |
+
"country_code": country_from_tax_id_type(resolved_id_type),
|
| 345 |
+
"format_valid": format_valid,
|
| 346 |
+
"checksum_valid": checksum_ok,
|
| 347 |
+
"validation_passed": bool(format_valid and checksum_ok),
|
| 348 |
+
}
|
| 349 |
+
|
| 350 |
+
def is_effective(row, invoice_date):
|
| 351 |
+
if getattr(row, "deleted_at", None) is not None:
|
| 352 |
+
return False
|
| 353 |
+
|
| 354 |
+
if invoice_date is None:
|
| 355 |
+
return False
|
| 356 |
+
|
| 357 |
+
effective_from = getattr(row, "effective_from", None)
|
| 358 |
+
effective_to = getattr(row, "effective_to", None)
|
| 359 |
+
|
| 360 |
+
if effective_from and invoice_date < effective_from:
|
| 361 |
+
return False
|
| 362 |
+
|
| 363 |
+
if effective_to and invoice_date >= effective_to:
|
| 364 |
+
return False
|
| 365 |
+
|
| 366 |
+
return True
|
| 367 |
+
|
| 368 |
+
risk_flags = []
|
| 369 |
+
advisory_flags = []
|
| 370 |
+
skipped_steps = []
|
| 371 |
+
|
| 372 |
+
if resolved_vendor_id is None:
|
| 373 |
+
skipped_steps.append(
|
| 374 |
+
{
|
| 375 |
+
"step": "tax_id_validation",
|
| 376 |
+
"skip_reason": "vendor_unresolved",
|
| 377 |
+
}
|
| 378 |
+
)
|
| 379 |
+
return {
|
| 380 |
+
"risk_flags": risk_flags,
|
| 381 |
+
"advisory_flags": advisory_flags,
|
| 382 |
+
"skipped_steps": skipped_steps,
|
| 383 |
+
"audit_payload": None,
|
| 384 |
+
"match_result": "skipped",
|
| 385 |
+
}
|
| 386 |
+
|
| 387 |
+
invoice_tax_id = getattr(extracted_fields, "sender_tax_id", None)
|
| 388 |
+
if not invoice_tax_id:
|
| 389 |
+
skipped_steps.append(
|
| 390 |
+
{
|
| 391 |
+
"step": "tax_id_validation",
|
| 392 |
+
"skip_reason": "no_invoice_tax_id",
|
| 393 |
+
}
|
| 394 |
+
)
|
| 395 |
+
return {
|
| 396 |
+
"risk_flags": risk_flags,
|
| 397 |
+
"advisory_flags": advisory_flags,
|
| 398 |
+
"skipped_steps": skipped_steps,
|
| 399 |
+
"audit_payload": None,
|
| 400 |
+
"match_result": "skipped",
|
| 401 |
+
}
|
| 402 |
+
|
| 403 |
+
invoice_date = getattr(extracted_fields, "invoice_date", None)
|
| 404 |
+
if isinstance(invoice_date, datetime):
|
| 405 |
+
invoice_date = invoice_date.date()
|
| 406 |
+
elif invoice_date is not None and not isinstance(invoice_date, date):
|
| 407 |
+
# Do not invent today's date; treat unknown types as unavailable.
|
| 408 |
+
invoice_date = None
|
| 409 |
+
|
| 410 |
+
vendor_country = getattr(vendor, "billing_country", None)
|
| 411 |
+
|
| 412 |
+
invoice_canonical_result = canonicalise_tax_id(
|
| 413 |
+
invoice_tax_id,
|
| 414 |
+
id_type=None,
|
| 415 |
+
country_code=vendor_country,
|
| 416 |
+
)
|
| 417 |
+
|
| 418 |
+
invoice_canonical = invoice_canonical_result["canonical"]
|
| 419 |
+
invoice_inferred_id_type = invoice_canonical_result["inferred_id_type"]
|
| 420 |
+
invoice_inferred_country = invoice_canonical_result["country_code"]
|
| 421 |
+
|
| 422 |
+
if not invoice_canonical:
|
| 423 |
+
skipped_steps.append({"step": "tax_id_validation", "skip_reason": "no_invoice_tax_id"})
|
| 424 |
+
return {
|
| 425 |
+
"risk_flags": risk_flags,
|
| 426 |
+
"advisory_flags": advisory_flags,
|
| 427 |
+
"skipped_steps": skipped_steps,
|
| 428 |
+
"audit_payload": None,
|
| 429 |
+
"match_result": "skipped",
|
| 430 |
+
}
|
| 431 |
+
|
| 432 |
+
candidate_rows_for_vendor = [
|
| 433 |
+
row
|
| 434 |
+
for row in vendor_tax_ids
|
| 435 |
+
if getattr(row, "tenant_id", None) == document.tenant_id
|
| 436 |
+
and getattr(row, "vendor_id", None) == resolved_vendor_id
|
| 437 |
+
and getattr(row, "deleted_at", None) is None
|
| 438 |
+
]
|
| 439 |
+
|
| 440 |
+
effective_candidate_rows = [
|
| 441 |
+
row
|
| 442 |
+
for row in candidate_rows_for_vendor
|
| 443 |
+
if is_effective(row, invoice_date)
|
| 444 |
+
]
|
| 445 |
+
|
| 446 |
+
matching_vendor_tax_row = None
|
| 447 |
+
for row in effective_candidate_rows:
|
| 448 |
+
if getattr(row, "tax_id_canonical", None) == invoice_canonical:
|
| 449 |
+
matching_vendor_tax_row = row
|
| 450 |
+
break
|
| 451 |
+
|
| 452 |
+
master_fallback_result = None
|
| 453 |
+
fallback_master_canonical = None
|
| 454 |
+
|
| 455 |
+
if not candidate_rows_for_vendor and getattr(vendor, "tax_registration_number", None):
|
| 456 |
+
master_fallback_result = canonicalise_tax_id(
|
| 457 |
+
vendor.tax_registration_number,
|
| 458 |
+
id_type=None,
|
| 459 |
+
country_code=vendor_country,
|
| 460 |
+
)
|
| 461 |
+
fallback_master_canonical = master_fallback_result["canonical"]
|
| 462 |
+
|
| 463 |
+
match_result = "unmatched"
|
| 464 |
+
master_hash_prefix = None
|
| 465 |
+
audit_id_type = invoice_inferred_id_type
|
| 466 |
+
|
| 467 |
+
if matching_vendor_tax_row:
|
| 468 |
+
match_result = "matched"
|
| 469 |
+
master_hash_prefix = hash_prefix(matching_vendor_tax_row.tax_id_canonical)
|
| 470 |
+
audit_id_type = getattr(matching_vendor_tax_row, "tax_id_type", None)
|
| 471 |
+
|
| 472 |
+
if getattr(matching_vendor_tax_row, "verified_at", None) is None:
|
| 473 |
+
advisory_flags.append(
|
| 474 |
+
{
|
| 475 |
+
"code": "tax_id_unverified_master",
|
| 476 |
+
"severity": "advisory",
|
| 477 |
+
"vendor_id": resolved_vendor_id,
|
| 478 |
+
}
|
| 479 |
+
)
|
| 480 |
+
|
| 481 |
+
# Country mismatch only after a real successful match path.
|
| 482 |
+
if (
|
| 483 |
+
invoice_inferred_country
|
| 484 |
+
and vendor_country
|
| 485 |
+
and str(invoice_inferred_country).upper() != str(vendor_country).upper()
|
| 486 |
+
):
|
| 487 |
+
risk_flags.append(
|
| 488 |
+
{
|
| 489 |
+
"code": "tax_id_country_mismatch",
|
| 490 |
+
"severity": "medium",
|
| 491 |
+
"weight": 0.25,
|
| 492 |
+
"invoice_country": invoice_inferred_country,
|
| 493 |
+
"vendor_country": vendor_country,
|
| 494 |
+
}
|
| 495 |
+
)
|
| 496 |
+
|
| 497 |
+
elif not candidate_rows_for_vendor and fallback_master_canonical:
|
| 498 |
+
# Fallback only when there are no structured vendor_tax_ids rows at all.
|
| 499 |
+
master_hash_prefix = hash_prefix(fallback_master_canonical)
|
| 500 |
+
|
| 501 |
+
if invoice_canonical == fallback_master_canonical:
|
| 502 |
+
match_result = "matched_fallback"
|
| 503 |
+
if (
|
| 504 |
+
invoice_inferred_country
|
| 505 |
+
and vendor_country
|
| 506 |
+
and str(invoice_inferred_country).upper() != str(vendor_country).upper()
|
| 507 |
+
):
|
| 508 |
+
risk_flags.append(
|
| 509 |
+
{
|
| 510 |
+
"code": "tax_id_country_mismatch",
|
| 511 |
+
"severity": "medium",
|
| 512 |
+
"weight": 0.25,
|
| 513 |
+
"invoice_country": invoice_inferred_country,
|
| 514 |
+
"vendor_country": vendor_country,
|
| 515 |
+
}
|
| 516 |
+
)
|
| 517 |
+
else:
|
| 518 |
+
risk_flags.append(
|
| 519 |
+
{
|
| 520 |
+
"code": "tax_id_mismatch",
|
| 521 |
+
"severity": "high",
|
| 522 |
+
"vendor_id": resolved_vendor_id,
|
| 523 |
+
}
|
| 524 |
+
)
|
| 525 |
+
|
| 526 |
+
else:
|
| 527 |
+
risk_flags.append(
|
| 528 |
+
{
|
| 529 |
+
"code": "tax_id_mismatch",
|
| 530 |
+
"severity": "high",
|
| 531 |
+
"vendor_id": resolved_vendor_id,
|
| 532 |
+
}
|
| 533 |
+
)
|
| 534 |
+
|
| 535 |
+
audit_payload = {
|
| 536 |
+
"document_id": document.id,
|
| 537 |
+
"vendor_id": resolved_vendor_id,
|
| 538 |
+
"match_result": match_result,
|
| 539 |
+
"invoice_hash_prefix": hash_prefix(invoice_canonical),
|
| 540 |
+
"master_hash_prefix": master_hash_prefix,
|
| 541 |
+
"id_type": audit_id_type,
|
| 542 |
+
"country_code": invoice_inferred_country or vendor_country,
|
| 543 |
+
"called_at": datetime.now(timezone.utc).isoformat(),
|
| 544 |
+
}
|
| 545 |
+
|
| 546 |
+
return {
|
| 547 |
+
"risk_flags": risk_flags,
|
| 548 |
+
"advisory_flags": advisory_flags,
|
| 549 |
+
"skipped_steps": skipped_steps,
|
| 550 |
+
"audit_payload": audit_payload,
|
| 551 |
+
"match_result": match_result,
|
| 552 |
+
"invoice_tax_id_canonical": invoice_canonical,
|
| 553 |
+
"invoice_tax_id_inferred_type": invoice_inferred_id_type,
|
| 554 |
+
"invoice_tax_id_format_valid": invoice_canonical_result.get("format_valid"),
|
| 555 |
+
"invoice_tax_id_checksum_valid": invoice_canonical_result.get("checksum_valid"),
|
| 556 |
+
}
|
src/Invoice Validation Functions/Vendor Validation.py
ADDED
|
@@ -0,0 +1,385 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from decimal import Decimal
|
| 2 |
+
from hashlib import sha256
|
| 3 |
+
from unicodedata import normalize
|
| 4 |
+
from uuid import uuid4
|
| 5 |
+
from datetime import datetime, timezone
|
| 6 |
+
|
| 7 |
+
from rapidfuzz import fuzz, process
|
| 8 |
+
|
| 9 |
+
|
| 10 |
+
def run_vendor_validation(
|
| 11 |
+
*,
|
| 12 |
+
document,
|
| 13 |
+
extracted_fields,
|
| 14 |
+
vendors,
|
| 15 |
+
platform_configs,
|
| 16 |
+
route_contexts=None,
|
| 17 |
+
actor_id=None,
|
| 18 |
+
):
|
| 19 |
+
"""
|
| 20 |
+
Runs Cat 2 vendor validation using RapidFuzz instead of the platform name-match algorithm.
|
| 21 |
+
|
| 22 |
+
Expected document fields:
|
| 23 |
+
document.id
|
| 24 |
+
document.tenant_id
|
| 25 |
+
|
| 26 |
+
Expected extracted_fields fields:
|
| 27 |
+
sender_name
|
| 28 |
+
sender_name_canonical
|
| 29 |
+
sender_tax_id_canonical
|
| 30 |
+
bank_iban_canonical
|
| 31 |
+
|
| 32 |
+
Expected vendor fields:
|
| 33 |
+
id
|
| 34 |
+
tenant_id
|
| 35 |
+
legal_name
|
| 36 |
+
vendor_name
|
| 37 |
+
legal_name_canonical
|
| 38 |
+
billing_country
|
| 39 |
+
billing_address
|
| 40 |
+
vendor_status
|
| 41 |
+
deleted_at
|
| 42 |
+
|
| 43 |
+
Returns a dict containing:
|
| 44 |
+
resolved_vendor_id
|
| 45 |
+
resolved_sender_name
|
| 46 |
+
resolved_sender_addr
|
| 47 |
+
risk_flags
|
| 48 |
+
advisory_flags
|
| 49 |
+
skipped_steps
|
| 50 |
+
audit_row
|
| 51 |
+
match_result
|
| 52 |
+
"""
|
| 53 |
+
|
| 54 |
+
route_contexts = set(route_contexts or [])
|
| 55 |
+
|
| 56 |
+
require_high_tier_contexts = set(
|
| 57 |
+
platform_configs.get(
|
| 58 |
+
"validation.vendor_match_require_high_tier_contexts",
|
| 59 |
+
["contract_match", "bank_validation", "approval_revalidation"],
|
| 60 |
+
)
|
| 61 |
+
)
|
| 62 |
+
|
| 63 |
+
algorithm_version = platform_configs.get(
|
| 64 |
+
"validation.name_match_algorithm_version",
|
| 65 |
+
"rapidfuzz.local.v1",
|
| 66 |
+
)
|
| 67 |
+
|
| 68 |
+
high_threshold = Decimal(str(platform_configs.get("validation.rapidfuzz_high_threshold", "0.90")))
|
| 69 |
+
medium_threshold = Decimal(str(platform_configs.get("validation.rapidfuzz_medium_threshold", "0.75")))
|
| 70 |
+
suggestion_threshold = Decimal(str(platform_configs.get("validation.rapidfuzz_suggestion_threshold", "0.60")))
|
| 71 |
+
|
| 72 |
+
def canonicalize(value):
|
| 73 |
+
if not value:
|
| 74 |
+
return ""
|
| 75 |
+
return normalize("NFKC", str(value)).lower().strip()
|
| 76 |
+
|
| 77 |
+
def hash_prefix(value, length=8):
|
| 78 |
+
if not value:
|
| 79 |
+
return None
|
| 80 |
+
return sha256(str(value).encode("utf-8")).hexdigest()[:length]
|
| 81 |
+
|
| 82 |
+
def get_vendor_names(vendor):
|
| 83 |
+
names = []
|
| 84 |
+
|
| 85 |
+
for name in (
|
| 86 |
+
getattr(vendor, "legal_name", None),
|
| 87 |
+
getattr(vendor, "vendor_name", None),
|
| 88 |
+
):
|
| 89 |
+
canonical = canonicalize(name)
|
| 90 |
+
if canonical and canonical not in names:
|
| 91 |
+
names.append(canonical)
|
| 92 |
+
|
| 93 |
+
return names
|
| 94 |
+
|
| 95 |
+
def build_candidate_pool():
|
| 96 |
+
pool = []
|
| 97 |
+
|
| 98 |
+
for vendor in vendors:
|
| 99 |
+
if getattr(vendor, "tenant_id", None) != document.tenant_id:
|
| 100 |
+
continue
|
| 101 |
+
|
| 102 |
+
if getattr(vendor, "vendor_status", None) == "deleted":
|
| 103 |
+
continue
|
| 104 |
+
|
| 105 |
+
if getattr(vendor, "deleted_at", None) is not None:
|
| 106 |
+
continue
|
| 107 |
+
|
| 108 |
+
names = get_vendor_names(vendor)
|
| 109 |
+
if not names:
|
| 110 |
+
continue
|
| 111 |
+
|
| 112 |
+
pool.append(
|
| 113 |
+
{
|
| 114 |
+
"record_id": vendor.id,
|
| 115 |
+
"vendor": vendor,
|
| 116 |
+
"names": names,
|
| 117 |
+
"auxiliary": {
|
| 118 |
+
"country": getattr(vendor, "billing_country", None),
|
| 119 |
+
"tax_id_hash": hash_prefix(
|
| 120 |
+
getattr(extracted_fields, "sender_tax_id_canonical", None)
|
| 121 |
+
),
|
| 122 |
+
"iban_hash": hash_prefix(
|
| 123 |
+
getattr(extracted_fields, "bank_iban_canonical", None)
|
| 124 |
+
),
|
| 125 |
+
},
|
| 126 |
+
}
|
| 127 |
+
)
|
| 128 |
+
|
| 129 |
+
return pool
|
| 130 |
+
|
| 131 |
+
def rapidfuzz_name_match(query_name, candidate_pool):
|
| 132 |
+
choices = []
|
| 133 |
+
|
| 134 |
+
for candidate in candidate_pool:
|
| 135 |
+
for name in candidate["names"]:
|
| 136 |
+
choices.append(
|
| 137 |
+
{
|
| 138 |
+
"name": name,
|
| 139 |
+
"record_id": candidate["record_id"],
|
| 140 |
+
"vendor": candidate["vendor"],
|
| 141 |
+
}
|
| 142 |
+
)
|
| 143 |
+
|
| 144 |
+
if not query_name or not choices:
|
| 145 |
+
return {
|
| 146 |
+
"decision": "unmatched",
|
| 147 |
+
"matched_record_id": None,
|
| 148 |
+
"matched_vendor": None,
|
| 149 |
+
"runner_up_record_id": None,
|
| 150 |
+
"runner_up_vendor": None,
|
| 151 |
+
"score": Decimal("0"),
|
| 152 |
+
"score_tier": "low",
|
| 153 |
+
"algorithm_version": algorithm_version,
|
| 154 |
+
"trace_id": str(uuid4()),
|
| 155 |
+
}
|
| 156 |
+
|
| 157 |
+
raw_matches = process.extract(
|
| 158 |
+
query_name,
|
| 159 |
+
[choice["name"] for choice in choices],
|
| 160 |
+
scorer=fuzz.WRatio,
|
| 161 |
+
limit=5,
|
| 162 |
+
)
|
| 163 |
+
|
| 164 |
+
ranked = []
|
| 165 |
+
seen_record_ids = set()
|
| 166 |
+
|
| 167 |
+
for matched_name, score, choice_index in raw_matches:
|
| 168 |
+
choice = choices[choice_index]
|
| 169 |
+
record_id = choice["record_id"]
|
| 170 |
+
|
| 171 |
+
if record_id in seen_record_ids:
|
| 172 |
+
continue
|
| 173 |
+
|
| 174 |
+
seen_record_ids.add(record_id)
|
| 175 |
+
|
| 176 |
+
ranked.append(
|
| 177 |
+
{
|
| 178 |
+
"record_id": record_id,
|
| 179 |
+
"vendor": choice["vendor"],
|
| 180 |
+
"matched_name": matched_name,
|
| 181 |
+
"score": Decimal(str(score / 100)).quantize(Decimal("0.0001")),
|
| 182 |
+
}
|
| 183 |
+
)
|
| 184 |
+
|
| 185 |
+
if not ranked:
|
| 186 |
+
best = None
|
| 187 |
+
runner_up = None
|
| 188 |
+
score = Decimal("0")
|
| 189 |
+
else:
|
| 190 |
+
best = ranked[0]
|
| 191 |
+
runner_up = ranked[1] if len(ranked) > 1 else None
|
| 192 |
+
score = best["score"]
|
| 193 |
+
|
| 194 |
+
if score >= high_threshold:
|
| 195 |
+
decision = "matched"
|
| 196 |
+
score_tier = "high"
|
| 197 |
+
matched_record_id = best["record_id"]
|
| 198 |
+
matched_vendor = best["vendor"]
|
| 199 |
+
elif score >= medium_threshold:
|
| 200 |
+
decision = "matched"
|
| 201 |
+
score_tier = "medium"
|
| 202 |
+
matched_record_id = best["record_id"]
|
| 203 |
+
matched_vendor = best["vendor"]
|
| 204 |
+
elif score >= suggestion_threshold:
|
| 205 |
+
decision = "suggestion"
|
| 206 |
+
score_tier = "low"
|
| 207 |
+
matched_record_id = None
|
| 208 |
+
matched_vendor = None
|
| 209 |
+
else:
|
| 210 |
+
decision = "unmatched"
|
| 211 |
+
score_tier = "low"
|
| 212 |
+
matched_record_id = None
|
| 213 |
+
matched_vendor = None
|
| 214 |
+
|
| 215 |
+
return {
|
| 216 |
+
"decision": decision,
|
| 217 |
+
"matched_record_id": matched_record_id,
|
| 218 |
+
"matched_vendor": matched_vendor,
|
| 219 |
+
"runner_up_record_id": runner_up["record_id"] if runner_up else (
|
| 220 |
+
best["record_id"] if decision == "suggestion" and best else None
|
| 221 |
+
),
|
| 222 |
+
"runner_up_vendor": runner_up["vendor"] if runner_up else (
|
| 223 |
+
best["vendor"] if decision == "suggestion" and best else None
|
| 224 |
+
),
|
| 225 |
+
"score": score,
|
| 226 |
+
"score_tier": score_tier,
|
| 227 |
+
"algorithm_version": algorithm_version,
|
| 228 |
+
"trace_id": str(uuid4()),
|
| 229 |
+
}
|
| 230 |
+
|
| 231 |
+
risk_flags = []
|
| 232 |
+
advisory_flags = []
|
| 233 |
+
skipped_steps = []
|
| 234 |
+
|
| 235 |
+
resolved_vendor_id = None
|
| 236 |
+
resolved_sender_name = None
|
| 237 |
+
resolved_sender_addr = None
|
| 238 |
+
|
| 239 |
+
query_name = canonicalize(
|
| 240 |
+
getattr(extracted_fields, "sender_name_canonical", None)
|
| 241 |
+
or getattr(extracted_fields, "sender_name", None)
|
| 242 |
+
)
|
| 243 |
+
|
| 244 |
+
candidate_pool = build_candidate_pool()
|
| 245 |
+
|
| 246 |
+
match_result = rapidfuzz_name_match(
|
| 247 |
+
query_name=query_name,
|
| 248 |
+
candidate_pool=candidate_pool,
|
| 249 |
+
)
|
| 250 |
+
|
| 251 |
+
decision = match_result["decision"]
|
| 252 |
+
score_tier = match_result["score_tier"]
|
| 253 |
+
|
| 254 |
+
control_context_requires_high = bool(route_contexts & require_high_tier_contexts)
|
| 255 |
+
|
| 256 |
+
if decision == "matched" and score_tier == "high":
|
| 257 |
+
resolved_vendor_id = match_result["matched_record_id"]
|
| 258 |
+
|
| 259 |
+
elif decision == "matched" and score_tier == "medium":
|
| 260 |
+
if control_context_requires_high:
|
| 261 |
+
match_result["decision"] = "suggestion"
|
| 262 |
+
advisory_flags.append(
|
| 263 |
+
{
|
| 264 |
+
"code": "vendor_suggestion",
|
| 265 |
+
"severity": "advisory",
|
| 266 |
+
"record_id": match_result["matched_record_id"],
|
| 267 |
+
"reason": "medium_tier_match_downgraded_for_control_context",
|
| 268 |
+
}
|
| 269 |
+
)
|
| 270 |
+
skipped_steps.extend(
|
| 271 |
+
[
|
| 272 |
+
{"step": "vendor_tax_validation", "skip_reason": "vendor_unresolved"},
|
| 273 |
+
{"step": "vendor_bank_validation", "skip_reason": "vendor_unresolved"},
|
| 274 |
+
{"step": "vendor_status_validation", "skip_reason": "vendor_unresolved"},
|
| 275 |
+
]
|
| 276 |
+
)
|
| 277 |
+
else:
|
| 278 |
+
resolved_vendor_id = match_result["matched_record_id"]
|
| 279 |
+
risk_flags.append(
|
| 280 |
+
{
|
| 281 |
+
"code": "low_confidence_vendor_resolution",
|
| 282 |
+
"severity": "medium",
|
| 283 |
+
"record_id": resolved_vendor_id,
|
| 284 |
+
"score_tier": score_tier,
|
| 285 |
+
}
|
| 286 |
+
)
|
| 287 |
+
|
| 288 |
+
elif decision == "suggestion":
|
| 289 |
+
advisory_flags.append(
|
| 290 |
+
{
|
| 291 |
+
"code": "vendor_suggestion",
|
| 292 |
+
"severity": "advisory",
|
| 293 |
+
"record_id": match_result["runner_up_record_id"],
|
| 294 |
+
"reason": "possible_vendor_match",
|
| 295 |
+
}
|
| 296 |
+
)
|
| 297 |
+
skipped_steps.extend(
|
| 298 |
+
[
|
| 299 |
+
{"step": "sender_field_override", "skip_reason": "vendor_unresolved"},
|
| 300 |
+
{"step": "vendor_tax_validation", "skip_reason": "vendor_unresolved"},
|
| 301 |
+
{"step": "vendor_bank_validation", "skip_reason": "vendor_unresolved"},
|
| 302 |
+
{"step": "vendor_status_validation", "skip_reason": "vendor_unresolved"},
|
| 303 |
+
]
|
| 304 |
+
)
|
| 305 |
+
|
| 306 |
+
else:
|
| 307 |
+
risk_flags.append(
|
| 308 |
+
{
|
| 309 |
+
"code": "unknown_vendor",
|
| 310 |
+
"severity": "high",
|
| 311 |
+
"reason": "no_vendor_match_found",
|
| 312 |
+
}
|
| 313 |
+
)
|
| 314 |
+
skipped_steps.extend(
|
| 315 |
+
[
|
| 316 |
+
{"step": "sender_field_override", "skip_reason": "vendor_unresolved"},
|
| 317 |
+
{"step": "vendor_tax_validation", "skip_reason": "vendor_unresolved"},
|
| 318 |
+
{"step": "vendor_bank_validation", "skip_reason": "vendor_unresolved"},
|
| 319 |
+
{"step": "vendor_status_validation", "skip_reason": "vendor_unresolved"},
|
| 320 |
+
]
|
| 321 |
+
)
|
| 322 |
+
|
| 323 |
+
blocked_vendor = None
|
| 324 |
+
|
| 325 |
+
if match_result["decision"] in ("matched", "suggestion"):
|
| 326 |
+
if match_result.get("matched_vendor") and match_result["matched_vendor"].vendor_status == "blocked":
|
| 327 |
+
blocked_vendor = match_result["matched_vendor"]
|
| 328 |
+
|
| 329 |
+
if match_result.get("runner_up_vendor") and match_result["runner_up_vendor"].vendor_status == "blocked":
|
| 330 |
+
blocked_vendor = match_result["runner_up_vendor"]
|
| 331 |
+
|
| 332 |
+
if blocked_vendor:
|
| 333 |
+
risk_flags.append(
|
| 334 |
+
{
|
| 335 |
+
"code": "vendor_blocked",
|
| 336 |
+
"severity": "high",
|
| 337 |
+
"record_id": blocked_vendor.id,
|
| 338 |
+
"reason": "matched_or_suggested_vendor_is_blocked",
|
| 339 |
+
}
|
| 340 |
+
)
|
| 341 |
+
|
| 342 |
+
if resolved_vendor_id:
|
| 343 |
+
resolved_vendor = match_result["matched_vendor"]
|
| 344 |
+
|
| 345 |
+
resolved_sender_name = (
|
| 346 |
+
getattr(resolved_vendor, "legal_name", None)
|
| 347 |
+
or getattr(resolved_vendor, "vendor_name", None)
|
| 348 |
+
)
|
| 349 |
+
|
| 350 |
+
billing_address = getattr(resolved_vendor, "billing_address", None)
|
| 351 |
+
if billing_address is not None:
|
| 352 |
+
resolved_sender_addr = billing_address
|
| 353 |
+
|
| 354 |
+
audit_row = {
|
| 355 |
+
"document_id": document.id,
|
| 356 |
+
"query_name_hash_prefix_8": hash_prefix(query_name, length=8),
|
| 357 |
+
"algorithm_version": algorithm_version,
|
| 358 |
+
"decision": match_result["decision"],
|
| 359 |
+
"matched_record_id": match_result["matched_record_id"],
|
| 360 |
+
"runner_up_record_id": match_result["runner_up_record_id"],
|
| 361 |
+
"score_tier": match_result["score_tier"],
|
| 362 |
+
"score": str(match_result["score"]),
|
| 363 |
+
"trace_id": match_result["trace_id"],
|
| 364 |
+
"called_at": datetime.now(timezone.utc),
|
| 365 |
+
"actor_id": actor_id,
|
| 366 |
+
}
|
| 367 |
+
|
| 368 |
+
return {
|
| 369 |
+
"resolved_vendor_id": resolved_vendor_id,
|
| 370 |
+
"resolved_sender_name": resolved_sender_name,
|
| 371 |
+
"resolved_sender_addr": resolved_sender_addr,
|
| 372 |
+
"risk_flags": risk_flags,
|
| 373 |
+
"advisory_flags": advisory_flags,
|
| 374 |
+
"skipped_steps": skipped_steps,
|
| 375 |
+
"audit_row": audit_row,
|
| 376 |
+
"match_result": {
|
| 377 |
+
"decision": match_result["decision"],
|
| 378 |
+
"matched_record_id": resolved_vendor_id,
|
| 379 |
+
"runner_up_record_id": match_result["runner_up_record_id"],
|
| 380 |
+
"score": match_result["score"],
|
| 381 |
+
"score_tier": match_result["score_tier"],
|
| 382 |
+
"algorithm_version": match_result["algorithm_version"],
|
| 383 |
+
"trace_id": match_result["trace_id"],
|
| 384 |
+
},
|
| 385 |
+
}
|
src/Invoice Validation Functions/completeness.py
ADDED
|
@@ -0,0 +1,169 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
|
| 2 |
+
from datetime import date, datetime
|
| 3 |
+
from decimal import Decimal, InvalidOperation
|
| 4 |
+
|
| 5 |
+
|
| 6 |
+
def parse_date(value):
|
| 7 |
+
if value in (None, ""):
|
| 8 |
+
return None
|
| 9 |
+
if isinstance(value, datetime):
|
| 10 |
+
return value.date()
|
| 11 |
+
if isinstance(value, date):
|
| 12 |
+
return value
|
| 13 |
+
try:
|
| 14 |
+
return datetime.strptime(str(value), "%Y-%m-%d").date()
|
| 15 |
+
except ValueError:
|
| 16 |
+
return None
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
def parse_decimal(value):
|
| 20 |
+
if value in (None, ""):
|
| 21 |
+
return None
|
| 22 |
+
if isinstance(value, Decimal):
|
| 23 |
+
return value
|
| 24 |
+
try:
|
| 25 |
+
return Decimal(str(value))
|
| 26 |
+
except (InvalidOperation, ValueError):
|
| 27 |
+
return None
|
| 28 |
+
|
| 29 |
+
|
| 30 |
+
def run_invoice_validation(*, document, extracted_fields, line_items, currency_seed_map, platform_configs=None):
|
| 31 |
+
"""
|
| 32 |
+
Business validation logic for:
|
| 33 |
+
7.7.1 Required fields + due-date derivation path
|
| 34 |
+
7.7.2 Currency membership
|
| 35 |
+
7.7.3 Math cross-check with currency-aware tolerance
|
| 36 |
+
"""
|
| 37 |
+
platform_configs = platform_configs or {}
|
| 38 |
+
|
| 39 |
+
cap_minor_units = int(
|
| 40 |
+
platform_configs.get("validation.solver_tolerance.cap_minor_units", 0)
|
| 41 |
+
)
|
| 42 |
+
base_per_minor_unit = int(
|
| 43 |
+
platform_configs.get("validation.solver_tolerance.base_per_minor_unit", 0)
|
| 44 |
+
)
|
| 45 |
+
per_line_minor_units = int(
|
| 46 |
+
platform_configs.get("validation.solver_tolerance.per_line_minor_units", 0)
|
| 47 |
+
)
|
| 48 |
+
|
| 49 |
+
risk_flags = []
|
| 50 |
+
skipped_steps = []
|
| 51 |
+
|
| 52 |
+
def add_flag(code, *, weight, detail=None):
|
| 53 |
+
risk_flags.append(
|
| 54 |
+
{
|
| 55 |
+
"code": code,
|
| 56 |
+
"severity": "advisory",
|
| 57 |
+
"weight": weight,
|
| 58 |
+
"detail": detail or {},
|
| 59 |
+
}
|
| 60 |
+
)
|
| 61 |
+
|
| 62 |
+
invoice_no = getattr(extracted_fields, "invoice_no", None)
|
| 63 |
+
invoice_date = parse_date(getattr(extracted_fields, "invoice_date", None))
|
| 64 |
+
total_amount = parse_decimal(getattr(extracted_fields, "total_amount", None))
|
| 65 |
+
subtotal = parse_decimal(getattr(extracted_fields, "subtotal", None))
|
| 66 |
+
sender_name = getattr(extracted_fields, "sender_name", None)
|
| 67 |
+
currency = getattr(extracted_fields, "currency", None)
|
| 68 |
+
due_date = parse_date(getattr(extracted_fields, "due_date", None))
|
| 69 |
+
payment_terms = getattr(extracted_fields, "payment_terms", None)
|
| 70 |
+
|
| 71 |
+
missing_fields = []
|
| 72 |
+
|
| 73 |
+
def mark_missing(field_name):
|
| 74 |
+
if field_name not in missing_fields:
|
| 75 |
+
missing_fields.append(field_name)
|
| 76 |
+
|
| 77 |
+
if not invoice_no:
|
| 78 |
+
mark_missing("invoice_no")
|
| 79 |
+
if invoice_date is None:
|
| 80 |
+
mark_missing("invoice_date")
|
| 81 |
+
if total_amount is None:
|
| 82 |
+
mark_missing("total_amount")
|
| 83 |
+
if not sender_name:
|
| 84 |
+
mark_missing("sender_name")
|
| 85 |
+
if not currency:
|
| 86 |
+
mark_missing("currency")
|
| 87 |
+
|
| 88 |
+
due_date_derivable = invoice_date is not None and payment_terms not in (None, "")
|
| 89 |
+
if due_date is None and not due_date_derivable:
|
| 90 |
+
mark_missing("due_date")
|
| 91 |
+
if invoice_date is None:
|
| 92 |
+
mark_missing("invoice_date")
|
| 93 |
+
if payment_terms in (None, ""):
|
| 94 |
+
mark_missing("payment_terms")
|
| 95 |
+
|
| 96 |
+
if missing_fields:
|
| 97 |
+
add_flag(
|
| 98 |
+
"incomplete_fields",
|
| 99 |
+
weight=0.25,
|
| 100 |
+
detail={"missing_fields": missing_fields},
|
| 101 |
+
)
|
| 102 |
+
|
| 103 |
+
currency_key = str(currency).strip().upper() if currency not in (None, "") else None
|
| 104 |
+
currency_row = currency_seed_map.get(currency_key) if currency_key else None
|
| 105 |
+
|
| 106 |
+
if currency_row is None:
|
| 107 |
+
add_flag(
|
| 108 |
+
"invalid_currency",
|
| 109 |
+
weight=0.20,
|
| 110 |
+
detail={
|
| 111 |
+
"currency": currency,
|
| 112 |
+
"reason": "currency_missing_or_not_in_iso_currencies_seed",
|
| 113 |
+
},
|
| 114 |
+
)
|
| 115 |
+
|
| 116 |
+
line_amounts = []
|
| 117 |
+
for li in line_items:
|
| 118 |
+
amount = parse_decimal(getattr(li, "amount", None))
|
| 119 |
+
if amount is not None:
|
| 120 |
+
line_amounts.append(amount)
|
| 121 |
+
|
| 122 |
+
num_line_items = len(line_amounts)
|
| 123 |
+
math_ran = False
|
| 124 |
+
|
| 125 |
+
if subtotal is not None and num_line_items > 0:
|
| 126 |
+
if currency_row is None:
|
| 127 |
+
skipped_steps.append(
|
| 128 |
+
{"step": "math_cross_check", "skip_reason": "currency_minor_unit_unknown"}
|
| 129 |
+
)
|
| 130 |
+
else:
|
| 131 |
+
minor_unit_value = parse_decimal(currency_row.get("minor_unit_value"))
|
| 132 |
+
if minor_unit_value is None:
|
| 133 |
+
skipped_steps.append(
|
| 134 |
+
{"step": "math_cross_check", "skip_reason": "minor_unit_value_missing_in_seed"}
|
| 135 |
+
)
|
| 136 |
+
else:
|
| 137 |
+
math_ran = True
|
| 138 |
+
line_sum = sum(line_amounts, Decimal("0"))
|
| 139 |
+
tolerance_minor_units = min(
|
| 140 |
+
cap_minor_units,
|
| 141 |
+
base_per_minor_unit + per_line_minor_units * num_line_items,
|
| 142 |
+
)
|
| 143 |
+
tolerance_decimal = Decimal(tolerance_minor_units) * minor_unit_value
|
| 144 |
+
difference = abs(line_sum - subtotal)
|
| 145 |
+
|
| 146 |
+
if difference > tolerance_decimal:
|
| 147 |
+
add_flag(
|
| 148 |
+
"math_error_line_items",
|
| 149 |
+
weight=0.20,
|
| 150 |
+
detail={
|
| 151 |
+
"subtotal": str(subtotal),
|
| 152 |
+
"line_items_sum": str(line_sum),
|
| 153 |
+
"difference": str(difference),
|
| 154 |
+
"tolerance_minor_units": tolerance_minor_units,
|
| 155 |
+
"tolerance_decimal": str(tolerance_decimal),
|
| 156 |
+
"currency": currency_key,
|
| 157 |
+
"minor_unit_value": str(minor_unit_value),
|
| 158 |
+
"num_line_items": num_line_items,
|
| 159 |
+
},
|
| 160 |
+
)
|
| 161 |
+
|
| 162 |
+
return {
|
| 163 |
+
"risk_flags": risk_flags,
|
| 164 |
+
"skipped_steps": skipped_steps,
|
| 165 |
+
"result": "flagged" if risk_flags else "clean",
|
| 166 |
+
"missing_fields": missing_fields,
|
| 167 |
+
"currency_checked": currency_row is not None,
|
| 168 |
+
"math_checked": math_ran,
|
| 169 |
+
}
|
src/Invoice Validation Functions/date_sanity.py
ADDED
|
@@ -0,0 +1,219 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from datetime import date, datetime, timedelta
|
| 2 |
+
from zoneinfo import ZoneInfo
|
| 3 |
+
|
| 4 |
+
|
| 5 |
+
def run_date_sanity_checks(
|
| 6 |
+
*,
|
| 7 |
+
document,
|
| 8 |
+
extracted_fields,
|
| 9 |
+
platform_configs=None,
|
| 10 |
+
):
|
| 11 |
+
"""
|
| 12 |
+
Runs Date Sanity Checks after date normalisation.
|
| 13 |
+
|
| 14 |
+
Expected document fields:
|
| 15 |
+
document.id
|
| 16 |
+
document.tenant_id
|
| 17 |
+
Optional: document.tenant_timezone
|
| 18 |
+
|
| 19 |
+
Expected extracted_fields fields:
|
| 20 |
+
invoice_date
|
| 21 |
+
due_date
|
| 22 |
+
due_date_origin
|
| 23 |
+
payment_terms
|
| 24 |
+
service_start_date
|
| 25 |
+
service_end_date
|
| 26 |
+
|
| 27 |
+
Returns:
|
| 28 |
+
dict with advisory risk flags and audit-friendly result.
|
| 29 |
+
"""
|
| 30 |
+
|
| 31 |
+
platform_configs = platform_configs or {}
|
| 32 |
+
|
| 33 |
+
default_timezone = platform_configs.get(
|
| 34 |
+
"validation.default_timezone",
|
| 35 |
+
getattr(document, "tenant_timezone", "UTC") or "UTC",
|
| 36 |
+
)
|
| 37 |
+
|
| 38 |
+
max_invoice_age_days = int(
|
| 39 |
+
platform_configs.get("validation.max_invoice_age_days", 365)
|
| 40 |
+
)
|
| 41 |
+
|
| 42 |
+
max_invoice_age_days = max(30, min(max_invoice_age_days, 1825))
|
| 43 |
+
|
| 44 |
+
service_end_grace_days = int(
|
| 45 |
+
platform_configs.get("validation.service_end_post_invoice_grace_days", 0)
|
| 46 |
+
)
|
| 47 |
+
|
| 48 |
+
def as_date(value):
|
| 49 |
+
if value is None or value == "":
|
| 50 |
+
return None
|
| 51 |
+
|
| 52 |
+
if isinstance(value, datetime):
|
| 53 |
+
return value.date()
|
| 54 |
+
|
| 55 |
+
if isinstance(value, date):
|
| 56 |
+
return value
|
| 57 |
+
|
| 58 |
+
return datetime.strptime(str(value), "%Y-%m-%d").date()
|
| 59 |
+
|
| 60 |
+
def as_int(value):
|
| 61 |
+
if value is None or value == "":
|
| 62 |
+
return None
|
| 63 |
+
try:
|
| 64 |
+
return int(value)
|
| 65 |
+
except (ValueError, TypeError):
|
| 66 |
+
return None
|
| 67 |
+
|
| 68 |
+
def tenant_current_date():
|
| 69 |
+
try:
|
| 70 |
+
return datetime.now(ZoneInfo(default_timezone)).date()
|
| 71 |
+
except Exception:
|
| 72 |
+
return datetime.now(ZoneInfo("UTC")).date()
|
| 73 |
+
|
| 74 |
+
def add_flag(code, *, weight, field, detail=None):
|
| 75 |
+
risk_flags.append(
|
| 76 |
+
{
|
| 77 |
+
"code": code,
|
| 78 |
+
"severity": "advisory",
|
| 79 |
+
"weight": weight,
|
| 80 |
+
"field": field,
|
| 81 |
+
"detail": detail or {},
|
| 82 |
+
}
|
| 83 |
+
)
|
| 84 |
+
|
| 85 |
+
risk_flags = []
|
| 86 |
+
skipped_steps = []
|
| 87 |
+
|
| 88 |
+
invoice_date = as_date(getattr(extracted_fields, "invoice_date", None))
|
| 89 |
+
due_date = as_date(getattr(extracted_fields, "due_date", None))
|
| 90 |
+
service_start_date = as_date(getattr(extracted_fields, "service_start_date", None))
|
| 91 |
+
service_end_date = as_date(getattr(extracted_fields, "service_end_date", None))
|
| 92 |
+
due_date_origin = getattr(extracted_fields, "due_date_origin", None)
|
| 93 |
+
payment_terms = as_int(getattr(extracted_fields, "payment_terms", None))
|
| 94 |
+
|
| 95 |
+
current_date = tenant_current_date()
|
| 96 |
+
|
| 97 |
+
# 1. Payment terms must be non-negative.
|
| 98 |
+
# Zero is allowed: due-on-receipt.
|
| 99 |
+
if payment_terms is not None and payment_terms < 0:
|
| 100 |
+
add_flag(
|
| 101 |
+
"date_anomaly_payment_terms_negative",
|
| 102 |
+
weight=0.20,
|
| 103 |
+
field="payment_terms",
|
| 104 |
+
detail={
|
| 105 |
+
"payment_terms": payment_terms,
|
| 106 |
+
},
|
| 107 |
+
)
|
| 108 |
+
|
| 109 |
+
# 2. Due date must be on or after invoice date.
|
| 110 |
+
if invoice_date is not None and due_date is not None:
|
| 111 |
+
if due_date < invoice_date:
|
| 112 |
+
add_flag(
|
| 113 |
+
"date_anomaly_due_before_invoice",
|
| 114 |
+
weight=0.20,
|
| 115 |
+
field="due_date",
|
| 116 |
+
detail={
|
| 117 |
+
"invoice_date": str(invoice_date),
|
| 118 |
+
"due_date": str(due_date),
|
| 119 |
+
},
|
| 120 |
+
)
|
| 121 |
+
|
| 122 |
+
if invoice_date is None:
|
| 123 |
+
skipped_steps.extend([
|
| 124 |
+
{"step": "date_anomaly_invoice_future_dated", "skip_reason": "no_invoice_date"},
|
| 125 |
+
{"step": "date_anomaly_invoice_too_old", "skip_reason": "no_invoice_date"},
|
| 126 |
+
])
|
| 127 |
+
|
| 128 |
+
# 3. Invoice must not be future-dated.
|
| 129 |
+
if invoice_date is not None and invoice_date > current_date:
|
| 130 |
+
add_flag(
|
| 131 |
+
"date_anomaly_invoice_future_dated",
|
| 132 |
+
weight=0.20,
|
| 133 |
+
field="invoice_date",
|
| 134 |
+
detail={
|
| 135 |
+
"invoice_date": str(invoice_date),
|
| 136 |
+
"current_date": str(current_date),
|
| 137 |
+
"timezone": default_timezone,
|
| 138 |
+
},
|
| 139 |
+
)
|
| 140 |
+
|
| 141 |
+
# 4. Invoice must not be too old.
|
| 142 |
+
if invoice_date is not None:
|
| 143 |
+
oldest_allowed_date = current_date - timedelta(days=max_invoice_age_days)
|
| 144 |
+
|
| 145 |
+
if invoice_date < oldest_allowed_date:
|
| 146 |
+
add_flag(
|
| 147 |
+
"date_anomaly_invoice_too_old",
|
| 148 |
+
weight=0.15,
|
| 149 |
+
field="invoice_date",
|
| 150 |
+
detail={
|
| 151 |
+
"invoice_date": str(invoice_date),
|
| 152 |
+
"oldest_allowed_date": str(oldest_allowed_date),
|
| 153 |
+
"max_invoice_age_days": max_invoice_age_days,
|
| 154 |
+
},
|
| 155 |
+
)
|
| 156 |
+
|
| 157 |
+
# 5. Service period must not be reversed.
|
| 158 |
+
if service_start_date is not None and service_end_date is not None:
|
| 159 |
+
if service_end_date < service_start_date:
|
| 160 |
+
add_flag(
|
| 161 |
+
"date_anomaly_service_period_reversed",
|
| 162 |
+
weight=0.15,
|
| 163 |
+
field="service_end_date",
|
| 164 |
+
detail={
|
| 165 |
+
"service_start_date": str(service_start_date),
|
| 166 |
+
"service_end_date": str(service_end_date),
|
| 167 |
+
},
|
| 168 |
+
)
|
| 169 |
+
|
| 170 |
+
# 6. Service end must not be too far after invoice date.
|
| 171 |
+
if service_end_date is not None:
|
| 172 |
+
if invoice_date is None:
|
| 173 |
+
skipped_steps.append(
|
| 174 |
+
{"step": "date_anomaly_service_end_after_invoice", "skip_reason": "no_invoice_date"}
|
| 175 |
+
)
|
| 176 |
+
else:
|
| 177 |
+
allowed_service_end = invoice_date + timedelta(days=service_end_grace_days)
|
| 178 |
+
|
| 179 |
+
if service_end_date > allowed_service_end:
|
| 180 |
+
add_flag(
|
| 181 |
+
"date_anomaly_service_end_after_invoice",
|
| 182 |
+
weight=0.15,
|
| 183 |
+
field="service_end_date",
|
| 184 |
+
detail={
|
| 185 |
+
"invoice_date": str(invoice_date),
|
| 186 |
+
"service_end_date": str(service_end_date),
|
| 187 |
+
"allowed_service_end": str(allowed_service_end),
|
| 188 |
+
"grace_days": service_end_grace_days,
|
| 189 |
+
},
|
| 190 |
+
)
|
| 191 |
+
|
| 192 |
+
# 7. Due date could not be derived.
|
| 193 |
+
if due_date is None and due_date_origin == "unknown":
|
| 194 |
+
add_flag(
|
| 195 |
+
"due_date_not_derivable",
|
| 196 |
+
weight=0.10,
|
| 197 |
+
field="due_date",
|
| 198 |
+
detail={
|
| 199 |
+
"due_date_origin": due_date_origin,
|
| 200 |
+
},
|
| 201 |
+
)
|
| 202 |
+
|
| 203 |
+
return {
|
| 204 |
+
"risk_flags": risk_flags,
|
| 205 |
+
"skipped_steps": skipped_steps,
|
| 206 |
+
"flag_codes": [flag["code"] for flag in risk_flags],
|
| 207 |
+
"checked_fields": {
|
| 208 |
+
"invoice_date": str(invoice_date) if invoice_date else None,
|
| 209 |
+
"due_date": str(due_date) if due_date else None,
|
| 210 |
+
"due_date_origin": due_date_origin,
|
| 211 |
+
"payment_terms": payment_terms,
|
| 212 |
+
"service_start_date": str(service_start_date) if service_start_date else None,
|
| 213 |
+
"service_end_date": str(service_end_date) if service_end_date else None,
|
| 214 |
+
"current_date": str(current_date),
|
| 215 |
+
"timezone": default_timezone,
|
| 216 |
+
"max_invoice_age_days": max_invoice_age_days,
|
| 217 |
+
"service_end_post_invoice_grace_days": service_end_grace_days,
|
| 218 |
+
},
|
| 219 |
+
}
|
src/Invoice Validation Functions/orchestrator.py
ADDED
|
@@ -0,0 +1,282 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Invoice validation orchestrator.
|
| 3 |
+
|
| 4 |
+
Loads platform_configs.json once, runs all seven validators in SDD order,
|
| 5 |
+
wires outputs to downstream inputs, and returns a single consolidated result.
|
| 6 |
+
|
| 7 |
+
No business logic lives here — all decisions remain inside the validator modules.
|
| 8 |
+
"""
|
| 9 |
+
|
| 10 |
+
import importlib.util
|
| 11 |
+
import json
|
| 12 |
+
import os
|
| 13 |
+
|
| 14 |
+
|
| 15 |
+
# ---------------------------------------------------------------------------
|
| 16 |
+
# Module loader — handles filenames that contain spaces or hyphens
|
| 17 |
+
# ---------------------------------------------------------------------------
|
| 18 |
+
|
| 19 |
+
def _load_fn(filename, fn_name):
|
| 20 |
+
"""Import a function from a .py file by path."""
|
| 21 |
+
here = os.path.dirname(os.path.abspath(__file__))
|
| 22 |
+
path = os.path.join(here, filename)
|
| 23 |
+
spec = importlib.util.spec_from_file_location(filename, path)
|
| 24 |
+
mod = importlib.util.module_from_spec(spec)
|
| 25 |
+
spec.loader.exec_module(mod)
|
| 26 |
+
return getattr(mod, fn_name)
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
_run_vendor_validation = _load_fn("Vendor Validation.py", "run_vendor_validation")
|
| 30 |
+
_run_tax_id_validation = _load_fn("Tax - ID validation.py", "run_tax_id_validation")
|
| 31 |
+
_run_bank_validation = _load_fn("Bank validation.py", "run_bank_validation")
|
| 32 |
+
_run_date_sanity_checks = _load_fn("date_sanity.py", "run_date_sanity_checks")
|
| 33 |
+
_run_payment_terms_validation = _load_fn("payment terms valdiation.py", "run_payment_terms_validation")
|
| 34 |
+
_run_tax_amount_validation = _load_fn("tax validation.py", "run_tax_amount_validation")
|
| 35 |
+
_run_completeness_validation = _load_fn("completeness.py", "run_invoice_validation")
|
| 36 |
+
|
| 37 |
+
|
| 38 |
+
# ---------------------------------------------------------------------------
|
| 39 |
+
# Helpers
|
| 40 |
+
# ---------------------------------------------------------------------------
|
| 41 |
+
|
| 42 |
+
class _FieldProxy:
|
| 43 |
+
"""
|
| 44 |
+
Overlays computed attributes onto an existing extracted_fields object so
|
| 45 |
+
downstream validators can read Tax-ID results via the normal
|
| 46 |
+
getattr(extracted_fields, ...) pattern without mutating the original object.
|
| 47 |
+
|
| 48 |
+
Only non-None values are overlaid; None values fall through to the base
|
| 49 |
+
object (and its getattr default), which preserves the lenient defaults each
|
| 50 |
+
validator defines for fields it cannot always receive.
|
| 51 |
+
"""
|
| 52 |
+
__slots__ = ("_base", "_overlay")
|
| 53 |
+
|
| 54 |
+
def __init__(self, base, **overlay):
|
| 55 |
+
object.__setattr__(self, "_base", base)
|
| 56 |
+
object.__setattr__(self, "_overlay", {k: v for k, v in overlay.items() if v is not None})
|
| 57 |
+
|
| 58 |
+
def __getattr__(self, name):
|
| 59 |
+
overlay = object.__getattribute__(self, "_overlay")
|
| 60 |
+
if name in overlay:
|
| 61 |
+
return overlay[name]
|
| 62 |
+
return getattr(object.__getattribute__(self, "_base"), name)
|
| 63 |
+
|
| 64 |
+
|
| 65 |
+
def _load_platform_configs(path=None):
|
| 66 |
+
if path is None:
|
| 67 |
+
here = os.path.dirname(os.path.abspath(__file__))
|
| 68 |
+
path = os.path.join(here, "platform_configs.json")
|
| 69 |
+
with open(path, encoding="utf-8") as fh:
|
| 70 |
+
return json.load(fh)
|
| 71 |
+
|
| 72 |
+
|
| 73 |
+
def _collect(result, key):
|
| 74 |
+
"""Safely pull a list from a module result dict (returns [] if absent)."""
|
| 75 |
+
return result.get(key) or []
|
| 76 |
+
|
| 77 |
+
|
| 78 |
+
# ---------------------------------------------------------------------------
|
| 79 |
+
# Pipeline
|
| 80 |
+
# ---------------------------------------------------------------------------
|
| 81 |
+
|
| 82 |
+
def run_invoice_validation_pipeline(
|
| 83 |
+
*,
|
| 84 |
+
document,
|
| 85 |
+
extracted_fields,
|
| 86 |
+
vendors,
|
| 87 |
+
vendor_bank_accounts,
|
| 88 |
+
vendor_tax_ids,
|
| 89 |
+
entity,
|
| 90 |
+
line_items,
|
| 91 |
+
purchase_orders,
|
| 92 |
+
contracts,
|
| 93 |
+
tax_master_rows,
|
| 94 |
+
currency_seed_map,
|
| 95 |
+
platform_configs=None,
|
| 96 |
+
route_contexts=None,
|
| 97 |
+
actor_id=None,
|
| 98 |
+
):
|
| 99 |
+
"""
|
| 100 |
+
Run all seven validation modules in SDD order and return a consolidated result.
|
| 101 |
+
|
| 102 |
+
Parameters
|
| 103 |
+
----------
|
| 104 |
+
document : Invoice document object (needs .id, .tenant_id, .entity_id).
|
| 105 |
+
extracted_fields : VLM-extracted fields object.
|
| 106 |
+
vendors : Iterable of vendor records used for vendor matching.
|
| 107 |
+
vendor_bank_accounts : Iterable of vendor bank account records.
|
| 108 |
+
vendor_tax_ids : Iterable of vendor tax-ID records.
|
| 109 |
+
entity : The routing entity object (needs .country_code, .vat_id, .region_code).
|
| 110 |
+
line_items : Iterable of line-item records.
|
| 111 |
+
purchase_orders : Iterable of purchase-order records.
|
| 112 |
+
contracts : Iterable of contract records.
|
| 113 |
+
tax_master_rows : Iterable of tax-master records.
|
| 114 |
+
currency_seed_map : Dict mapping ISO currency code → row with minor_unit_value.
|
| 115 |
+
platform_configs : Pre-loaded config dict; loaded from platform_configs.json when None.
|
| 116 |
+
route_contexts : List of route-context strings forwarded to Vendor Validation.
|
| 117 |
+
actor_id : Actor identifier forwarded to Vendor Validation for audit.
|
| 118 |
+
|
| 119 |
+
Returns
|
| 120 |
+
-------
|
| 121 |
+
{
|
| 122 |
+
"risk_flags": [...], # aggregated, in execution order
|
| 123 |
+
"advisory_flags": [...], # aggregated, in execution order
|
| 124 |
+
"skipped_steps": [...], # aggregated, in execution order
|
| 125 |
+
"ops_alerts": [...], # operational alerts (e.g. tax_master_unconfigured)
|
| 126 |
+
"resolved_vendor_id": ...,
|
| 127 |
+
"module_results": {...}, # raw return dict from each module, keyed by name
|
| 128 |
+
}
|
| 129 |
+
"""
|
| 130 |
+
|
| 131 |
+
if platform_configs is None:
|
| 132 |
+
platform_configs = _load_platform_configs()
|
| 133 |
+
|
| 134 |
+
risk_flags = []
|
| 135 |
+
advisory_flags = []
|
| 136 |
+
skipped_steps = []
|
| 137 |
+
ops_alerts = []
|
| 138 |
+
module_results = {}
|
| 139 |
+
|
| 140 |
+
# ------------------------------------------------------------------
|
| 141 |
+
# Step 1 — Vendor Validation
|
| 142 |
+
# Runs first; produces resolved_vendor_id used by all downstream steps.
|
| 143 |
+
# ------------------------------------------------------------------
|
| 144 |
+
vendor_result = _run_vendor_validation(
|
| 145 |
+
document=document,
|
| 146 |
+
extracted_fields=extracted_fields,
|
| 147 |
+
vendors=vendors,
|
| 148 |
+
platform_configs=platform_configs,
|
| 149 |
+
route_contexts=route_contexts,
|
| 150 |
+
actor_id=actor_id,
|
| 151 |
+
)
|
| 152 |
+
module_results["vendor_validation"] = vendor_result
|
| 153 |
+
risk_flags.extend(_collect(vendor_result, "risk_flags"))
|
| 154 |
+
advisory_flags.extend(_collect(vendor_result, "advisory_flags"))
|
| 155 |
+
skipped_steps.extend(_collect(vendor_result, "skipped_steps"))
|
| 156 |
+
|
| 157 |
+
resolved_vendor_id = vendor_result.get("resolved_vendor_id")
|
| 158 |
+
|
| 159 |
+
# Resolve the vendor object so downstream modules receive it directly.
|
| 160 |
+
resolved_vendor = next(
|
| 161 |
+
(v for v in vendors if getattr(v, "id", None) == resolved_vendor_id),
|
| 162 |
+
None,
|
| 163 |
+
) if resolved_vendor_id else None
|
| 164 |
+
|
| 165 |
+
# ------------------------------------------------------------------
|
| 166 |
+
# Step 2 — Tax ID Validation
|
| 167 |
+
# Depends on resolved_vendor_id and the resolved vendor object.
|
| 168 |
+
# ------------------------------------------------------------------
|
| 169 |
+
tax_id_result = _run_tax_id_validation(
|
| 170 |
+
document=document,
|
| 171 |
+
extracted_fields=extracted_fields,
|
| 172 |
+
vendor=resolved_vendor,
|
| 173 |
+
vendor_tax_ids=vendor_tax_ids,
|
| 174 |
+
resolved_vendor_id=resolved_vendor_id,
|
| 175 |
+
platform_configs=platform_configs,
|
| 176 |
+
)
|
| 177 |
+
module_results["tax_id_validation"] = tax_id_result
|
| 178 |
+
risk_flags.extend(_collect(tax_id_result, "risk_flags"))
|
| 179 |
+
advisory_flags.extend(_collect(tax_id_result, "advisory_flags"))
|
| 180 |
+
skipped_steps.extend(_collect(tax_id_result, "skipped_steps"))
|
| 181 |
+
|
| 182 |
+
# Enrich extracted_fields with Tax-ID results so Tax Validation can read
|
| 183 |
+
# sender_tax_id_inferred_family / _format_valid / _checksum_valid via its
|
| 184 |
+
# normal getattr(extracted_fields, ...) pattern.
|
| 185 |
+
enriched_fields = _FieldProxy(
|
| 186 |
+
extracted_fields,
|
| 187 |
+
sender_tax_id_inferred_family=tax_id_result.get("invoice_tax_id_inferred_type"),
|
| 188 |
+
sender_tax_id_format_valid=tax_id_result.get("invoice_tax_id_format_valid"),
|
| 189 |
+
sender_tax_id_checksum_valid=tax_id_result.get("invoice_tax_id_checksum_valid"),
|
| 190 |
+
)
|
| 191 |
+
|
| 192 |
+
# ------------------------------------------------------------------
|
| 193 |
+
# Step 3 — Bank Validation
|
| 194 |
+
# Depends on resolved_vendor_id.
|
| 195 |
+
# ------------------------------------------------------------------
|
| 196 |
+
bank_result = _run_bank_validation(
|
| 197 |
+
document=document,
|
| 198 |
+
extracted_fields=extracted_fields,
|
| 199 |
+
vendor_bank_accounts=vendor_bank_accounts,
|
| 200 |
+
resolved_vendor_id=resolved_vendor_id,
|
| 201 |
+
platform_configs=platform_configs,
|
| 202 |
+
)
|
| 203 |
+
module_results["bank_validation"] = bank_result
|
| 204 |
+
risk_flags.extend(_collect(bank_result, "risk_flags"))
|
| 205 |
+
advisory_flags.extend(_collect(bank_result, "advisory_flags"))
|
| 206 |
+
skipped_steps.extend(_collect(bank_result, "skipped_steps"))
|
| 207 |
+
|
| 208 |
+
# ------------------------------------------------------------------
|
| 209 |
+
# Step 4 — Date Sanity Checks
|
| 210 |
+
# Independent of vendor resolution.
|
| 211 |
+
# ------------------------------------------------------------------
|
| 212 |
+
date_result = _run_date_sanity_checks(
|
| 213 |
+
document=document,
|
| 214 |
+
extracted_fields=extracted_fields,
|
| 215 |
+
platform_configs=platform_configs,
|
| 216 |
+
)
|
| 217 |
+
module_results["date_sanity"] = date_result
|
| 218 |
+
risk_flags.extend(_collect(date_result, "risk_flags"))
|
| 219 |
+
advisory_flags.extend(_collect(date_result, "advisory_flags"))
|
| 220 |
+
skipped_steps.extend(_collect(date_result, "skipped_steps"))
|
| 221 |
+
|
| 222 |
+
# ------------------------------------------------------------------
|
| 223 |
+
# Step 5 — Payment Terms Validation
|
| 224 |
+
# Depends on resolved_vendor_id and the resolved vendor object.
|
| 225 |
+
# ------------------------------------------------------------------
|
| 226 |
+
payment_terms_result = _run_payment_terms_validation(
|
| 227 |
+
document=document,
|
| 228 |
+
extracted_fields=extracted_fields,
|
| 229 |
+
vendor=resolved_vendor,
|
| 230 |
+
purchase_orders=purchase_orders,
|
| 231 |
+
contracts=contracts,
|
| 232 |
+
resolved_vendor_id=resolved_vendor_id,
|
| 233 |
+
platform_configs=platform_configs,
|
| 234 |
+
)
|
| 235 |
+
module_results["payment_terms_validation"] = payment_terms_result
|
| 236 |
+
risk_flags.extend(_collect(payment_terms_result, "risk_flags"))
|
| 237 |
+
advisory_flags.extend(_collect(payment_terms_result, "advisory_flags"))
|
| 238 |
+
skipped_steps.extend(_collect(payment_terms_result, "skipped_steps"))
|
| 239 |
+
|
| 240 |
+
# ------------------------------------------------------------------
|
| 241 |
+
# Step 6 — Tax Amount Validation
|
| 242 |
+
# Depends on resolved vendor object and enriched_fields from Step 2.
|
| 243 |
+
# ------------------------------------------------------------------
|
| 244 |
+
tax_result = _run_tax_amount_validation(
|
| 245 |
+
document=document,
|
| 246 |
+
extracted_fields=enriched_fields,
|
| 247 |
+
line_items=line_items,
|
| 248 |
+
vendor=resolved_vendor,
|
| 249 |
+
entity=entity,
|
| 250 |
+
tax_master_rows=tax_master_rows,
|
| 251 |
+
platform_configs=platform_configs,
|
| 252 |
+
)
|
| 253 |
+
module_results["tax_validation"] = tax_result
|
| 254 |
+
risk_flags.extend(_collect(tax_result, "risk_flags"))
|
| 255 |
+
advisory_flags.extend(_collect(tax_result, "advisory_flags"))
|
| 256 |
+
skipped_steps.extend(_collect(tax_result, "skipped_checks")) # tax validation uses "skipped_checks"
|
| 257 |
+
ops_alerts.extend(_collect(tax_result, "ops_alerts"))
|
| 258 |
+
|
| 259 |
+
# ------------------------------------------------------------------
|
| 260 |
+
# Step 7 — Completeness / Math Cross-check
|
| 261 |
+
# Independent of vendor resolution.
|
| 262 |
+
# ------------------------------------------------------------------
|
| 263 |
+
completeness_result = _run_completeness_validation(
|
| 264 |
+
document=document,
|
| 265 |
+
extracted_fields=extracted_fields,
|
| 266 |
+
line_items=line_items,
|
| 267 |
+
currency_seed_map=currency_seed_map,
|
| 268 |
+
platform_configs=platform_configs,
|
| 269 |
+
)
|
| 270 |
+
module_results["completeness"] = completeness_result
|
| 271 |
+
risk_flags.extend(_collect(completeness_result, "risk_flags"))
|
| 272 |
+
advisory_flags.extend(_collect(completeness_result, "advisory_flags"))
|
| 273 |
+
skipped_steps.extend(_collect(completeness_result, "skipped_steps"))
|
| 274 |
+
|
| 275 |
+
return {
|
| 276 |
+
"risk_flags": risk_flags,
|
| 277 |
+
"advisory_flags": advisory_flags,
|
| 278 |
+
"skipped_steps": skipped_steps,
|
| 279 |
+
"ops_alerts": ops_alerts,
|
| 280 |
+
"resolved_vendor_id": resolved_vendor_id,
|
| 281 |
+
"module_results": module_results,
|
| 282 |
+
}
|
src/Invoice Validation Functions/payment terms valdiation.py
ADDED
|
@@ -0,0 +1,264 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from datetime import date, datetime
|
| 2 |
+
|
| 3 |
+
|
| 4 |
+
def run_payment_terms_validation(
|
| 5 |
+
*,
|
| 6 |
+
document,
|
| 7 |
+
extracted_fields,
|
| 8 |
+
vendor,
|
| 9 |
+
purchase_orders,
|
| 10 |
+
contracts,
|
| 11 |
+
resolved_vendor_id,
|
| 12 |
+
platform_configs=None,
|
| 13 |
+
):
|
| 14 |
+
"""
|
| 15 |
+
Payment Terms Validation.
|
| 16 |
+
|
| 17 |
+
Expected document fields:
|
| 18 |
+
document.id
|
| 19 |
+
document.tenant_id
|
| 20 |
+
document.entity_id
|
| 21 |
+
|
| 22 |
+
Expected extracted_fields fields:
|
| 23 |
+
payment_terms
|
| 24 |
+
po_number_canonical
|
| 25 |
+
invoice_date
|
| 26 |
+
|
| 27 |
+
Expected vendor fields:
|
| 28 |
+
vendor.id
|
| 29 |
+
vendor.payment_terms
|
| 30 |
+
|
| 31 |
+
Expected purchase_orders fields:
|
| 32 |
+
tenant_id
|
| 33 |
+
po_number_canonical
|
| 34 |
+
payment_terms
|
| 35 |
+
deleted_at
|
| 36 |
+
|
| 37 |
+
Expected contract fields:
|
| 38 |
+
tenant_id
|
| 39 |
+
vendor_id
|
| 40 |
+
entity_id
|
| 41 |
+
status
|
| 42 |
+
payment_terms
|
| 43 |
+
effective_from
|
| 44 |
+
effective_to
|
| 45 |
+
deleted_at
|
| 46 |
+
|
| 47 |
+
Returns:
|
| 48 |
+
dict with advisory flags, skipped status, and resolution detail.
|
| 49 |
+
"""
|
| 50 |
+
|
| 51 |
+
platform_configs = platform_configs or {}
|
| 52 |
+
|
| 53 |
+
tolerance_days = int(
|
| 54 |
+
platform_configs.get("validation.payment_terms_tolerance_days", 0)
|
| 55 |
+
)
|
| 56 |
+
|
| 57 |
+
def as_int(value):
|
| 58 |
+
if value is None or value == "":
|
| 59 |
+
return None
|
| 60 |
+
try:
|
| 61 |
+
return int(value)
|
| 62 |
+
except (ValueError, TypeError):
|
| 63 |
+
return None
|
| 64 |
+
|
| 65 |
+
def as_date(value):
|
| 66 |
+
if value is None or value == "":
|
| 67 |
+
return None
|
| 68 |
+
|
| 69 |
+
if isinstance(value, datetime):
|
| 70 |
+
return value.date()
|
| 71 |
+
|
| 72 |
+
if isinstance(value, date):
|
| 73 |
+
return value
|
| 74 |
+
|
| 75 |
+
return datetime.strptime(str(value), "%Y-%m-%d").date()
|
| 76 |
+
|
| 77 |
+
def add_flag(code, *, weight, detail=None):
|
| 78 |
+
risk_flags.append(
|
| 79 |
+
{
|
| 80 |
+
"code": code,
|
| 81 |
+
"severity": "advisory",
|
| 82 |
+
"weight": weight,
|
| 83 |
+
"detail": detail or {},
|
| 84 |
+
}
|
| 85 |
+
)
|
| 86 |
+
|
| 87 |
+
def po_terms_tier():
|
| 88 |
+
po_number = getattr(extracted_fields, "po_number_canonical", None)
|
| 89 |
+
|
| 90 |
+
if not po_number:
|
| 91 |
+
return None
|
| 92 |
+
|
| 93 |
+
matching_pos = [
|
| 94 |
+
po
|
| 95 |
+
for po in purchase_orders
|
| 96 |
+
if po.tenant_id == document.tenant_id
|
| 97 |
+
and po.po_number_canonical == po_number
|
| 98 |
+
and getattr(po, "deleted_at", None) is None
|
| 99 |
+
and as_int(getattr(po, "payment_terms", None)) is not None
|
| 100 |
+
]
|
| 101 |
+
|
| 102 |
+
if len(matching_pos) >= 1:
|
| 103 |
+
return {
|
| 104 |
+
"tier": "purchase_order",
|
| 105 |
+
"expected_payment_terms": as_int(matching_pos[0].payment_terms),
|
| 106 |
+
"source_id": getattr(matching_pos[0], "id", None),
|
| 107 |
+
}
|
| 108 |
+
|
| 109 |
+
return None
|
| 110 |
+
|
| 111 |
+
def contract_terms_tier():
|
| 112 |
+
invoice_date = as_date(getattr(extracted_fields, "invoice_date", None))
|
| 113 |
+
|
| 114 |
+
if not invoice_date:
|
| 115 |
+
skipped_steps.append(
|
| 116 |
+
{"step": "contract_payment_terms", "skip_reason": "no_invoice_date"}
|
| 117 |
+
)
|
| 118 |
+
return None
|
| 119 |
+
|
| 120 |
+
matching_contracts = []
|
| 121 |
+
|
| 122 |
+
for contract in contracts:
|
| 123 |
+
if contract.tenant_id != document.tenant_id:
|
| 124 |
+
continue
|
| 125 |
+
|
| 126 |
+
if contract.vendor_id != resolved_vendor_id:
|
| 127 |
+
continue
|
| 128 |
+
|
| 129 |
+
if contract.entity_id != document.entity_id:
|
| 130 |
+
continue
|
| 131 |
+
|
| 132 |
+
if getattr(contract, "status", None) != "active":
|
| 133 |
+
continue
|
| 134 |
+
|
| 135 |
+
if getattr(contract, "deleted_at", None) is not None:
|
| 136 |
+
continue
|
| 137 |
+
|
| 138 |
+
effective_from = as_date(getattr(contract, "effective_from", None))
|
| 139 |
+
effective_to = as_date(getattr(contract, "effective_to", None))
|
| 140 |
+
|
| 141 |
+
if effective_from is None or invoice_date < effective_from:
|
| 142 |
+
continue
|
| 143 |
+
|
| 144 |
+
if effective_to is not None and invoice_date > effective_to:
|
| 145 |
+
continue
|
| 146 |
+
|
| 147 |
+
matching_contracts.append(contract)
|
| 148 |
+
|
| 149 |
+
if len(matching_contracts) != 1:
|
| 150 |
+
return None
|
| 151 |
+
|
| 152 |
+
payment_terms = as_int(getattr(matching_contracts[0], "payment_terms", None))
|
| 153 |
+
if payment_terms is None:
|
| 154 |
+
return None
|
| 155 |
+
|
| 156 |
+
return {
|
| 157 |
+
"tier": "contract",
|
| 158 |
+
"expected_payment_terms": payment_terms,
|
| 159 |
+
"source_id": getattr(matching_contracts[0], "id", None),
|
| 160 |
+
}
|
| 161 |
+
|
| 162 |
+
def vendor_master_terms_tier():
|
| 163 |
+
payment_terms = as_int(getattr(vendor, "payment_terms", None))
|
| 164 |
+
|
| 165 |
+
if payment_terms is None:
|
| 166 |
+
return None
|
| 167 |
+
|
| 168 |
+
return {
|
| 169 |
+
"tier": "vendor_master",
|
| 170 |
+
"expected_payment_terms": payment_terms,
|
| 171 |
+
"source_id": getattr(vendor, "id", None),
|
| 172 |
+
}
|
| 173 |
+
|
| 174 |
+
risk_flags = []
|
| 175 |
+
skipped_steps = []
|
| 176 |
+
|
| 177 |
+
if resolved_vendor_id is None:
|
| 178 |
+
skipped_steps.append(
|
| 179 |
+
{
|
| 180 |
+
"step": "payment_terms_validation",
|
| 181 |
+
"skip_reason": "vendor_unresolved",
|
| 182 |
+
}
|
| 183 |
+
)
|
| 184 |
+
return {
|
| 185 |
+
"risk_flags": risk_flags,
|
| 186 |
+
"skipped_steps": skipped_steps,
|
| 187 |
+
"result": "skipped",
|
| 188 |
+
"expected_payment_terms": None,
|
| 189 |
+
"expected_source_tier": None,
|
| 190 |
+
}
|
| 191 |
+
|
| 192 |
+
invoice_payment_terms = as_int(getattr(extracted_fields, "payment_terms", None))
|
| 193 |
+
|
| 194 |
+
if invoice_payment_terms is None:
|
| 195 |
+
skipped_steps.append(
|
| 196 |
+
{
|
| 197 |
+
"step": "payment_terms_validation",
|
| 198 |
+
"skip_reason": "invoice_payment_terms_missing",
|
| 199 |
+
}
|
| 200 |
+
)
|
| 201 |
+
|
| 202 |
+
return {
|
| 203 |
+
"risk_flags": risk_flags,
|
| 204 |
+
"skipped_steps": skipped_steps,
|
| 205 |
+
"result": "skipped",
|
| 206 |
+
"expected_payment_terms": None,
|
| 207 |
+
"expected_source_tier": None,
|
| 208 |
+
}
|
| 209 |
+
|
| 210 |
+
resolved = (
|
| 211 |
+
po_terms_tier()
|
| 212 |
+
or contract_terms_tier()
|
| 213 |
+
or vendor_master_terms_tier()
|
| 214 |
+
)
|
| 215 |
+
|
| 216 |
+
if resolved is None:
|
| 217 |
+
add_flag(
|
| 218 |
+
"vendor_payment_terms_unconfigured",
|
| 219 |
+
weight=0.10,
|
| 220 |
+
detail={
|
| 221 |
+
"reason": "no_po_contract_or_vendor_payment_terms",
|
| 222 |
+
},
|
| 223 |
+
)
|
| 224 |
+
|
| 225 |
+
return {
|
| 226 |
+
"risk_flags": risk_flags,
|
| 227 |
+
"skipped_steps": skipped_steps,
|
| 228 |
+
"result": "unconfigured",
|
| 229 |
+
"expected_payment_terms": None,
|
| 230 |
+
"expected_source_tier": None,
|
| 231 |
+
}
|
| 232 |
+
|
| 233 |
+
expected_payment_terms = resolved["expected_payment_terms"]
|
| 234 |
+
expected_source_tier = resolved["tier"]
|
| 235 |
+
|
| 236 |
+
difference = abs(invoice_payment_terms - expected_payment_terms)
|
| 237 |
+
|
| 238 |
+
if difference > tolerance_days:
|
| 239 |
+
add_flag(
|
| 240 |
+
"payment_terms_mismatch",
|
| 241 |
+
weight=0.20,
|
| 242 |
+
detail={
|
| 243 |
+
"invoice_payment_terms": invoice_payment_terms,
|
| 244 |
+
"expected_payment_terms": expected_payment_terms,
|
| 245 |
+
"difference_days": difference,
|
| 246 |
+
"tolerance_days": tolerance_days,
|
| 247 |
+
"expected_source_tier": expected_source_tier,
|
| 248 |
+
"source_id": resolved.get("source_id"),
|
| 249 |
+
},
|
| 250 |
+
)
|
| 251 |
+
|
| 252 |
+
result = "mismatch"
|
| 253 |
+
else:
|
| 254 |
+
result = "matched"
|
| 255 |
+
|
| 256 |
+
return {
|
| 257 |
+
"risk_flags": risk_flags,
|
| 258 |
+
"skipped_steps": skipped_steps,
|
| 259 |
+
"result": result,
|
| 260 |
+
"invoice_payment_terms": invoice_payment_terms,
|
| 261 |
+
"expected_payment_terms": expected_payment_terms,
|
| 262 |
+
"expected_source_tier": expected_source_tier,
|
| 263 |
+
"tolerance_days": tolerance_days,
|
| 264 |
+
}
|
src/Invoice Validation Functions/platform_configs.json
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"_comment_vendor": "Vendor validation configs",
|
| 3 |
+
|
| 4 |
+
"validation.name_match_algorithm_version": "rapidfuzz.local.v1",
|
| 5 |
+
|
| 6 |
+
"validation.vendor_match_require_high_tier_contexts": [
|
| 7 |
+
"contract_match",
|
| 8 |
+
"bank_validation",
|
| 9 |
+
"approval_revalidation"
|
| 10 |
+
],
|
| 11 |
+
|
| 12 |
+
"validation.rapidfuzz_high_threshold": 0.90,
|
| 13 |
+
"validation.rapidfuzz_medium_threshold": 0.75,
|
| 14 |
+
"validation.rapidfuzz_suggestion_threshold": 0.60,
|
| 15 |
+
|
| 16 |
+
"_comment_tax_id": "Tax ID validation configs",
|
| 17 |
+
"_comment_tax_id_handlers": "Built-in checksum handlers are implemented in Tax - ID validation.py (_BUILTIN_CHECKSUM_HANDLERS). They cover all families with a non-null checksum in the registry: gb_vat_mod97, de_vat_mod11, fr_vat_mod97, it_vat_luhn, es_nif, gstin_crc, abn_mod89, bn_luhn, sg_nric_fin. To override at runtime, supply a callable dict under validation.tax_id_checksum_handlers when building platform_configs.",
|
| 18 |
+
|
| 19 |
+
"validation.tax_id_family_registry": {
|
| 20 |
+
"VAT-GB": {"pattern": "^(GB)?[0-9]{9}([0-9]{3})?$", "checksum": "gb_vat_mod97"},
|
| 21 |
+
"VAT-DE": {"pattern": "^(DE)?[0-9]{9}$", "checksum": "de_vat_mod11"},
|
| 22 |
+
"VAT-FR": {"pattern": "^(FR)?[A-Z0-9]{2}[0-9]{9}$", "checksum": "fr_vat_mod97"},
|
| 23 |
+
"VAT-IT": {"pattern": "^(IT)?[0-9]{11}$", "checksum": "it_vat_luhn"},
|
| 24 |
+
"VAT-ES": {"pattern": "^(ES)?[A-Z0-9][0-9]{7}[A-Z0-9]$", "checksum": "es_nif"},
|
| 25 |
+
"VAT-NL": {"pattern": "^(NL)?[0-9]{9}B[0-9]{2}$", "checksum": null},
|
| 26 |
+
"GSTIN-IN": {"pattern": "^[0-9]{2}[A-Z]{5}[0-9]{4}[A-Z][1-9A-Z]Z[0-9A-Z]$", "checksum": "gstin_crc"},
|
| 27 |
+
"PAN-IN": {"pattern": "^[A-Z]{5}[0-9]{4}[A-Z]$", "checksum": null},
|
| 28 |
+
"EIN-US": {"pattern": "^[0-9]{2}-?[0-9]{7}$", "checksum": null},
|
| 29 |
+
"ABN-AU": {"pattern": "^[0-9]{11}$", "checksum": "abn_mod89"},
|
| 30 |
+
"BN-CA": {"pattern": "^[0-9]{9}([A-Z]{2}[0-9]{4})?$", "checksum": "bn_luhn"},
|
| 31 |
+
"TIN-SG": {"pattern": "^(?:[STFG][0-9]{7}[A-Z]|[0-9]{9}[A-Z])$", "checksum": "sg_nric_fin"}
|
| 32 |
+
},
|
| 33 |
+
|
| 34 |
+
"_comment_bank": "Bank validation configs",
|
| 35 |
+
|
| 36 |
+
"validation.iban_checksum_required": true,
|
| 37 |
+
"validation.iban_accno_substring_exempt_countries": ["LC", "MT"],
|
| 38 |
+
|
| 39 |
+
"_comment_date": "Date sanity configs",
|
| 40 |
+
"validation.default_timezone": "Asia/Kolkata",
|
| 41 |
+
"validation.max_invoice_age_days": 365,
|
| 42 |
+
"validation.service_end_post_invoice_grace_days": 0,
|
| 43 |
+
|
| 44 |
+
"_comment_payment": "Payment terms configs",
|
| 45 |
+
"validation.payment_terms_tolerance_days": 2,
|
| 46 |
+
|
| 47 |
+
"_comment_tax": "Tax configs",
|
| 48 |
+
"validation.tax_rate_match_tolerance": "0.0001",
|
| 49 |
+
"iso_currencies.minor_unit_value": {
|
| 50 |
+
"USD": "0.01",
|
| 51 |
+
"GBP": "0.01",
|
| 52 |
+
"EUR": "0.01",
|
| 53 |
+
"INR": "0.01",
|
| 54 |
+
"JPY": "1",
|
| 55 |
+
"BHD": "0.001"
|
| 56 |
+
},
|
| 57 |
+
"validation.eu_member_states": [
|
| 58 |
+
"AT", "BE", "BG", "HR", "CY", "CZ", "DK", "EE", "FI", "FR",
|
| 59 |
+
"DE", "GR", "HU", "IE", "IT", "LV", "LT", "LU", "MT", "NL",
|
| 60 |
+
"PL", "PT", "RO", "SK", "SI", "ES", "SE"
|
| 61 |
+
],
|
| 62 |
+
|
| 63 |
+
"_comment_completeness": "Completeness configs",
|
| 64 |
+
"validation.solver_tolerance.cap_minor_units": 5,
|
| 65 |
+
"validation.solver_tolerance.base_per_minor_unit": 1,
|
| 66 |
+
"validation.solver_tolerance.per_line_minor_units": 1
|
| 67 |
+
}
|
src/Invoice Validation Functions/tax validation.py
ADDED
|
@@ -0,0 +1,587 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from decimal import Decimal, ROUND_HALF_UP
|
| 2 |
+
from datetime import date, datetime
|
| 3 |
+
|
| 4 |
+
|
| 5 |
+
def run_tax_amount_validation(
|
| 6 |
+
*,
|
| 7 |
+
document,
|
| 8 |
+
extracted_fields,
|
| 9 |
+
line_items,
|
| 10 |
+
vendor,
|
| 11 |
+
entity,
|
| 12 |
+
tax_master_rows,
|
| 13 |
+
platform_configs=None,
|
| 14 |
+
):
|
| 15 |
+
"""
|
| 16 |
+
Tax percentage and tax amount validation.
|
| 17 |
+
|
| 18 |
+
Advisory checks:
|
| 19 |
+
- tax rate exists in tax_master
|
| 20 |
+
- tax amount matches taxable base
|
| 21 |
+
- cross-country routing
|
| 22 |
+
- US sales/use tax advisories
|
| 23 |
+
- UK VAT advisories
|
| 24 |
+
- India GST advisories
|
| 25 |
+
- EU reverse charge
|
| 26 |
+
- vendor withholding advisory
|
| 27 |
+
- vendor tax exemption handling
|
| 28 |
+
"""
|
| 29 |
+
|
| 30 |
+
platform_configs = platform_configs or {}
|
| 31 |
+
|
| 32 |
+
tax_rate_tolerance = Decimal(
|
| 33 |
+
str(platform_configs.get("validation.tax_rate_match_tolerance", "0.0001"))
|
| 34 |
+
)
|
| 35 |
+
|
| 36 |
+
currency_minor_units = platform_configs.get(
|
| 37 |
+
"iso_currencies.minor_unit_value",
|
| 38 |
+
{
|
| 39 |
+
"USD": Decimal("0.01"),
|
| 40 |
+
"GBP": Decimal("0.01"),
|
| 41 |
+
"EUR": Decimal("0.01"),
|
| 42 |
+
"INR": Decimal("0.01"),
|
| 43 |
+
"JPY": Decimal("1"),
|
| 44 |
+
"BHD": Decimal("0.001"),
|
| 45 |
+
},
|
| 46 |
+
)
|
| 47 |
+
|
| 48 |
+
eu_member_states = set(
|
| 49 |
+
platform_configs.get(
|
| 50 |
+
"validation.eu_member_states",
|
| 51 |
+
[
|
| 52 |
+
"AT", "BE", "BG", "HR", "CY", "CZ", "DK", "EE", "FI", "FR",
|
| 53 |
+
"DE", "GR", "HU", "IE", "IT", "LV", "LT", "LU", "MT", "NL",
|
| 54 |
+
"PL", "PT", "RO", "SK", "SI", "ES", "SE",
|
| 55 |
+
],
|
| 56 |
+
)
|
| 57 |
+
)
|
| 58 |
+
|
| 59 |
+
def as_decimal(value):
|
| 60 |
+
if value is None or value == "":
|
| 61 |
+
return None
|
| 62 |
+
try:
|
| 63 |
+
return Decimal(str(value))
|
| 64 |
+
except Exception:
|
| 65 |
+
return None
|
| 66 |
+
|
| 67 |
+
def country(value):
|
| 68 |
+
if not value:
|
| 69 |
+
return None
|
| 70 |
+
return str(value).upper()
|
| 71 |
+
|
| 72 |
+
def is_zero(value):
|
| 73 |
+
value = as_decimal(value)
|
| 74 |
+
return value is not None and value == Decimal("0")
|
| 75 |
+
|
| 76 |
+
def is_non_zero(value):
|
| 77 |
+
value = as_decimal(value)
|
| 78 |
+
return value is not None and value != Decimal("0")
|
| 79 |
+
|
| 80 |
+
def invoice_date_value():
|
| 81 |
+
value = getattr(extracted_fields, "invoice_date", None)
|
| 82 |
+
if isinstance(value, datetime):
|
| 83 |
+
return value.date()
|
| 84 |
+
if isinstance(value, date):
|
| 85 |
+
return value
|
| 86 |
+
return None # do not invent today's date
|
| 87 |
+
|
| 88 |
+
def currency_tolerance():
|
| 89 |
+
invoice_currency = country(getattr(extracted_fields, "currency", None))
|
| 90 |
+
raw = currency_minor_units.get(invoice_currency)
|
| 91 |
+
return Decimal(str(raw)) if raw is not None else None
|
| 92 |
+
|
| 93 |
+
def add_flag(code, *, weight=None, detail=None):
|
| 94 |
+
flag = {"code": code, "severity": "advisory"}
|
| 95 |
+
if weight is not None:
|
| 96 |
+
flag["weight"] = weight
|
| 97 |
+
if detail is not None:
|
| 98 |
+
flag["detail"] = detail
|
| 99 |
+
risk_flags.append(flag)
|
| 100 |
+
|
| 101 |
+
def add_advisory(code, *, detail=None):
|
| 102 |
+
advisory = {"code": code, "severity": "advisory"}
|
| 103 |
+
if detail is not None:
|
| 104 |
+
advisory["detail"] = detail
|
| 105 |
+
# avoid duplicates
|
| 106 |
+
if not any(a["code"] == code for a in advisory_flags):
|
| 107 |
+
advisory_flags.append(advisory)
|
| 108 |
+
|
| 109 |
+
def add_skip(check_name, reason):
|
| 110 |
+
skipped_checks.append({"check": check_name, "skip_reason": reason})
|
| 111 |
+
|
| 112 |
+
def effective_tax_master_rows():
|
| 113 |
+
invoice_date = invoice_date_value()
|
| 114 |
+
rows = []
|
| 115 |
+
for row in tax_master_rows:
|
| 116 |
+
if getattr(row, "tenant_id", None) != document.tenant_id:
|
| 117 |
+
continue
|
| 118 |
+
if not getattr(row, "is_active", True):
|
| 119 |
+
continue
|
| 120 |
+
if getattr(row, "deleted_at", None) is not None:
|
| 121 |
+
continue
|
| 122 |
+
|
| 123 |
+
# If invoice date is unavailable, do not invent one.
|
| 124 |
+
# Keep only active rows, but skip date-sensitive matching later if needed.
|
| 125 |
+
if invoice_date is not None:
|
| 126 |
+
effective_from = getattr(row, "effective_from", None)
|
| 127 |
+
effective_to = getattr(row, "effective_to", None)
|
| 128 |
+
|
| 129 |
+
if effective_from is not None and effective_from > invoice_date:
|
| 130 |
+
continue
|
| 131 |
+
if effective_to is not None and invoice_date >= effective_to:
|
| 132 |
+
continue
|
| 133 |
+
|
| 134 |
+
rows.append(row)
|
| 135 |
+
return rows
|
| 136 |
+
|
| 137 |
+
def rate_matches(master_rate, invoice_rate):
|
| 138 |
+
master_rate = as_decimal(master_rate)
|
| 139 |
+
invoice_rate = as_decimal(invoice_rate)
|
| 140 |
+
if master_rate is None or invoice_rate is None:
|
| 141 |
+
return False
|
| 142 |
+
return abs(master_rate - invoice_rate) <= tax_rate_tolerance
|
| 143 |
+
|
| 144 |
+
def row_region_matches(row_region, hint_region):
|
| 145 |
+
if row_region is None:
|
| 146 |
+
return True
|
| 147 |
+
return hint_region is not None and str(row_region).upper() == str(hint_region).upper()
|
| 148 |
+
|
| 149 |
+
def find_tax_master_rate(
|
| 150 |
+
*,
|
| 151 |
+
rate,
|
| 152 |
+
country_code,
|
| 153 |
+
region_code=None,
|
| 154 |
+
allowed_tax_types=None,
|
| 155 |
+
reduced_rate_allowed=False,
|
| 156 |
+
):
|
| 157 |
+
country_code = country(country_code)
|
| 158 |
+
matches = []
|
| 159 |
+
|
| 160 |
+
for row in active_tax_master:
|
| 161 |
+
if country(getattr(row, "country_code", None)) != country_code:
|
| 162 |
+
continue
|
| 163 |
+
|
| 164 |
+
if not row_region_matches(getattr(row, "region_code", None), region_code):
|
| 165 |
+
continue
|
| 166 |
+
|
| 167 |
+
if allowed_tax_types is not None and getattr(row, "tax_type", None) not in allowed_tax_types:
|
| 168 |
+
continue
|
| 169 |
+
|
| 170 |
+
if rate_matches(getattr(row, "tax_rate", None), rate):
|
| 171 |
+
matches.append(row)
|
| 172 |
+
continue
|
| 173 |
+
|
| 174 |
+
elif reduced_rate_allowed:
|
| 175 |
+
tax_name = str(getattr(row, "tax_name", "") or "").lower()
|
| 176 |
+
if "reduced" in tax_name:
|
| 177 |
+
matches.append(row)
|
| 178 |
+
|
| 179 |
+
return matches
|
| 180 |
+
|
| 181 |
+
def find_india_gst_aggregate_rate(rate, region_code, jurisdiction):
|
| 182 |
+
"""
|
| 183 |
+
Match invoice aggregate GST rate against the sum of applicable component rates:
|
| 184 |
+
inter_state : IGST single-row match
|
| 185 |
+
intra_state : CGST + SGST pair whose rates sum to the invoice rate
|
| 186 |
+
None/unknown: try IGST, then CGST+SGST, then CGST+UTGST
|
| 187 |
+
"""
|
| 188 |
+
|
| 189 |
+
def in_rows(tax_type, rgn=None):
|
| 190 |
+
return [
|
| 191 |
+
r for r in active_tax_master
|
| 192 |
+
if country(getattr(r, "country_code", None)) == "IN"
|
| 193 |
+
and getattr(r, "tax_type", None) == tax_type
|
| 194 |
+
and row_region_matches(getattr(r, "region_code", None), rgn)
|
| 195 |
+
]
|
| 196 |
+
|
| 197 |
+
def sum_pair(type_a, type_b, rgn=None):
|
| 198 |
+
for a in in_rows(type_a, rgn):
|
| 199 |
+
a_rate = as_decimal(getattr(a, "tax_rate", None))
|
| 200 |
+
if a_rate is None:
|
| 201 |
+
continue
|
| 202 |
+
for b in in_rows(type_b, rgn):
|
| 203 |
+
b_rate = as_decimal(getattr(b, "tax_rate", None))
|
| 204 |
+
if b_rate is None:
|
| 205 |
+
continue
|
| 206 |
+
if abs((a_rate + b_rate) - rate) <= tax_rate_tolerance:
|
| 207 |
+
return [a, b]
|
| 208 |
+
return []
|
| 209 |
+
|
| 210 |
+
if jurisdiction == "inter_state":
|
| 211 |
+
for r in in_rows("IGST", region_code):
|
| 212 |
+
if rate_matches(getattr(r, "tax_rate", None), rate):
|
| 213 |
+
return [r]
|
| 214 |
+
return []
|
| 215 |
+
|
| 216 |
+
if jurisdiction == "intra_state":
|
| 217 |
+
return sum_pair("CGST", "SGST", region_code)
|
| 218 |
+
|
| 219 |
+
# Unknown jurisdiction: try all applicable component combinations.
|
| 220 |
+
for r in in_rows("IGST"):
|
| 221 |
+
if rate_matches(getattr(r, "tax_rate", None), rate):
|
| 222 |
+
return [r]
|
| 223 |
+
result = sum_pair("CGST", "SGST")
|
| 224 |
+
if result:
|
| 225 |
+
return result
|
| 226 |
+
return sum_pair("CGST", "UTGST")
|
| 227 |
+
|
| 228 |
+
def all_line_rates_zero():
|
| 229 |
+
for line in line_items:
|
| 230 |
+
rate = as_decimal(getattr(line, "tax_rate_per_item", None))
|
| 231 |
+
if rate is None:
|
| 232 |
+
continue
|
| 233 |
+
if rate != Decimal("0"):
|
| 234 |
+
return False
|
| 235 |
+
return True
|
| 236 |
+
|
| 237 |
+
def all_line_tax_amounts_zero():
|
| 238 |
+
found_any = False
|
| 239 |
+
for line in line_items:
|
| 240 |
+
amount = as_decimal(getattr(line, "tax_amount_per_item", None))
|
| 241 |
+
if amount is None:
|
| 242 |
+
continue
|
| 243 |
+
found_any = True
|
| 244 |
+
if amount != Decimal("0"):
|
| 245 |
+
return False
|
| 246 |
+
|
| 247 |
+
summary_tax_amount = as_decimal(getattr(extracted_fields, "tax_amount", None))
|
| 248 |
+
if summary_tax_amount is not None:
|
| 249 |
+
found_any = True
|
| 250 |
+
if summary_tax_amount != Decimal("0"):
|
| 251 |
+
return False
|
| 252 |
+
|
| 253 |
+
return found_any
|
| 254 |
+
|
| 255 |
+
def sender_tax_family():
|
| 256 |
+
return getattr(extracted_fields, "sender_tax_id_inferred_family", None)
|
| 257 |
+
|
| 258 |
+
def sender_tax_valid_for_country(expected_family):
|
| 259 |
+
return (
|
| 260 |
+
getattr(extracted_fields, "sender_tax_id", None) is not None
|
| 261 |
+
and sender_tax_family() == expected_family
|
| 262 |
+
and bool(getattr(extracted_fields, "sender_tax_id_format_valid", True))
|
| 263 |
+
and bool(getattr(extracted_fields, "sender_tax_id_checksum_valid", True))
|
| 264 |
+
)
|
| 265 |
+
|
| 266 |
+
def supplier_and_recipient_are_eu():
|
| 267 |
+
return supplier_country in eu_member_states and recipient_country in eu_member_states
|
| 268 |
+
|
| 269 |
+
def eu_reverse_charge_conditions_met():
|
| 270 |
+
if not supplier_and_recipient_are_eu():
|
| 271 |
+
return False
|
| 272 |
+
if supplier_country == recipient_country:
|
| 273 |
+
return False
|
| 274 |
+
|
| 275 |
+
if vendor_exemption_status == "reverse_charge":
|
| 276 |
+
return True
|
| 277 |
+
|
| 278 |
+
supplier_family = f"VAT-{supplier_country}"
|
| 279 |
+
supplier_vat_valid = sender_tax_valid_for_country(supplier_family)
|
| 280 |
+
recipient_vat_present = bool(getattr(entity, "vat_id", None))
|
| 281 |
+
|
| 282 |
+
return (
|
| 283 |
+
supplier_vat_valid
|
| 284 |
+
and recipient_vat_present
|
| 285 |
+
and all_line_rates_zero()
|
| 286 |
+
and is_zero(getattr(extracted_fields, "tax_amount", None))
|
| 287 |
+
)
|
| 288 |
+
|
| 289 |
+
def india_jurisdiction():
|
| 290 |
+
supplier_state = getattr(vendor, "billing_state", None)
|
| 291 |
+
recipient_state = getattr(entity, "region_code", None)
|
| 292 |
+
if not supplier_state or not recipient_state:
|
| 293 |
+
return None
|
| 294 |
+
if str(supplier_state).upper() == str(recipient_state).upper():
|
| 295 |
+
return "intra_state"
|
| 296 |
+
return "inter_state"
|
| 297 |
+
|
| 298 |
+
risk_flags = []
|
| 299 |
+
advisory_flags = []
|
| 300 |
+
skipped_checks = []
|
| 301 |
+
ops_alerts = []
|
| 302 |
+
matched_tax_master_rows = {}
|
| 303 |
+
|
| 304 |
+
active_tax_master = effective_tax_master_rows()
|
| 305 |
+
|
| 306 |
+
supplier_country = country(getattr(vendor, "billing_country", None))
|
| 307 |
+
recipient_country = country(getattr(entity, "country_code", None))
|
| 308 |
+
invoice_currency = country(getattr(extracted_fields, "currency", None))
|
| 309 |
+
vendor_exemption_status = getattr(vendor, "exemption_status", None)
|
| 310 |
+
invoice_date = invoice_date_value()
|
| 311 |
+
|
| 312 |
+
if not active_tax_master:
|
| 313 |
+
ops_alerts.append(
|
| 314 |
+
{
|
| 315 |
+
"code": "alert.tax_master_unconfigured",
|
| 316 |
+
"tenant_id": document.tenant_id,
|
| 317 |
+
}
|
| 318 |
+
)
|
| 319 |
+
return {
|
| 320 |
+
"risk_flags": risk_flags,
|
| 321 |
+
"advisory_flags": advisory_flags,
|
| 322 |
+
"skipped_checks": [
|
| 323 |
+
{"check": "all_tax_checks", "skip_reason": "tax_master_unconfigured"}
|
| 324 |
+
],
|
| 325 |
+
"ops_alerts": ops_alerts,
|
| 326 |
+
"matched_tax_master_rows": matched_tax_master_rows,
|
| 327 |
+
}
|
| 328 |
+
|
| 329 |
+
# Check 1 and Check 2 both skip if supplier country is missing.
|
| 330 |
+
if not supplier_country:
|
| 331 |
+
add_skip("rate_exists_in_master", "supplier_country_missing")
|
| 332 |
+
add_skip("tax_amount_matches_taxable_base", "supplier_country_missing")
|
| 333 |
+
else:
|
| 334 |
+
unique_rates = set()
|
| 335 |
+
|
| 336 |
+
# Gather unique non-null, non-zero line rates
|
| 337 |
+
for line in line_items:
|
| 338 |
+
rate = as_decimal(getattr(line, "tax_rate_per_item", None))
|
| 339 |
+
if rate is not None and rate != Decimal("0"):
|
| 340 |
+
unique_rates.add(
|
| 341 |
+
(
|
| 342 |
+
rate,
|
| 343 |
+
getattr(line, "region_code_hint", None),
|
| 344 |
+
getattr(line, "line_number", None),
|
| 345 |
+
)
|
| 346 |
+
)
|
| 347 |
+
|
| 348 |
+
# Summary tax rate is checked separately
|
| 349 |
+
summary_rate = as_decimal(getattr(extracted_fields, "tax_rate", None))
|
| 350 |
+
if summary_rate is not None and summary_rate != Decimal("0"):
|
| 351 |
+
unique_rates.add((summary_rate, None, "summary"))
|
| 352 |
+
|
| 353 |
+
for rate, region_code, source in unique_rates:
|
| 354 |
+
# India aggregate rate matching is handled entirely in Check 6.
|
| 355 |
+
if supplier_country == "IN":
|
| 356 |
+
continue
|
| 357 |
+
|
| 358 |
+
# US state tax needs a region hint
|
| 359 |
+
if supplier_country == "US" and not region_code:
|
| 360 |
+
add_advisory("us_region_unresolved", detail={"source": source})
|
| 361 |
+
continue
|
| 362 |
+
|
| 363 |
+
allowed_tax_types = None
|
| 364 |
+
if supplier_country == "GB":
|
| 365 |
+
allowed_tax_types = {"VAT"}
|
| 366 |
+
if supplier_country == "IN":
|
| 367 |
+
allowed_tax_types = {"CGST", "SGST", "IGST", "UTGST"}
|
| 368 |
+
if supplier_country == "US":
|
| 369 |
+
allowed_tax_types = None
|
| 370 |
+
|
| 371 |
+
reduced_rate_allowed = vendor_exemption_status == "reduced_rate"
|
| 372 |
+
|
| 373 |
+
matches = find_tax_master_rate(
|
| 374 |
+
rate=rate,
|
| 375 |
+
country_code=supplier_country,
|
| 376 |
+
region_code=region_code,
|
| 377 |
+
allowed_tax_types=allowed_tax_types,
|
| 378 |
+
reduced_rate_allowed=reduced_rate_allowed,
|
| 379 |
+
)
|
| 380 |
+
|
| 381 |
+
if matches:
|
| 382 |
+
matched_tax_master_rows[source] = [getattr(row, "id", None) for row in matches]
|
| 383 |
+
else:
|
| 384 |
+
add_flag(
|
| 385 |
+
"tax_rate_not_in_master",
|
| 386 |
+
weight=Decimal("0.35"),
|
| 387 |
+
detail={
|
| 388 |
+
"rate": str(rate),
|
| 389 |
+
"country": supplier_country,
|
| 390 |
+
"region_code": region_code,
|
| 391 |
+
"source": source,
|
| 392 |
+
},
|
| 393 |
+
)
|
| 394 |
+
|
| 395 |
+
|
| 396 |
+
# Check 2: Amount matches taxable base.
|
| 397 |
+
if supplier_country:
|
| 398 |
+
tolerance = currency_tolerance()
|
| 399 |
+
if tolerance is None:
|
| 400 |
+
add_skip("tax_amount_matches_taxable_base", "currency_minor_unit_unknown")
|
| 401 |
+
else:
|
| 402 |
+
for line in line_items:
|
| 403 |
+
rate = as_decimal(getattr(line, "tax_rate_per_item", None))
|
| 404 |
+
amount = as_decimal(getattr(line, "amount", None))
|
| 405 |
+
actual_tax = as_decimal(getattr(line, "tax_amount_per_item", None))
|
| 406 |
+
|
| 407 |
+
if rate is None or amount is None or actual_tax is None:
|
| 408 |
+
continue
|
| 409 |
+
|
| 410 |
+
discount = as_decimal(getattr(line, "discount_amount_per_item", None)) or Decimal("0")
|
| 411 |
+
taxable_base = amount - discount
|
| 412 |
+
expected_tax = taxable_base * (rate / Decimal("100"))
|
| 413 |
+
|
| 414 |
+
if abs(expected_tax - actual_tax) > tolerance:
|
| 415 |
+
add_flag(
|
| 416 |
+
"tax_amount_mismatch",
|
| 417 |
+
weight=Decimal("0.35"),
|
| 418 |
+
detail={
|
| 419 |
+
"line_number": getattr(line, "line_number", None),
|
| 420 |
+
"expected_tax": str(expected_tax.quantize(tolerance, rounding=ROUND_HALF_UP)),
|
| 421 |
+
"actual_tax": str(actual_tax),
|
| 422 |
+
"currency": invoice_currency,
|
| 423 |
+
},
|
| 424 |
+
)
|
| 425 |
+
|
| 426 |
+
# §8.9: vendor_tax_exempt advisory fires when all extracted line rates are zero.
|
| 427 |
+
if vendor_exemption_status == "exempt" and all_line_rates_zero():
|
| 428 |
+
add_advisory("vendor_tax_exempt")
|
| 429 |
+
|
| 430 |
+
# Check 3: Cross-country routing.
|
| 431 |
+
summary_tax_amount = as_decimal(getattr(extracted_fields, "tax_amount", None))
|
| 432 |
+
|
| 433 |
+
if supplier_country is None or summary_tax_amount is None or summary_tax_amount == Decimal("0"):
|
| 434 |
+
add_skip("cross_country_routing", "supplier_country_or_tax_amount_missing_or_zero")
|
| 435 |
+
elif recipient_country is None:
|
| 436 |
+
add_skip("cross_country_routing", "recipient_country_missing")
|
| 437 |
+
elif supplier_country != recipient_country:
|
| 438 |
+
add_flag(
|
| 439 |
+
"cross_country_tax",
|
| 440 |
+
weight=Decimal("0.30"),
|
| 441 |
+
detail={
|
| 442 |
+
"supplier_country": supplier_country,
|
| 443 |
+
"recipient_country": recipient_country,
|
| 444 |
+
},
|
| 445 |
+
)
|
| 446 |
+
|
| 447 |
+
# Check 4: United States.
|
| 448 |
+
if supplier_country == "US" or recipient_country == "US":
|
| 449 |
+
if supplier_country == "US" and summary_tax_amount is not None and summary_tax_amount != Decimal("0"):
|
| 450 |
+
add_flag(
|
| 451 |
+
"us_sales_tax",
|
| 452 |
+
weight=Decimal("0.20"),
|
| 453 |
+
detail={"tax_amount": str(summary_tax_amount)},
|
| 454 |
+
)
|
| 455 |
+
|
| 456 |
+
if (
|
| 457 |
+
recipient_country == "US"
|
| 458 |
+
and supplier_country != recipient_country
|
| 459 |
+
and all_line_rates_zero()
|
| 460 |
+
):
|
| 461 |
+
add_flag(
|
| 462 |
+
"us_use_tax_possibly_owed",
|
| 463 |
+
weight=Decimal("0.15"),
|
| 464 |
+
)
|
| 465 |
+
|
| 466 |
+
# Check 5: United Kingdom.
|
| 467 |
+
if supplier_country == "GB" or recipient_country == "GB":
|
| 468 |
+
for line in line_items:
|
| 469 |
+
rate = as_decimal(getattr(line, "tax_rate_per_item", None))
|
| 470 |
+
tax_amount = as_decimal(getattr(line, "tax_amount_per_item", None))
|
| 471 |
+
|
| 472 |
+
if rate == Decimal("0"):
|
| 473 |
+
gb_matches = find_tax_master_rate(
|
| 474 |
+
rate=rate,
|
| 475 |
+
country_code="GB",
|
| 476 |
+
region_code=getattr(line, "region_code_hint", None),
|
| 477 |
+
allowed_tax_types={"zero_rated", "exempt", "VAT"},
|
| 478 |
+
reduced_rate_allowed=False,
|
| 479 |
+
)
|
| 480 |
+
|
| 481 |
+
if any(getattr(row, "tax_type", None) == "zero_rated" for row in gb_matches):
|
| 482 |
+
add_advisory("uk_zero_rated", detail={"line_number": getattr(line, "line_number", None)})
|
| 483 |
+
elif tax_amount == Decimal("0") and any(
|
| 484 |
+
getattr(row, "tax_type", None) == "exempt" for row in gb_matches
|
| 485 |
+
):
|
| 486 |
+
add_advisory("uk_exempt", detail={"line_number": getattr(line, "line_number", None)})
|
| 487 |
+
elif not (
|
| 488 |
+
vendor_exemption_status == "reverse_charge"
|
| 489 |
+
and supplier_and_recipient_are_eu()
|
| 490 |
+
and supplier_country != recipient_country
|
| 491 |
+
) and not (
|
| 492 |
+
vendor_exemption_status == "exempt"
|
| 493 |
+
and all_line_rates_zero()
|
| 494 |
+
):
|
| 495 |
+
add_flag(
|
| 496 |
+
"tax_rate_not_in_master",
|
| 497 |
+
weight=Decimal("0.35"),
|
| 498 |
+
detail={
|
| 499 |
+
"rate": "0",
|
| 500 |
+
"country": "GB",
|
| 501 |
+
"source": getattr(line, "line_number", None),
|
| 502 |
+
},
|
| 503 |
+
)
|
| 504 |
+
|
| 505 |
+
if (
|
| 506 |
+
supplier_country == "GB"
|
| 507 |
+
and recipient_country == "GB"
|
| 508 |
+
and summary_tax_amount is not None
|
| 509 |
+
and summary_tax_amount != Decimal("0")
|
| 510 |
+
and not sender_tax_valid_for_country("VAT-GB")
|
| 511 |
+
):
|
| 512 |
+
add_flag(
|
| 513 |
+
"missing_tax_id_for_local_tax",
|
| 514 |
+
weight=Decimal("0.25"),
|
| 515 |
+
detail={"expected_family": "VAT-GB"},
|
| 516 |
+
)
|
| 517 |
+
|
| 518 |
+
# Check 6: India GST.
|
| 519 |
+
if supplier_country == "IN" or recipient_country == "IN":
|
| 520 |
+
jurisdiction = india_jurisdiction()
|
| 521 |
+
if jurisdiction is None:
|
| 522 |
+
add_advisory("in_gst_jurisdiction_unresolved")
|
| 523 |
+
else:
|
| 524 |
+
add_advisory("in_gst_jurisdiction_inferred", detail={"jurisdiction": jurisdiction})
|
| 525 |
+
|
| 526 |
+
# Sender tax ID must look like GSTIN-IN or PAN-IN
|
| 527 |
+
if sender_tax_family() not in {"GSTIN-IN", "PAN-IN"}:
|
| 528 |
+
add_flag(
|
| 529 |
+
"missing_tax_id_for_local_tax",
|
| 530 |
+
weight=Decimal("0.25"),
|
| 531 |
+
detail={"expected_family": "GSTIN-IN or PAN-IN"},
|
| 532 |
+
)
|
| 533 |
+
|
| 534 |
+
for line in line_items:
|
| 535 |
+
rate = as_decimal(getattr(line, "tax_rate_per_item", None))
|
| 536 |
+
if rate is None or rate == Decimal("0"):
|
| 537 |
+
continue
|
| 538 |
+
|
| 539 |
+
region_code = None
|
| 540 |
+
if jurisdiction == "intra_state":
|
| 541 |
+
region_code = getattr(vendor, "billing_state", None) or getattr(entity, "region_code", None)
|
| 542 |
+
|
| 543 |
+
matches = find_india_gst_aggregate_rate(rate, region_code, jurisdiction)
|
| 544 |
+
|
| 545 |
+
if matches:
|
| 546 |
+
matched_tax_master_rows[getattr(line, "line_number", None)] = [
|
| 547 |
+
getattr(r, "id", None) for r in matches
|
| 548 |
+
]
|
| 549 |
+
else:
|
| 550 |
+
add_flag(
|
| 551 |
+
"tax_rate_not_in_master",
|
| 552 |
+
weight=Decimal("0.35"),
|
| 553 |
+
detail={
|
| 554 |
+
"rate": str(rate),
|
| 555 |
+
"country": "IN",
|
| 556 |
+
"region_code": region_code,
|
| 557 |
+
"source": getattr(line, "line_number", None),
|
| 558 |
+
},
|
| 559 |
+
)
|
| 560 |
+
|
| 561 |
+
# Check 7: EU reverse charge.
|
| 562 |
+
# If conditions are met, add advisory and retract cross_country_tax raised by Check 3.
|
| 563 |
+
if supplier_and_recipient_are_eu() and supplier_country != recipient_country:
|
| 564 |
+
if eu_reverse_charge_conditions_met():
|
| 565 |
+
add_advisory("eu_reverse_charge")
|
| 566 |
+
risk_flags[:] = [f for f in risk_flags if f["code"] != "cross_country_tax"]
|
| 567 |
+
|
| 568 |
+
# Check 8: Withholding tax advisory.
|
| 569 |
+
if vendor_exemption_status == "withholding_applicable":
|
| 570 |
+
add_advisory("vendor_withholding_applicable")
|
| 571 |
+
|
| 572 |
+
for line in line_items:
|
| 573 |
+
tax_master_id = getattr(line, "tax_master_id", None)
|
| 574 |
+
if not tax_master_id:
|
| 575 |
+
continue
|
| 576 |
+
for row in active_tax_master:
|
| 577 |
+
if getattr(row, "id", None) == tax_master_id and getattr(row, "tax_type", None) == "withholding":
|
| 578 |
+
add_advisory("vendor_withholding_applicable")
|
| 579 |
+
break
|
| 580 |
+
|
| 581 |
+
return {
|
| 582 |
+
"risk_flags": risk_flags,
|
| 583 |
+
"advisory_flags": advisory_flags,
|
| 584 |
+
"skipped_checks": skipped_checks,
|
| 585 |
+
"ops_alerts": ops_alerts,
|
| 586 |
+
"matched_tax_master_rows": matched_tax_master_rows,
|
| 587 |
+
}
|
src/Matching Functions/contract_match.py
ADDED
|
@@ -0,0 +1,757 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
from dataclasses import dataclass, field
|
| 4 |
+
from datetime import date, datetime
|
| 5 |
+
from decimal import Decimal, ROUND_HALF_EVEN, getcontext
|
| 6 |
+
from typing import Any, Callable, Optional
|
| 7 |
+
|
| 8 |
+
import re
|
| 9 |
+
import unicodedata
|
| 10 |
+
|
| 11 |
+
from rapidfuzz import fuzz
|
| 12 |
+
|
| 13 |
+
from two_way_match import MatchExceptionRecord, InvoiceLine, to_decimal
|
| 14 |
+
|
| 15 |
+
# ============================================================
|
| 16 |
+
# Decimal hygiene
|
| 17 |
+
# ============================================================
|
| 18 |
+
getcontext().prec = 28
|
| 19 |
+
getcontext().rounding = ROUND_HALF_EVEN
|
| 20 |
+
|
| 21 |
+
D0 = Decimal("0")
|
| 22 |
+
D100 = Decimal("100")
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
# ============================================================
|
| 26 |
+
# Data models
|
| 27 |
+
# ============================================================
|
| 28 |
+
@dataclass(frozen=True)
|
| 29 |
+
class RateCardItem:
|
| 30 |
+
id: int
|
| 31 |
+
description: Optional[str] = None
|
| 32 |
+
description_canonical: Optional[str] = None
|
| 33 |
+
unit_price: Optional[Decimal] = None
|
| 34 |
+
item_type: Optional[str] = None # goods | services | None
|
| 35 |
+
|
| 36 |
+
|
| 37 |
+
@dataclass(frozen=True)
|
| 38 |
+
class ContractRecord:
|
| 39 |
+
id: int
|
| 40 |
+
tenant_id: int
|
| 41 |
+
vendor_id: int
|
| 42 |
+
entity_id: int
|
| 43 |
+
currency: str
|
| 44 |
+
status: str = "active"
|
| 45 |
+
deleted_at: Any = None
|
| 46 |
+
effective_from: Optional[date] = None
|
| 47 |
+
effective_to: Optional[date] = None
|
| 48 |
+
created_at: Optional[datetime] = None
|
| 49 |
+
total_value: Optional[Decimal] = None
|
| 50 |
+
cumulative_billed_amount: Optional[Decimal] = None
|
| 51 |
+
rate_card_items: Optional[list[RateCardItem]] = None
|
| 52 |
+
|
| 53 |
+
|
| 54 |
+
@dataclass(frozen=True)
|
| 55 |
+
class ContractInvoiceHeader:
|
| 56 |
+
id: int
|
| 57 |
+
tenant_id: int
|
| 58 |
+
entity_id: int
|
| 59 |
+
resolved_vendor_id: Optional[int]
|
| 60 |
+
currency: Optional[str]
|
| 61 |
+
invoice_date: Optional[date]
|
| 62 |
+
service_start_date: Optional[date] = None
|
| 63 |
+
service_end_date: Optional[date] = None
|
| 64 |
+
total_amount: Optional[Decimal] = None
|
| 65 |
+
advisory_flags: tuple[str, ...] = ()
|
| 66 |
+
|
| 67 |
+
|
| 68 |
+
@dataclass
|
| 69 |
+
class ContractLineResult:
|
| 70 |
+
invoice_line_id: int
|
| 71 |
+
rate_card_item_id: Optional[int]
|
| 72 |
+
line_status: str = "matched" # matched | no_match | exception
|
| 73 |
+
line_zone: str = "green" # green | amber | red
|
| 74 |
+
price_var_pct: Optional[Decimal] = None
|
| 75 |
+
allocated_inv_amount: Optional[Decimal] = None
|
| 76 |
+
allocated_rate_amount: Optional[Decimal] = None
|
| 77 |
+
exception_type: Optional[str] = None
|
| 78 |
+
exception_detail: dict[str, Any] = field(default_factory=dict)
|
| 79 |
+
|
| 80 |
+
|
| 81 |
+
@dataclass
|
| 82 |
+
class ContractMatchResult:
|
| 83 |
+
match_type: str = "contract"
|
| 84 |
+
overall_status: str = "exception" # matched | partial_match | exception
|
| 85 |
+
match_zone: str = "red" # green | amber | red | critical
|
| 86 |
+
documents_status: str = "pending_approval"
|
| 87 |
+
|
| 88 |
+
lines_total: int = 0
|
| 89 |
+
lines_matched: int = 0
|
| 90 |
+
lines_in_exception: int = 0
|
| 91 |
+
|
| 92 |
+
advisory_flags: list[str] = field(default_factory=list)
|
| 93 |
+
risk_flags: list[str] = field(default_factory=list)
|
| 94 |
+
skipped_steps: list[dict] = field(default_factory=list)
|
| 95 |
+
header_exceptions: list[MatchExceptionRecord] = field(default_factory=list)
|
| 96 |
+
match_exceptions: list[MatchExceptionRecord] = field(default_factory=list)
|
| 97 |
+
match_line_results: list[ContractLineResult] = field(default_factory=list)
|
| 98 |
+
|
| 99 |
+
selected_contract_id: Optional[int] = None
|
| 100 |
+
|
| 101 |
+
|
| 102 |
+
@dataclass(frozen=True)
|
| 103 |
+
class ContractConfig:
|
| 104 |
+
# RapidFuzz thresholds for rate-card / tiebreaker matching
|
| 105 |
+
high_threshold: float = 90.0
|
| 106 |
+
medium_threshold: float = 80.0
|
| 107 |
+
score_cutoff: float = 80.0
|
| 108 |
+
|
| 109 |
+
# Price variance bands (same idea as 2-way; tune to your config table)
|
| 110 |
+
price_goods_green: Decimal = Decimal("5")
|
| 111 |
+
price_goods_amber: Decimal = Decimal("10")
|
| 112 |
+
price_services_green: Decimal = Decimal("10")
|
| 113 |
+
price_services_amber: Decimal = Decimal("15")
|
| 114 |
+
|
| 115 |
+
precision: int = 28
|
| 116 |
+
|
| 117 |
+
|
| 118 |
+
# ============================================================
|
| 119 |
+
# Helpers
|
| 120 |
+
# ============================================================
|
| 121 |
+
def canonicalise_identifier(value: Optional[str]) -> Optional[str]:
|
| 122 |
+
if value is None:
|
| 123 |
+
return None
|
| 124 |
+
value = unicodedata.normalize("NFKC", str(value)).upper()
|
| 125 |
+
value = re.sub(r"[^A-Z0-9]+", "", value)
|
| 126 |
+
return value or None
|
| 127 |
+
|
| 128 |
+
|
| 129 |
+
def norm_currency(value: Optional[str]) -> Optional[str]:
|
| 130 |
+
return value.upper().strip() if value else None
|
| 131 |
+
|
| 132 |
+
|
| 133 |
+
def price_variance_pct(inv_price: Optional[Decimal], ref_price: Optional[Decimal]) -> Optional[Decimal]:
|
| 134 |
+
if inv_price is None or ref_price is None or ref_price == 0:
|
| 135 |
+
return None
|
| 136 |
+
return (abs(inv_price - ref_price) / ref_price * D100).quantize(Decimal("0.0001"))
|
| 137 |
+
|
| 138 |
+
|
| 139 |
+
def _bands(item_type: Optional[str], cfg: ContractConfig) -> tuple[Decimal, Decimal]:
|
| 140 |
+
item_type = (item_type or "services").lower()
|
| 141 |
+
if item_type == "goods":
|
| 142 |
+
return cfg.price_goods_green, cfg.price_goods_amber
|
| 143 |
+
return cfg.price_services_green, cfg.price_services_amber
|
| 144 |
+
|
| 145 |
+
|
| 146 |
+
def _line_zone_from_price(price_pct: Optional[Decimal], item_type: Optional[str], cfg: ContractConfig) -> str:
|
| 147 |
+
if price_pct is None:
|
| 148 |
+
return "green"
|
| 149 |
+
green, amber = _bands(item_type, cfg)
|
| 150 |
+
if price_pct <= green:
|
| 151 |
+
return "green"
|
| 152 |
+
if price_pct <= amber:
|
| 153 |
+
return "amber"
|
| 154 |
+
return "red"
|
| 155 |
+
|
| 156 |
+
|
| 157 |
+
def _is_service_window_contained(
|
| 158 |
+
contract: ContractRecord,
|
| 159 |
+
invoice_start: Optional[date],
|
| 160 |
+
invoice_end: Optional[date],
|
| 161 |
+
) -> bool:
|
| 162 |
+
if invoice_start is None or invoice_end is None:
|
| 163 |
+
return False
|
| 164 |
+
if contract.effective_from is None:
|
| 165 |
+
return False
|
| 166 |
+
effective_to = contract.effective_to or date(9999, 12, 31)
|
| 167 |
+
return contract.effective_from <= invoice_start and invoice_end <= effective_to
|
| 168 |
+
|
| 169 |
+
|
| 170 |
+
def _joined_invoice_descriptions(inv_lines: list[InvoiceLine]) -> str:
|
| 171 |
+
parts = []
|
| 172 |
+
for line in inv_lines:
|
| 173 |
+
if line.description_canonical:
|
| 174 |
+
parts.append(line.description_canonical)
|
| 175 |
+
elif line.description:
|
| 176 |
+
parts.append(canonicalise_identifier(line.description) or "")
|
| 177 |
+
return " ".join(p for p in parts if p).strip()
|
| 178 |
+
|
| 179 |
+
|
| 180 |
+
def rapidfuzz_best_match(
|
| 181 |
+
query: str,
|
| 182 |
+
candidates: list[dict[str, Any]],
|
| 183 |
+
*,
|
| 184 |
+
high_threshold: float,
|
| 185 |
+
medium_threshold: float,
|
| 186 |
+
score_cutoff: float,
|
| 187 |
+
) -> dict[str, Any]:
|
| 188 |
+
"""
|
| 189 |
+
Simple local matcher.
|
| 190 |
+
Swap this out with your platform name-match algorithm later if required.
|
| 191 |
+
"""
|
| 192 |
+
if not query or not candidates:
|
| 193 |
+
return {"decision": "unmatched", "score_tier": None, "score": 0.0, "record_id": None}
|
| 194 |
+
|
| 195 |
+
best_record_id = None
|
| 196 |
+
best_score = 0.0
|
| 197 |
+
|
| 198 |
+
for cand in candidates:
|
| 199 |
+
record_id = cand.get("record_id")
|
| 200 |
+
names = cand.get("names") or []
|
| 201 |
+
for name in names:
|
| 202 |
+
if not name:
|
| 203 |
+
continue
|
| 204 |
+
score = float(fuzz.WRatio(query, name, score_cutoff=score_cutoff))
|
| 205 |
+
if score > best_score:
|
| 206 |
+
best_score = score
|
| 207 |
+
best_record_id = record_id
|
| 208 |
+
|
| 209 |
+
if best_record_id is None or best_score < score_cutoff:
|
| 210 |
+
return {"decision": "unmatched", "score_tier": None, "score": 0.0, "record_id": None}
|
| 211 |
+
|
| 212 |
+
if best_score >= high_threshold:
|
| 213 |
+
tier = "high"
|
| 214 |
+
elif best_score >= medium_threshold:
|
| 215 |
+
tier = "medium"
|
| 216 |
+
else:
|
| 217 |
+
tier = "low"
|
| 218 |
+
|
| 219 |
+
return {
|
| 220 |
+
"decision": "matched" if tier in {"high", "medium"} else "suggestion",
|
| 221 |
+
"score_tier": tier,
|
| 222 |
+
"score": round(best_score, 2),
|
| 223 |
+
"record_id": best_record_id,
|
| 224 |
+
}
|
| 225 |
+
|
| 226 |
+
|
| 227 |
+
def _contract_stub_result(
|
| 228 |
+
invoice: ContractInvoiceHeader,
|
| 229 |
+
inv_lines: list[InvoiceLine],
|
| 230 |
+
exception_type: str,
|
| 231 |
+
detail: dict[str, Any],
|
| 232 |
+
) -> ContractMatchResult:
|
| 233 |
+
res = ContractMatchResult(
|
| 234 |
+
overall_status="exception",
|
| 235 |
+
match_zone="red",
|
| 236 |
+
documents_status="pending_approval",
|
| 237 |
+
lines_total=len(inv_lines),
|
| 238 |
+
lines_matched=0,
|
| 239 |
+
lines_in_exception=len(inv_lines),
|
| 240 |
+
)
|
| 241 |
+
res.header_exceptions.append(
|
| 242 |
+
MatchExceptionRecord(
|
| 243 |
+
exception_type=exception_type,
|
| 244 |
+
exception_detail=detail,
|
| 245 |
+
)
|
| 246 |
+
)
|
| 247 |
+
return res
|
| 248 |
+
|
| 249 |
+
|
| 250 |
+
# ============================================================
|
| 251 |
+
# Contract candidate selection
|
| 252 |
+
# ============================================================
|
| 253 |
+
def select_contract_candidate(
|
| 254 |
+
invoice: ContractInvoiceHeader,
|
| 255 |
+
inv_lines: list[InvoiceLine],
|
| 256 |
+
contracts: list[ContractRecord],
|
| 257 |
+
cfg: ContractConfig,
|
| 258 |
+
) -> tuple[Optional[ContractRecord], Optional[MatchExceptionRecord]]:
|
| 259 |
+
"""
|
| 260 |
+
Returns:
|
| 261 |
+
- selected contract
|
| 262 |
+
- or a MatchExceptionRecord for stub paths
|
| 263 |
+
"""
|
| 264 |
+
inv_currency = norm_currency(invoice.currency)
|
| 265 |
+
|
| 266 |
+
initial_candidates: list[dict[str, Any]] = []
|
| 267 |
+
for c in contracts:
|
| 268 |
+
if c.deleted_at is not None:
|
| 269 |
+
continue
|
| 270 |
+
if c.status != "active":
|
| 271 |
+
continue
|
| 272 |
+
if c.tenant_id != invoice.tenant_id:
|
| 273 |
+
continue
|
| 274 |
+
if c.vendor_id != invoice.resolved_vendor_id:
|
| 275 |
+
continue
|
| 276 |
+
if c.entity_id != invoice.entity_id:
|
| 277 |
+
continue
|
| 278 |
+
if norm_currency(c.currency) != inv_currency:
|
| 279 |
+
continue
|
| 280 |
+
if invoice.invoice_date is None:
|
| 281 |
+
continue
|
| 282 |
+
if c.effective_from is None:
|
| 283 |
+
continue
|
| 284 |
+
effective_to = c.effective_to or date(9999, 12, 31)
|
| 285 |
+
if not (c.effective_from <= invoice.invoice_date < effective_to):
|
| 286 |
+
continue
|
| 287 |
+
|
| 288 |
+
initial_candidates.append(
|
| 289 |
+
{
|
| 290 |
+
"contract": c,
|
| 291 |
+
"elimination_reason": None,
|
| 292 |
+
}
|
| 293 |
+
)
|
| 294 |
+
|
| 295 |
+
if not initial_candidates:
|
| 296 |
+
return None, MatchExceptionRecord(
|
| 297 |
+
exception_type="contract_not_found",
|
| 298 |
+
exception_detail={
|
| 299 |
+
"reason": "initial_query_returned_zero_rows",
|
| 300 |
+
"tenant_id": invoice.tenant_id,
|
| 301 |
+
"vendor_id": invoice.resolved_vendor_id,
|
| 302 |
+
"entity_id": invoice.entity_id,
|
| 303 |
+
"currency": inv_currency,
|
| 304 |
+
"invoice_date": str(invoice.invoice_date) if invoice.invoice_date else None,
|
| 305 |
+
},
|
| 306 |
+
)
|
| 307 |
+
|
| 308 |
+
remaining = [x["contract"] for x in initial_candidates]
|
| 309 |
+
|
| 310 |
+
# Tiebreaker 1: service-period containment (only when both service dates are present)
|
| 311 |
+
if invoice.service_start_date is not None and invoice.service_end_date is not None:
|
| 312 |
+
contained = [c for c in remaining if _is_service_window_contained(c, invoice.service_start_date, invoice.service_end_date)]
|
| 313 |
+
if contained:
|
| 314 |
+
remaining = contained
|
| 315 |
+
else:
|
| 316 |
+
# Candidates existed, but all were filtered out by this rule
|
| 317 |
+
return None, MatchExceptionRecord(
|
| 318 |
+
exception_type="cannot_match_no_contract",
|
| 319 |
+
exception_detail={
|
| 320 |
+
"reason": "all_candidates_filtered_out_by_service_period_containment",
|
| 321 |
+
"invoice_service_start_date": str(invoice.service_start_date),
|
| 322 |
+
"invoice_service_end_date": str(invoice.service_end_date),
|
| 323 |
+
"considered_contract_ids": [c.id for c in remaining],
|
| 324 |
+
},
|
| 325 |
+
)
|
| 326 |
+
|
| 327 |
+
# Tiebreaker 2: rate-card best fit via RapidFuzz
|
| 328 |
+
# Query uses joined canonical invoice descriptions.
|
| 329 |
+
joined_desc = _joined_invoice_descriptions(inv_lines)
|
| 330 |
+
if joined_desc:
|
| 331 |
+
scored_candidates: list[dict[str, Any]] = []
|
| 332 |
+
for c in remaining:
|
| 333 |
+
if not c.rate_card_items:
|
| 334 |
+
continue
|
| 335 |
+
item_names = [
|
| 336 |
+
(item.description_canonical or canonicalise_identifier(item.description) or "")
|
| 337 |
+
for item in c.rate_card_items
|
| 338 |
+
]
|
| 339 |
+
item_names = [n for n in item_names if n]
|
| 340 |
+
if not item_names:
|
| 341 |
+
continue
|
| 342 |
+
scored_candidates.append({"record_id": c.id, "names": item_names})
|
| 343 |
+
|
| 344 |
+
if scored_candidates:
|
| 345 |
+
# Contracts without rate card items cannot participate; exclude them now
|
| 346 |
+
# so they don't survive to tiebreaker 3 ahead of contracts that do.
|
| 347 |
+
scored_ids = {x["record_id"] for x in scored_candidates}
|
| 348 |
+
remaining = [c for c in remaining if c.id in scored_ids]
|
| 349 |
+
|
| 350 |
+
# Evaluate each contract individually: keep only those whose rate card
|
| 351 |
+
# descriptions yield decision='matched' against the joined invoice text.
|
| 352 |
+
matched_ids = {
|
| 353 |
+
x["record_id"]
|
| 354 |
+
for x in scored_candidates
|
| 355 |
+
if rapidfuzz_best_match(
|
| 356 |
+
joined_desc,
|
| 357 |
+
[x],
|
| 358 |
+
high_threshold=cfg.high_threshold,
|
| 359 |
+
medium_threshold=cfg.medium_threshold,
|
| 360 |
+
score_cutoff=cfg.score_cutoff,
|
| 361 |
+
)["decision"] == "matched"
|
| 362 |
+
}
|
| 363 |
+
if matched_ids:
|
| 364 |
+
remaining = [c for c in remaining if c.id in matched_ids]
|
| 365 |
+
else:
|
| 366 |
+
return None, MatchExceptionRecord(
|
| 367 |
+
exception_type="cannot_match_no_contract",
|
| 368 |
+
exception_detail={
|
| 369 |
+
"reason": "rate_card_best_fit_filtered_all_candidates",
|
| 370 |
+
"joined_invoice_descriptions": joined_desc,
|
| 371 |
+
"considered_contract_ids": [c.id for c in remaining],
|
| 372 |
+
},
|
| 373 |
+
)
|
| 374 |
+
|
| 375 |
+
# Tiebreaker 3: newest created_at DESC, id DESC
|
| 376 |
+
remaining.sort(
|
| 377 |
+
key=lambda c: (
|
| 378 |
+
c.created_at or datetime.min,
|
| 379 |
+
c.id,
|
| 380 |
+
),
|
| 381 |
+
reverse=True,
|
| 382 |
+
)
|
| 383 |
+
|
| 384 |
+
if len(remaining) == 1:
|
| 385 |
+
return remaining[0], None
|
| 386 |
+
|
| 387 |
+
if len(remaining) == 0:
|
| 388 |
+
return None, MatchExceptionRecord(
|
| 389 |
+
exception_type="cannot_match_no_contract",
|
| 390 |
+
exception_detail={
|
| 391 |
+
"reason": "all_candidates_removed_by_tiebreakers",
|
| 392 |
+
},
|
| 393 |
+
)
|
| 394 |
+
|
| 395 |
+
return None, MatchExceptionRecord(
|
| 396 |
+
exception_type="ambiguous_active_contract",
|
| 397 |
+
exception_detail={
|
| 398 |
+
"reason": "multiple_contracts_remain_after_tiebreakers",
|
| 399 |
+
"surviving_contract_ids": [c.id for c in remaining],
|
| 400 |
+
},
|
| 401 |
+
)
|
| 402 |
+
|
| 403 |
+
|
| 404 |
+
# ============================================================
|
| 405 |
+
# Config builder
|
| 406 |
+
# ============================================================
|
| 407 |
+
def _cfg_from_contract_dict(d: dict) -> ContractConfig:
|
| 408 |
+
def _d(key: str, default: str) -> Decimal:
|
| 409 |
+
return Decimal(str(d.get(key, default)))
|
| 410 |
+
def _f(key: str, default: float) -> float:
|
| 411 |
+
return float(d.get(key, default))
|
| 412 |
+
def _i(key: str, default: int) -> int:
|
| 413 |
+
return int(d.get(key, default))
|
| 414 |
+
return ContractConfig(
|
| 415 |
+
high_threshold=_f("contract_fuzzy_high_threshold", 90.0),
|
| 416 |
+
medium_threshold=_f("contract_fuzzy_medium_threshold", 80.0),
|
| 417 |
+
score_cutoff=_f("contract_fuzzy_score_cutoff", 80.0),
|
| 418 |
+
price_goods_green=_d("contract_price_goods_green", "5"),
|
| 419 |
+
price_goods_amber=_d("contract_price_goods_amber", "10"),
|
| 420 |
+
price_services_green=_d("contract_price_services_green", "10"),
|
| 421 |
+
price_services_amber=_d("contract_price_services_amber", "15"),
|
| 422 |
+
precision=_i("contract_precision", 28),
|
| 423 |
+
)
|
| 424 |
+
|
| 425 |
+
|
| 426 |
+
# ============================================================
|
| 427 |
+
# Main contract match core
|
| 428 |
+
# ============================================================
|
| 429 |
+
def run_contract_match_core(
|
| 430 |
+
invoice: ContractInvoiceHeader,
|
| 431 |
+
inv_lines: list[InvoiceLine],
|
| 432 |
+
contracts: list[ContractRecord],
|
| 433 |
+
*,
|
| 434 |
+
matching_configs: Optional[dict] = None,
|
| 435 |
+
) -> ContractMatchResult:
|
| 436 |
+
"""
|
| 437 |
+
Contract match used when po_number_canonical is NULL.
|
| 438 |
+
|
| 439 |
+
Returns:
|
| 440 |
+
- a stub exception result if no unique contract can be selected
|
| 441 |
+
- otherwise runs billing-period, rate-card, and budget checks
|
| 442 |
+
|
| 443 |
+
Note:
|
| 444 |
+
- cumulative_billed_amount is NOT incremented here
|
| 445 |
+
- this is matching-only, not approval
|
| 446 |
+
"""
|
| 447 |
+
cfg = _cfg_from_contract_dict(matching_configs or {})
|
| 448 |
+
getcontext().prec = cfg.precision
|
| 449 |
+
|
| 450 |
+
result = ContractMatchResult(
|
| 451 |
+
lines_total=len(inv_lines),
|
| 452 |
+
documents_status="pending_approval",
|
| 453 |
+
match_type="contract",
|
| 454 |
+
)
|
| 455 |
+
result.advisory_flags = list(invoice.advisory_flags)
|
| 456 |
+
|
| 457 |
+
if invoice.resolved_vendor_id is None:
|
| 458 |
+
return _contract_stub_result(
|
| 459 |
+
invoice,
|
| 460 |
+
inv_lines,
|
| 461 |
+
"cannot_match_no_vendor",
|
| 462 |
+
{
|
| 463 |
+
"reason": "resolved_vendor_id_is_required",
|
| 464 |
+
"tenant_id": invoice.tenant_id,
|
| 465 |
+
"entity_id": invoice.entity_id,
|
| 466 |
+
},
|
| 467 |
+
)
|
| 468 |
+
|
| 469 |
+
selected_contract, stub_exc = select_contract_candidate(invoice, inv_lines, contracts, cfg)
|
| 470 |
+
if stub_exc is not None or selected_contract is None:
|
| 471 |
+
# contract_not_found / cannot_match_no_contract / ambiguous_active_contract
|
| 472 |
+
result.header_exceptions.append(stub_exc or MatchExceptionRecord(
|
| 473 |
+
exception_type="cannot_match_no_contract",
|
| 474 |
+
exception_detail={"reason": "no_selected_contract"},
|
| 475 |
+
))
|
| 476 |
+
result.overall_status = "exception"
|
| 477 |
+
result.match_zone = "red"
|
| 478 |
+
result.lines_in_exception = len(inv_lines)
|
| 479 |
+
return result
|
| 480 |
+
|
| 481 |
+
result.selected_contract_id = selected_contract.id
|
| 482 |
+
|
| 483 |
+
# ---------------------------------------------------------
|
| 484 |
+
# Contract checks
|
| 485 |
+
# ---------------------------------------------------------
|
| 486 |
+
# 1) Billing period missing
|
| 487 |
+
if invoice.service_start_date is None or invoice.service_end_date is None:
|
| 488 |
+
result.match_exceptions.append(
|
| 489 |
+
MatchExceptionRecord(
|
| 490 |
+
exception_type="billing_period_unverified",
|
| 491 |
+
exception_detail={
|
| 492 |
+
"reason": "service_start_date_or_service_end_date_missing",
|
| 493 |
+
"service_start_date": str(invoice.service_start_date) if invoice.service_start_date else None,
|
| 494 |
+
"service_end_date": str(invoice.service_end_date) if invoice.service_end_date else None,
|
| 495 |
+
},
|
| 496 |
+
)
|
| 497 |
+
)
|
| 498 |
+
|
| 499 |
+
# 2) Billing period outside contract
|
| 500 |
+
if invoice.service_start_date is not None and invoice.service_end_date is not None:
|
| 501 |
+
if not _is_service_window_contained(selected_contract, invoice.service_start_date, invoice.service_end_date):
|
| 502 |
+
result.match_exceptions.append(
|
| 503 |
+
MatchExceptionRecord(
|
| 504 |
+
exception_type="billing_period_outside_contract",
|
| 505 |
+
exception_detail={
|
| 506 |
+
"reason": "invoice_service_window_not_contained_in_contract",
|
| 507 |
+
"service_start_date": str(invoice.service_start_date),
|
| 508 |
+
"service_end_date": str(invoice.service_end_date),
|
| 509 |
+
"contract_effective_from": str(selected_contract.effective_from) if selected_contract.effective_from else None,
|
| 510 |
+
"contract_effective_to": str(selected_contract.effective_to) if selected_contract.effective_to else None,
|
| 511 |
+
},
|
| 512 |
+
)
|
| 513 |
+
)
|
| 514 |
+
|
| 515 |
+
# 3) Rate card exists
|
| 516 |
+
if not selected_contract.rate_card_items:
|
| 517 |
+
result.match_exceptions.append(
|
| 518 |
+
MatchExceptionRecord(
|
| 519 |
+
exception_type="no_rate_card_configured",
|
| 520 |
+
exception_detail={
|
| 521 |
+
"reason": "contract_has_no_rate_card_items",
|
| 522 |
+
"contract_id": selected_contract.id,
|
| 523 |
+
},
|
| 524 |
+
)
|
| 525 |
+
)
|
| 526 |
+
result.overall_status = "exception"
|
| 527 |
+
result.match_zone = "red"
|
| 528 |
+
result.lines_in_exception = len(inv_lines)
|
| 529 |
+
return result
|
| 530 |
+
|
| 531 |
+
# 4) Per-line rate check
|
| 532 |
+
rate_card_candidates = [
|
| 533 |
+
{
|
| 534 |
+
"record_id": item.id,
|
| 535 |
+
"names": [item.description_canonical or canonicalise_identifier(item.description) or ""],
|
| 536 |
+
}
|
| 537 |
+
for item in selected_contract.rate_card_items
|
| 538 |
+
if (item.description_canonical or item.description)
|
| 539 |
+
]
|
| 540 |
+
|
| 541 |
+
line_matched_count = 0
|
| 542 |
+
line_exception_count = 0
|
| 543 |
+
|
| 544 |
+
for inv_line in inv_lines:
|
| 545 |
+
query = inv_line.description_canonical or canonicalise_identifier(inv_line.description) or ""
|
| 546 |
+
if not query:
|
| 547 |
+
result.match_exceptions.append(
|
| 548 |
+
MatchExceptionRecord(
|
| 549 |
+
exception_type="no_rate_card_match",
|
| 550 |
+
line_id=inv_line.id,
|
| 551 |
+
exception_detail={
|
| 552 |
+
"reason": "invoice_line_description_missing",
|
| 553 |
+
"invoice_line_id": inv_line.id,
|
| 554 |
+
},
|
| 555 |
+
)
|
| 556 |
+
)
|
| 557 |
+
line_exception_count += 1
|
| 558 |
+
continue
|
| 559 |
+
|
| 560 |
+
rf = rapidfuzz_best_match(
|
| 561 |
+
query=query,
|
| 562 |
+
candidates=rate_card_candidates,
|
| 563 |
+
high_threshold=cfg.high_threshold,
|
| 564 |
+
medium_threshold=cfg.medium_threshold,
|
| 565 |
+
score_cutoff=cfg.score_cutoff,
|
| 566 |
+
)
|
| 567 |
+
|
| 568 |
+
if rf["decision"] not in {"matched", "suggestion"} or rf["record_id"] is None:
|
| 569 |
+
result.match_exceptions.append(
|
| 570 |
+
MatchExceptionRecord(
|
| 571 |
+
exception_type="no_rate_card_match",
|
| 572 |
+
line_id=inv_line.id,
|
| 573 |
+
exception_detail={
|
| 574 |
+
"reason": "rate_card_name_match_failed",
|
| 575 |
+
"invoice_line_id": inv_line.id,
|
| 576 |
+
"query": query,
|
| 577 |
+
},
|
| 578 |
+
)
|
| 579 |
+
)
|
| 580 |
+
line_exception_count += 1
|
| 581 |
+
continue
|
| 582 |
+
|
| 583 |
+
rate_item = next((x for x in selected_contract.rate_card_items if x.id == rf["record_id"]), None)
|
| 584 |
+
if rate_item is None:
|
| 585 |
+
result.match_exceptions.append(
|
| 586 |
+
MatchExceptionRecord(
|
| 587 |
+
exception_type="no_rate_card_match",
|
| 588 |
+
line_id=inv_line.id,
|
| 589 |
+
exception_detail={
|
| 590 |
+
"reason": "matched_rate_card_item_not_found",
|
| 591 |
+
"invoice_line_id": inv_line.id,
|
| 592 |
+
"matched_rate_card_item_id": rf["record_id"],
|
| 593 |
+
},
|
| 594 |
+
)
|
| 595 |
+
)
|
| 596 |
+
line_exception_count += 1
|
| 597 |
+
continue
|
| 598 |
+
|
| 599 |
+
# Compare unit price if present
|
| 600 |
+
if inv_line.unit_price is None:
|
| 601 |
+
if "rate_card_match_no_price_compare" not in result.advisory_flags:
|
| 602 |
+
result.advisory_flags.append("rate_card_match_no_price_compare")
|
| 603 |
+
result.match_line_results.append(
|
| 604 |
+
ContractLineResult(
|
| 605 |
+
invoice_line_id=inv_line.id,
|
| 606 |
+
rate_card_item_id=rate_item.id,
|
| 607 |
+
line_status="matched",
|
| 608 |
+
line_zone="green",
|
| 609 |
+
price_var_pct=None,
|
| 610 |
+
allocated_inv_amount=inv_line.amount,
|
| 611 |
+
allocated_rate_amount=rate_item.unit_price,
|
| 612 |
+
)
|
| 613 |
+
)
|
| 614 |
+
line_matched_count += 1
|
| 615 |
+
continue
|
| 616 |
+
|
| 617 |
+
if rate_item.unit_price is None:
|
| 618 |
+
# Name match succeeded; price comparison is impossible. Treat as an
|
| 619 |
+
# advisory (not a hard exception) because the match itself is valid.
|
| 620 |
+
if "rate_card_match_no_price_compare" not in result.advisory_flags:
|
| 621 |
+
result.advisory_flags.append("rate_card_match_no_price_compare")
|
| 622 |
+
result.match_line_results.append(
|
| 623 |
+
ContractLineResult(
|
| 624 |
+
invoice_line_id=inv_line.id,
|
| 625 |
+
rate_card_item_id=rate_item.id,
|
| 626 |
+
line_status="matched",
|
| 627 |
+
line_zone="green",
|
| 628 |
+
price_var_pct=None,
|
| 629 |
+
allocated_inv_amount=inv_line.amount,
|
| 630 |
+
allocated_rate_amount=None,
|
| 631 |
+
)
|
| 632 |
+
)
|
| 633 |
+
line_matched_count += 1
|
| 634 |
+
continue
|
| 635 |
+
|
| 636 |
+
p_var = price_variance_pct(inv_line.unit_price, rate_item.unit_price)
|
| 637 |
+
zone = _line_zone_from_price(p_var, rate_item.item_type, cfg)
|
| 638 |
+
|
| 639 |
+
if zone == "red":
|
| 640 |
+
result.match_exceptions.append(
|
| 641 |
+
MatchExceptionRecord(
|
| 642 |
+
exception_type="rate_card_price_mismatch",
|
| 643 |
+
line_id=inv_line.id,
|
| 644 |
+
po_line_id=rate_item.id,
|
| 645 |
+
exception_detail={
|
| 646 |
+
"reason": "price_outside_tolerance",
|
| 647 |
+
"invoice_line_id": inv_line.id,
|
| 648 |
+
"rate_card_item_id": rate_item.id,
|
| 649 |
+
"invoice_unit_price": str(inv_line.unit_price),
|
| 650 |
+
"rate_card_unit_price": str(rate_item.unit_price),
|
| 651 |
+
"price_var_pct": str(p_var),
|
| 652 |
+
},
|
| 653 |
+
)
|
| 654 |
+
)
|
| 655 |
+
result.match_line_results.append(
|
| 656 |
+
ContractLineResult(
|
| 657 |
+
invoice_line_id=inv_line.id,
|
| 658 |
+
rate_card_item_id=rate_item.id,
|
| 659 |
+
line_status="exception",
|
| 660 |
+
line_zone="red",
|
| 661 |
+
price_var_pct=p_var,
|
| 662 |
+
allocated_inv_amount=inv_line.amount,
|
| 663 |
+
allocated_rate_amount=rate_item.unit_price,
|
| 664 |
+
exception_type="rate_card_price_mismatch",
|
| 665 |
+
)
|
| 666 |
+
)
|
| 667 |
+
line_exception_count += 1
|
| 668 |
+
continue
|
| 669 |
+
|
| 670 |
+
result.match_line_results.append(
|
| 671 |
+
ContractLineResult(
|
| 672 |
+
invoice_line_id=inv_line.id,
|
| 673 |
+
rate_card_item_id=rate_item.id,
|
| 674 |
+
line_status="matched",
|
| 675 |
+
line_zone=zone,
|
| 676 |
+
price_var_pct=p_var,
|
| 677 |
+
allocated_inv_amount=inv_line.amount,
|
| 678 |
+
allocated_rate_amount=rate_item.unit_price,
|
| 679 |
+
)
|
| 680 |
+
)
|
| 681 |
+
line_matched_count += 1
|
| 682 |
+
|
| 683 |
+
result.lines_matched = line_matched_count
|
| 684 |
+
result.lines_in_exception = line_exception_count + (len(inv_lines) - line_matched_count - line_exception_count)
|
| 685 |
+
|
| 686 |
+
# ---------------------------------------------------------
|
| 687 |
+
# Budget ceiling
|
| 688 |
+
# ---------------------------------------------------------
|
| 689 |
+
inv_currency = norm_currency(invoice.currency)
|
| 690 |
+
contract_currency = norm_currency(selected_contract.currency)
|
| 691 |
+
|
| 692 |
+
if contract_currency != inv_currency:
|
| 693 |
+
result.match_exceptions.append(
|
| 694 |
+
MatchExceptionRecord(
|
| 695 |
+
exception_type="contract_currency_mismatch",
|
| 696 |
+
exception_detail={
|
| 697 |
+
"reason": "contract_currency_changed_or_mismatch",
|
| 698 |
+
"invoice_currency": inv_currency,
|
| 699 |
+
"contract_currency": contract_currency,
|
| 700 |
+
"contract_id": selected_contract.id,
|
| 701 |
+
},
|
| 702 |
+
)
|
| 703 |
+
)
|
| 704 |
+
result.overall_status = "exception"
|
| 705 |
+
result.match_zone = "red"
|
| 706 |
+
return result
|
| 707 |
+
|
| 708 |
+
if invoice.total_amount is not None:
|
| 709 |
+
cumulative = selected_contract.cumulative_billed_amount or D0
|
| 710 |
+
total_value = selected_contract.total_value
|
| 711 |
+
if total_value is not None and (cumulative + invoice.total_amount) > total_value:
|
| 712 |
+
result.match_exceptions.append(
|
| 713 |
+
MatchExceptionRecord(
|
| 714 |
+
exception_type="contract_budget_exceeded",
|
| 715 |
+
exception_detail={
|
| 716 |
+
"reason": "budget_ceiling_breached",
|
| 717 |
+
"contract_id": selected_contract.id,
|
| 718 |
+
"invoice_total_amount": str(invoice.total_amount),
|
| 719 |
+
"cumulative_billed_amount": str(cumulative),
|
| 720 |
+
"contract_total_value": str(total_value),
|
| 721 |
+
},
|
| 722 |
+
)
|
| 723 |
+
)
|
| 724 |
+
result.overall_status = "exception"
|
| 725 |
+
result.match_zone = "red"
|
| 726 |
+
return result
|
| 727 |
+
|
| 728 |
+
# ---------------------------------------------------------
|
| 729 |
+
# Overall status / zone
|
| 730 |
+
# ---------------------------------------------------------
|
| 731 |
+
if any(exc.exception_type in {
|
| 732 |
+
"billing_period_unverified",
|
| 733 |
+
"billing_period_outside_contract",
|
| 734 |
+
"no_rate_card_configured",
|
| 735 |
+
"no_rate_card_match",
|
| 736 |
+
"rate_card_price_mismatch",
|
| 737 |
+
"contract_budget_exceeded",
|
| 738 |
+
"contract_currency_mismatch",
|
| 739 |
+
} for exc in result.match_exceptions):
|
| 740 |
+
# If any hard contract exception exists, keep it red.
|
| 741 |
+
if result.lines_matched > 0 and result.lines_in_exception > 0:
|
| 742 |
+
result.overall_status = "partial_match"
|
| 743 |
+
else:
|
| 744 |
+
result.overall_status = "exception"
|
| 745 |
+
result.match_zone = "red"
|
| 746 |
+
else:
|
| 747 |
+
if result.lines_matched == result.lines_total and result.lines_in_exception == 0:
|
| 748 |
+
result.overall_status = "matched"
|
| 749 |
+
result.match_zone = "amber" if any(r.line_zone == "amber" for r in result.match_line_results) else "green"
|
| 750 |
+
elif result.lines_matched > 0:
|
| 751 |
+
result.overall_status = "partial_match"
|
| 752 |
+
result.match_zone = "amber" if any(r.line_zone == "amber" for r in result.match_line_results) else "green"
|
| 753 |
+
else:
|
| 754 |
+
result.overall_status = "exception"
|
| 755 |
+
result.match_zone = "red"
|
| 756 |
+
|
| 757 |
+
return result
|
src/Matching Functions/invoice_pipeline.py
ADDED
|
@@ -0,0 +1,342 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Top-level invoice processing pipeline.
|
| 3 |
+
|
| 4 |
+
Flow: validation → routing → matching → final result.
|
| 5 |
+
|
| 6 |
+
No business logic lives here. Each step delegates entirely to its own module:
|
| 7 |
+
- Validation : Invoice Validation Functions/orchestrator.py
|
| 8 |
+
- Routing : match_router.py
|
| 9 |
+
- 2-way match : two_way_match.py
|
| 10 |
+
- 3-way match : three_way_match.py (layers on top of 2-way result)
|
| 11 |
+
- Contract : contract_match.py
|
| 12 |
+
|
| 13 |
+
Caller responsibilities (not enforced here):
|
| 14 |
+
- Acquire the advisory lock before calling (SDD §3.1).
|
| 15 |
+
- Pre-fetch po, po_lines, gr_rows from the database.
|
| 16 |
+
- Construct matching_invoice (InvoiceHeader) and matching_inv_lines.
|
| 17 |
+
- Construct contract_invoice (ContractInvoiceHeader) when contract match is possible.
|
| 18 |
+
- Handle prior-match supersession in the database after this call returns (SDD §3.5).
|
| 19 |
+
"""
|
| 20 |
+
|
| 21 |
+
from __future__ import annotations
|
| 22 |
+
|
| 23 |
+
import importlib.util
|
| 24 |
+
import json
|
| 25 |
+
import os
|
| 26 |
+
from typing import Any, Optional
|
| 27 |
+
|
| 28 |
+
# ============================================================
|
| 29 |
+
# Load validation orchestrator by path
|
| 30 |
+
# (the directory name contains spaces, so importlib is required)
|
| 31 |
+
# ============================================================
|
| 32 |
+
|
| 33 |
+
_HERE = os.path.dirname(os.path.abspath(__file__))
|
| 34 |
+
_VALIDATION_DIR = os.path.normpath(os.path.join(_HERE, "..", "Invoice Validation Functions"))
|
| 35 |
+
|
| 36 |
+
|
| 37 |
+
def _load_module_by_path(filepath: str, module_name: str):
|
| 38 |
+
spec = importlib.util.spec_from_file_location(module_name, filepath)
|
| 39 |
+
mod = importlib.util.module_from_spec(spec)
|
| 40 |
+
spec.loader.exec_module(mod)
|
| 41 |
+
return mod
|
| 42 |
+
|
| 43 |
+
|
| 44 |
+
_val_orch = _load_module_by_path(
|
| 45 |
+
os.path.join(_VALIDATION_DIR, "orchestrator.py"),
|
| 46 |
+
"_validation_orchestrator",
|
| 47 |
+
)
|
| 48 |
+
_run_validation_pipeline = _val_orch.run_invoice_validation_pipeline
|
| 49 |
+
|
| 50 |
+
|
| 51 |
+
# ============================================================
|
| 52 |
+
# Matching layer imports
|
| 53 |
+
# ============================================================
|
| 54 |
+
|
| 55 |
+
from match_router import (
|
| 56 |
+
MatchPreconditionError,
|
| 57 |
+
RouteDecision,
|
| 58 |
+
RoutingDocument,
|
| 59 |
+
RoutingExtractedFields,
|
| 60 |
+
route_invoice,
|
| 61 |
+
)
|
| 62 |
+
from two_way_match import (
|
| 63 |
+
InvoiceHeader,
|
| 64 |
+
InvoiceLine,
|
| 65 |
+
MatchExceptionRecord,
|
| 66 |
+
POHeader,
|
| 67 |
+
POLine,
|
| 68 |
+
run_two_way_match_core,
|
| 69 |
+
)
|
| 70 |
+
from three_way_match import GRLineItem, run_three_way_match
|
| 71 |
+
from contract_match import (
|
| 72 |
+
ContractInvoiceHeader,
|
| 73 |
+
ContractMatchResult,
|
| 74 |
+
run_contract_match_core,
|
| 75 |
+
)
|
| 76 |
+
|
| 77 |
+
|
| 78 |
+
# ============================================================
|
| 79 |
+
# Config loader
|
| 80 |
+
# ============================================================
|
| 81 |
+
|
| 82 |
+
def _load_matching_configs(path: Optional[str] = None) -> dict:
|
| 83 |
+
if path is None:
|
| 84 |
+
path = os.path.join(_HERE, "matching_configs.json")
|
| 85 |
+
with open(path, encoding="utf-8") as fh:
|
| 86 |
+
return json.load(fh)
|
| 87 |
+
|
| 88 |
+
|
| 89 |
+
# ============================================================
|
| 90 |
+
# Internal helpers
|
| 91 |
+
# ============================================================
|
| 92 |
+
|
| 93 |
+
def _stub_missing_contract_invoice(inv_lines: list[InvoiceLine]) -> ContractMatchResult:
|
| 94 |
+
"""Returned when contract route is selected but contract_invoice was not provided."""
|
| 95 |
+
res = ContractMatchResult(
|
| 96 |
+
match_type="contract",
|
| 97 |
+
overall_status="exception",
|
| 98 |
+
match_zone="red",
|
| 99 |
+
documents_status="pending_approval",
|
| 100 |
+
lines_total=len(inv_lines),
|
| 101 |
+
lines_matched=0,
|
| 102 |
+
lines_in_exception=len(inv_lines),
|
| 103 |
+
)
|
| 104 |
+
res.header_exceptions.append(
|
| 105 |
+
MatchExceptionRecord(
|
| 106 |
+
exception_type="cannot_match_missing_contract_invoice",
|
| 107 |
+
exception_detail={"reason": "contract_invoice_not_provided_to_pipeline"},
|
| 108 |
+
)
|
| 109 |
+
)
|
| 110 |
+
return res
|
| 111 |
+
|
| 112 |
+
|
| 113 |
+
def _collect_match_flags(match_result: Any) -> tuple[list, list, list]:
|
| 114 |
+
"""Extract risk_flags, advisory_flags, skipped_steps from a match result."""
|
| 115 |
+
if match_result is None:
|
| 116 |
+
return [], [], []
|
| 117 |
+
return (
|
| 118 |
+
list(getattr(match_result, "risk_flags", None) or []),
|
| 119 |
+
list(getattr(match_result, "advisory_flags", None) or []),
|
| 120 |
+
list(getattr(match_result, "skipped_steps", None) or []),
|
| 121 |
+
)
|
| 122 |
+
|
| 123 |
+
|
| 124 |
+
def _compute_final_status(validation_risk_flags: list, match_result: Any) -> str:
|
| 125 |
+
"""
|
| 126 |
+
Derive a single final status from both validation and match outcomes.
|
| 127 |
+
Any validation risk flag is treated as an exception regardless of match result.
|
| 128 |
+
"""
|
| 129 |
+
if validation_risk_flags:
|
| 130 |
+
return "exception"
|
| 131 |
+
if match_result is None:
|
| 132 |
+
return "exception"
|
| 133 |
+
return getattr(match_result, "overall_status", "exception")
|
| 134 |
+
|
| 135 |
+
|
| 136 |
+
# ============================================================
|
| 137 |
+
# Top-level pipeline
|
| 138 |
+
# ============================================================
|
| 139 |
+
|
| 140 |
+
def run_invoice_pipeline(
|
| 141 |
+
*,
|
| 142 |
+
# --- Validation inputs ---
|
| 143 |
+
document,
|
| 144 |
+
extracted_fields,
|
| 145 |
+
vendors,
|
| 146 |
+
vendor_bank_accounts,
|
| 147 |
+
vendor_tax_ids,
|
| 148 |
+
entity,
|
| 149 |
+
line_items,
|
| 150 |
+
purchase_orders,
|
| 151 |
+
contracts,
|
| 152 |
+
tax_master_rows,
|
| 153 |
+
currency_seed_map,
|
| 154 |
+
platform_configs: Optional[dict] = None,
|
| 155 |
+
route_contexts=None,
|
| 156 |
+
actor_id=None,
|
| 157 |
+
# --- Matching inputs ---
|
| 158 |
+
matching_invoice: InvoiceHeader,
|
| 159 |
+
matching_inv_lines: list[InvoiceLine],
|
| 160 |
+
contract_invoice: Optional[ContractInvoiceHeader] = None,
|
| 161 |
+
po: Optional[POHeader] = None,
|
| 162 |
+
po_lines: Optional[list[POLine]] = None,
|
| 163 |
+
gr_rows: Optional[list[GRLineItem]] = None,
|
| 164 |
+
matching_configs: Optional[dict] = None,
|
| 165 |
+
) -> dict[str, Any]:
|
| 166 |
+
"""
|
| 167 |
+
Run the full invoice processing pipeline and return a single combined result.
|
| 168 |
+
|
| 169 |
+
Parameters
|
| 170 |
+
----------
|
| 171 |
+
document : Invoice document object (.id, .tenant_id, .entity_id,
|
| 172 |
+
.status, .doc_type required for routing).
|
| 173 |
+
extracted_fields : VLM-extracted fields (.po_number_canonical used for routing).
|
| 174 |
+
vendors : Vendor records for vendor validation.
|
| 175 |
+
vendor_bank_accounts : Vendor bank account records.
|
| 176 |
+
vendor_tax_ids : Vendor tax-ID records.
|
| 177 |
+
entity : Routing entity (.country_code, .vat_id, .region_code).
|
| 178 |
+
line_items : Invoice line-item records (for validation).
|
| 179 |
+
purchase_orders : Purchase-order records (for payment-terms validation).
|
| 180 |
+
contracts : Contract records (for payment-terms validation and contract match).
|
| 181 |
+
tax_master_rows : Tax-master records.
|
| 182 |
+
currency_seed_map : Dict mapping ISO currency code → minor-unit row.
|
| 183 |
+
platform_configs : Validation config dict; loaded from platform_configs.json if None.
|
| 184 |
+
route_contexts : Route-context strings forwarded to Vendor Validation.
|
| 185 |
+
actor_id : Actor identifier forwarded to Vendor Validation for audit.
|
| 186 |
+
matching_invoice : InvoiceHeader for 2-way / 3-way matching.
|
| 187 |
+
matching_inv_lines : Invoice lines for all match types.
|
| 188 |
+
contract_invoice : ContractInvoiceHeader for contract matching (optional).
|
| 189 |
+
po : Pre-fetched POHeader; None if no PO number on the invoice.
|
| 190 |
+
po_lines : Pre-fetched PO lines; None if no PO.
|
| 191 |
+
gr_rows : Pre-fetched GR rows; None or [] triggers 2-way instead of 3-way.
|
| 192 |
+
matching_configs : Matching config dict; loaded from matching_configs.json if None.
|
| 193 |
+
|
| 194 |
+
Returns
|
| 195 |
+
-------
|
| 196 |
+
dict with keys:
|
| 197 |
+
validation_result — raw dict from the validation pipeline
|
| 198 |
+
route — 'two_way' | 'three_way' | 'contract' | 'credit_note' |
|
| 199 |
+
'stub' | 'precondition_error'
|
| 200 |
+
match_result — TwoWayMatchResult | ContractMatchResult | None
|
| 201 |
+
risk_flags — combined validation + matching risk flags, in execution order
|
| 202 |
+
advisory_flags — combined, in execution order
|
| 203 |
+
skipped_steps — combined, in execution order
|
| 204 |
+
ops_alerts — operational alerts from the validation layer
|
| 205 |
+
resolved_vendor_id — from vendor validation
|
| 206 |
+
final_status — 'matched' | 'partial_match' | 'exception'
|
| 207 |
+
"""
|
| 208 |
+
|
| 209 |
+
# ------------------------------------------------------------------
|
| 210 |
+
# Step 1 — Validation
|
| 211 |
+
# ------------------------------------------------------------------
|
| 212 |
+
validation_result = _run_validation_pipeline(
|
| 213 |
+
document=document,
|
| 214 |
+
extracted_fields=extracted_fields,
|
| 215 |
+
vendors=vendors,
|
| 216 |
+
vendor_bank_accounts=vendor_bank_accounts,
|
| 217 |
+
vendor_tax_ids=vendor_tax_ids,
|
| 218 |
+
entity=entity,
|
| 219 |
+
line_items=line_items,
|
| 220 |
+
purchase_orders=purchase_orders,
|
| 221 |
+
contracts=contracts,
|
| 222 |
+
tax_master_rows=tax_master_rows,
|
| 223 |
+
currency_seed_map=currency_seed_map,
|
| 224 |
+
platform_configs=platform_configs,
|
| 225 |
+
route_contexts=route_contexts,
|
| 226 |
+
actor_id=actor_id,
|
| 227 |
+
)
|
| 228 |
+
|
| 229 |
+
resolved_vendor_id = validation_result.get("resolved_vendor_id")
|
| 230 |
+
risk_flags: list = list(validation_result.get("risk_flags") or [])
|
| 231 |
+
advisory_flags: list = list(validation_result.get("advisory_flags") or [])
|
| 232 |
+
skipped_steps: list = list(validation_result.get("skipped_steps") or [])
|
| 233 |
+
|
| 234 |
+
# ------------------------------------------------------------------
|
| 235 |
+
# Step 2 — Routing
|
| 236 |
+
# ------------------------------------------------------------------
|
| 237 |
+
if matching_configs is None:
|
| 238 |
+
matching_configs = _load_matching_configs()
|
| 239 |
+
|
| 240 |
+
routing_doc = RoutingDocument(
|
| 241 |
+
id=getattr(document, "id", None),
|
| 242 |
+
tenant_id=getattr(document, "tenant_id", None),
|
| 243 |
+
entity_id=getattr(document, "entity_id", None),
|
| 244 |
+
status=getattr(document, "status", "validated"),
|
| 245 |
+
doc_type=getattr(document, "doc_type", "invoice"),
|
| 246 |
+
)
|
| 247 |
+
routing_ef = RoutingExtractedFields(
|
| 248 |
+
po_number_canonical=getattr(extracted_fields, "po_number_canonical", None),
|
| 249 |
+
resolved_vendor_id=resolved_vendor_id,
|
| 250 |
+
)
|
| 251 |
+
|
| 252 |
+
try:
|
| 253 |
+
decision: RouteDecision = route_invoice(
|
| 254 |
+
document=routing_doc,
|
| 255 |
+
ef=routing_ef,
|
| 256 |
+
inv_lines=matching_inv_lines,
|
| 257 |
+
po=po,
|
| 258 |
+
po_lines=po_lines,
|
| 259 |
+
gr_rows=gr_rows,
|
| 260 |
+
)
|
| 261 |
+
except MatchPreconditionError as exc:
|
| 262 |
+
return {
|
| 263 |
+
"validation_result": validation_result,
|
| 264 |
+
"route": "precondition_error",
|
| 265 |
+
"match_result": None,
|
| 266 |
+
"risk_flags": risk_flags,
|
| 267 |
+
"advisory_flags": advisory_flags,
|
| 268 |
+
"skipped_steps": skipped_steps,
|
| 269 |
+
"ops_alerts": list(validation_result.get("ops_alerts") or []),
|
| 270 |
+
"resolved_vendor_id": resolved_vendor_id,
|
| 271 |
+
"final_status": "exception",
|
| 272 |
+
"error": str(exc),
|
| 273 |
+
}
|
| 274 |
+
|
| 275 |
+
route = decision.route
|
| 276 |
+
|
| 277 |
+
# ------------------------------------------------------------------
|
| 278 |
+
# Step 3 — Matching
|
| 279 |
+
# ------------------------------------------------------------------
|
| 280 |
+
match_result: Any = None
|
| 281 |
+
|
| 282 |
+
if route == "stub":
|
| 283 |
+
match_result = decision.stub_result
|
| 284 |
+
|
| 285 |
+
elif route == "two_way":
|
| 286 |
+
match_result = run_two_way_match_core(
|
| 287 |
+
matching_invoice,
|
| 288 |
+
matching_inv_lines,
|
| 289 |
+
decision.po,
|
| 290 |
+
decision.po_lines or [],
|
| 291 |
+
matching_configs=matching_configs,
|
| 292 |
+
)
|
| 293 |
+
|
| 294 |
+
elif route == "three_way":
|
| 295 |
+
two_way_result = run_two_way_match_core(
|
| 296 |
+
matching_invoice,
|
| 297 |
+
matching_inv_lines,
|
| 298 |
+
decision.po,
|
| 299 |
+
decision.po_lines or [],
|
| 300 |
+
matching_configs=matching_configs,
|
| 301 |
+
)
|
| 302 |
+
match_result = run_three_way_match(
|
| 303 |
+
two_way_result,
|
| 304 |
+
matching_invoice,
|
| 305 |
+
matching_inv_lines,
|
| 306 |
+
decision.po_lines or [],
|
| 307 |
+
decision.gr_rows or [],
|
| 308 |
+
matching_configs=matching_configs,
|
| 309 |
+
)
|
| 310 |
+
|
| 311 |
+
elif route == "contract":
|
| 312 |
+
if contract_invoice is None:
|
| 313 |
+
match_result = _stub_missing_contract_invoice(matching_inv_lines)
|
| 314 |
+
else:
|
| 315 |
+
match_result = run_contract_match_core(
|
| 316 |
+
contract_invoice,
|
| 317 |
+
matching_inv_lines,
|
| 318 |
+
list(contracts),
|
| 319 |
+
matching_configs=matching_configs,
|
| 320 |
+
)
|
| 321 |
+
|
| 322 |
+
# ------------------------------------------------------------------
|
| 323 |
+
# Step 4 — Combine flags and return
|
| 324 |
+
# ------------------------------------------------------------------
|
| 325 |
+
m_risk, m_advisory, m_skipped = _collect_match_flags(match_result)
|
| 326 |
+
risk_flags.extend(m_risk)
|
| 327 |
+
advisory_flags.extend(m_advisory)
|
| 328 |
+
skipped_steps.extend(m_skipped)
|
| 329 |
+
|
| 330 |
+
return {
|
| 331 |
+
"validation_result": validation_result,
|
| 332 |
+
"route": route,
|
| 333 |
+
"match_result": match_result,
|
| 334 |
+
"risk_flags": risk_flags,
|
| 335 |
+
"advisory_flags": advisory_flags,
|
| 336 |
+
"skipped_steps": skipped_steps,
|
| 337 |
+
"ops_alerts": list(validation_result.get("ops_alerts") or []),
|
| 338 |
+
"resolved_vendor_id": resolved_vendor_id,
|
| 339 |
+
"final_status": _compute_final_status(
|
| 340 |
+
validation_result.get("risk_flags") or [], match_result
|
| 341 |
+
),
|
| 342 |
+
}
|
src/Matching Functions/match_router.py
ADDED
|
@@ -0,0 +1,273 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
from dataclasses import dataclass, field
|
| 4 |
+
from typing import Any, Optional, Union
|
| 5 |
+
|
| 6 |
+
from two_way_match import (
|
| 7 |
+
InvoiceLine,
|
| 8 |
+
MatchExceptionRecord,
|
| 9 |
+
POHeader,
|
| 10 |
+
POLine,
|
| 11 |
+
TwoWayMatchResult,
|
| 12 |
+
)
|
| 13 |
+
from three_way_match import GRLineItem
|
| 14 |
+
from contract_match import ContractMatchResult
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
# ============================================================
|
| 18 |
+
# Precondition violation
|
| 19 |
+
# ============================================================
|
| 20 |
+
|
| 21 |
+
class MatchPreconditionError(Exception):
|
| 22 |
+
"""Raised when document.status is not 'validated' or 'matching' (SDD §3.1)."""
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
# ============================================================
|
| 26 |
+
# Routing input models
|
| 27 |
+
# ============================================================
|
| 28 |
+
|
| 29 |
+
@dataclass(frozen=True)
|
| 30 |
+
class RoutingDocument:
|
| 31 |
+
id: int
|
| 32 |
+
tenant_id: int
|
| 33 |
+
entity_id: int
|
| 34 |
+
status: str # 'validated' | 'matching' — enforced by route_invoice
|
| 35 |
+
doc_type: str # 'invoice' | 'credit_note' | ...
|
| 36 |
+
|
| 37 |
+
|
| 38 |
+
@dataclass(frozen=True)
|
| 39 |
+
class RoutingExtractedFields:
|
| 40 |
+
po_number_canonical: Optional[str]
|
| 41 |
+
resolved_vendor_id: Optional[int]
|
| 42 |
+
|
| 43 |
+
|
| 44 |
+
# ============================================================
|
| 45 |
+
# Routing decision
|
| 46 |
+
# ============================================================
|
| 47 |
+
|
| 48 |
+
MatchResult = Union[TwoWayMatchResult, ContractMatchResult]
|
| 49 |
+
|
| 50 |
+
|
| 51 |
+
@dataclass
|
| 52 |
+
class RouteDecision:
|
| 53 |
+
route: str # 'two_way' | 'three_way' | 'contract' | 'stub'
|
| 54 |
+
po: Optional[POHeader] = None # populated for two_way / three_way
|
| 55 |
+
po_lines: Optional[list[POLine]] = None
|
| 56 |
+
gr_rows: Optional[list[GRLineItem]] = None # populated for three_way
|
| 57 |
+
stub_result: Optional[MatchResult] = None # populated when route='stub'
|
| 58 |
+
|
| 59 |
+
|
| 60 |
+
# ============================================================
|
| 61 |
+
# Stub builders (SDD §3.4)
|
| 62 |
+
# ============================================================
|
| 63 |
+
|
| 64 |
+
def _two_way_stub(
|
| 65 |
+
inv_lines: list[InvoiceLine],
|
| 66 |
+
exception_type: str,
|
| 67 |
+
exception_detail: dict[str, Any],
|
| 68 |
+
) -> TwoWayMatchResult:
|
| 69 |
+
res = TwoWayMatchResult(
|
| 70 |
+
match_type="two_way",
|
| 71 |
+
overall_status="exception",
|
| 72 |
+
match_zone="red",
|
| 73 |
+
documents_status="pending_approval",
|
| 74 |
+
lines_total=len(inv_lines),
|
| 75 |
+
lines_matched=0,
|
| 76 |
+
lines_in_exception=len(inv_lines),
|
| 77 |
+
)
|
| 78 |
+
res.header_exceptions.append(
|
| 79 |
+
MatchExceptionRecord(
|
| 80 |
+
exception_type=exception_type,
|
| 81 |
+
exception_detail=exception_detail,
|
| 82 |
+
)
|
| 83 |
+
)
|
| 84 |
+
return res
|
| 85 |
+
|
| 86 |
+
|
| 87 |
+
def _contract_stub(
|
| 88 |
+
inv_lines: list[InvoiceLine],
|
| 89 |
+
exception_type: str,
|
| 90 |
+
exception_detail: dict[str, Any],
|
| 91 |
+
) -> ContractMatchResult:
|
| 92 |
+
res = ContractMatchResult(
|
| 93 |
+
match_type="contract",
|
| 94 |
+
overall_status="exception",
|
| 95 |
+
match_zone="red",
|
| 96 |
+
documents_status="pending_approval",
|
| 97 |
+
lines_total=len(inv_lines),
|
| 98 |
+
lines_matched=0,
|
| 99 |
+
lines_in_exception=len(inv_lines),
|
| 100 |
+
)
|
| 101 |
+
res.header_exceptions.append(
|
| 102 |
+
MatchExceptionRecord(
|
| 103 |
+
exception_type=exception_type,
|
| 104 |
+
exception_detail=exception_detail,
|
| 105 |
+
)
|
| 106 |
+
)
|
| 107 |
+
return res
|
| 108 |
+
|
| 109 |
+
|
| 110 |
+
# ============================================================
|
| 111 |
+
# Status sets
|
| 112 |
+
# ============================================================
|
| 113 |
+
|
| 114 |
+
_VALID_ENTRY_STATUSES = {"validated", "matching"}
|
| 115 |
+
_PO_ACTIVE_STATUSES = {"open", "partial"}
|
| 116 |
+
_PO_LINE_LIVE_STATUSES = {"open", "fully_invoiced"}
|
| 117 |
+
_PO_LINE_DEAD_STATUSES = {"closed", "cancelled"}
|
| 118 |
+
|
| 119 |
+
|
| 120 |
+
# ============================================================
|
| 121 |
+
# Decision tree (SDD §3.3)
|
| 122 |
+
# ============================================================
|
| 123 |
+
|
| 124 |
+
def route_invoice(
|
| 125 |
+
document: RoutingDocument,
|
| 126 |
+
ef: RoutingExtractedFields,
|
| 127 |
+
inv_lines: list[InvoiceLine],
|
| 128 |
+
po: Optional[POHeader],
|
| 129 |
+
po_lines: Optional[list[POLine]],
|
| 130 |
+
gr_rows: Optional[list[GRLineItem]],
|
| 131 |
+
) -> RouteDecision:
|
| 132 |
+
"""
|
| 133 |
+
Deterministic match-type routing (SDD §3.3). First match wins.
|
| 134 |
+
|
| 135 |
+
Caller responsibilities:
|
| 136 |
+
- Acquire the advisory lock before calling (§3.1).
|
| 137 |
+
- Pre-fetch po / po_lines from the DB (pass None if po_number_canonical
|
| 138 |
+
is NULL or the PO row was not found).
|
| 139 |
+
- Pre-fetch gr_rows (pass empty list or None if no GR rows exist for the PO).
|
| 140 |
+
- After receiving RouteDecision, handle prior-match supersession (§3.5)
|
| 141 |
+
before dispatching to the match function.
|
| 142 |
+
|
| 143 |
+
Raises:
|
| 144 |
+
MatchPreconditionError — document.status not in {'validated', 'matching'} (§3.1).
|
| 145 |
+
"""
|
| 146 |
+
|
| 147 |
+
# §3.1 — Entry precondition
|
| 148 |
+
if document.status not in _VALID_ENTRY_STATUSES:
|
| 149 |
+
raise MatchPreconditionError(
|
| 150 |
+
f"document {document.id} has status={document.status!r}; "
|
| 151 |
+
f"expected one of {sorted(_VALID_ENTRY_STATUSES)}"
|
| 152 |
+
)
|
| 153 |
+
|
| 154 |
+
# §3.3 rule 2 — PO-based routing
|
| 155 |
+
if ef.po_number_canonical is not None:
|
| 156 |
+
if po is None:
|
| 157 |
+
return RouteDecision(
|
| 158 |
+
route="stub",
|
| 159 |
+
stub_result=_two_way_stub(
|
| 160 |
+
inv_lines,
|
| 161 |
+
"po_not_found",
|
| 162 |
+
{
|
| 163 |
+
"reason": "no_purchase_order_matched_canonical",
|
| 164 |
+
"tenant_id": document.tenant_id,
|
| 165 |
+
"entity_id": document.entity_id,
|
| 166 |
+
"po_number_canonical": ef.po_number_canonical,
|
| 167 |
+
},
|
| 168 |
+
),
|
| 169 |
+
)
|
| 170 |
+
|
| 171 |
+
if po.status == "cancelled":
|
| 172 |
+
return RouteDecision(
|
| 173 |
+
route="stub",
|
| 174 |
+
stub_result=_two_way_stub(
|
| 175 |
+
inv_lines,
|
| 176 |
+
"po_cancelled",
|
| 177 |
+
{
|
| 178 |
+
"reason": "purchase_order_is_cancelled",
|
| 179 |
+
"po_id": po.id,
|
| 180 |
+
"po_number_canonical": ef.po_number_canonical,
|
| 181 |
+
},
|
| 182 |
+
),
|
| 183 |
+
)
|
| 184 |
+
|
| 185 |
+
lines: list[POLine] = po_lines or []
|
| 186 |
+
|
| 187 |
+
if po.status == "closed" and all(
|
| 188 |
+
pl.status in _PO_LINE_DEAD_STATUSES for pl in lines
|
| 189 |
+
):
|
| 190 |
+
return RouteDecision(
|
| 191 |
+
route="stub",
|
| 192 |
+
stub_result=_two_way_stub(
|
| 193 |
+
inv_lines,
|
| 194 |
+
"po_closed",
|
| 195 |
+
{
|
| 196 |
+
"reason": "purchase_order_closed_all_lines_dead",
|
| 197 |
+
"po_id": po.id,
|
| 198 |
+
"po_number_canonical": ef.po_number_canonical,
|
| 199 |
+
"line_statuses": [pl.status for pl in lines],
|
| 200 |
+
},
|
| 201 |
+
),
|
| 202 |
+
)
|
| 203 |
+
|
| 204 |
+
# §3.3 table: open/partial + every line closed/cancelled → po_all_lines_closed
|
| 205 |
+
if po.status in _PO_ACTIVE_STATUSES and all(
|
| 206 |
+
pl.status in _PO_LINE_DEAD_STATUSES for pl in lines
|
| 207 |
+
):
|
| 208 |
+
return RouteDecision(
|
| 209 |
+
route="stub",
|
| 210 |
+
stub_result=_two_way_stub(
|
| 211 |
+
inv_lines,
|
| 212 |
+
"po_all_lines_closed",
|
| 213 |
+
{
|
| 214 |
+
"reason": "all_po_lines_closed_or_cancelled",
|
| 215 |
+
"po_id": po.id,
|
| 216 |
+
"po_number_canonical": ef.po_number_canonical,
|
| 217 |
+
"line_statuses": [pl.status for pl in lines],
|
| 218 |
+
},
|
| 219 |
+
),
|
| 220 |
+
)
|
| 221 |
+
|
| 222 |
+
# §3.3: at least one live line (open or fully_invoiced) must exist to proceed.
|
| 223 |
+
# fully_invoiced lines reach matching (not stub) so credit-note reversals and
|
| 224 |
+
# po_ceiling_exhausted can be raised correctly by §4 / §5.
|
| 225 |
+
has_live_line = any(pl.status in _PO_LINE_LIVE_STATUSES for pl in lines)
|
| 226 |
+
if not has_live_line:
|
| 227 |
+
return RouteDecision(
|
| 228 |
+
route="stub",
|
| 229 |
+
stub_result=_two_way_stub(
|
| 230 |
+
inv_lines,
|
| 231 |
+
"po_all_lines_closed",
|
| 232 |
+
{
|
| 233 |
+
"reason": "no_live_po_lines_found",
|
| 234 |
+
"po_id": po.id,
|
| 235 |
+
"po_number_canonical": ef.po_number_canonical,
|
| 236 |
+
"line_statuses": [pl.status for pl in lines],
|
| 237 |
+
},
|
| 238 |
+
),
|
| 239 |
+
)
|
| 240 |
+
|
| 241 |
+
# §3.3: any GR row (any inspection_status) → three-way.
|
| 242 |
+
# §5 adjudicates no_accepted_gr / inspection_rejected / timing_fraud internally.
|
| 243 |
+
if gr_rows:
|
| 244 |
+
return RouteDecision(
|
| 245 |
+
route="three_way",
|
| 246 |
+
po=po,
|
| 247 |
+
po_lines=lines,
|
| 248 |
+
gr_rows=gr_rows,
|
| 249 |
+
)
|
| 250 |
+
|
| 251 |
+
return RouteDecision(
|
| 252 |
+
route="two_way",
|
| 253 |
+
po=po,
|
| 254 |
+
po_lines=lines,
|
| 255 |
+
)
|
| 256 |
+
|
| 257 |
+
# §3.3 rule 3 — No PO, no vendor anchor
|
| 258 |
+
if ef.resolved_vendor_id is None:
|
| 259 |
+
return RouteDecision(
|
| 260 |
+
route="stub",
|
| 261 |
+
stub_result=_contract_stub(
|
| 262 |
+
inv_lines,
|
| 263 |
+
"cannot_match_no_vendor",
|
| 264 |
+
{
|
| 265 |
+
"reason": "resolved_vendor_id_is_null_and_no_po",
|
| 266 |
+
"tenant_id": document.tenant_id,
|
| 267 |
+
"entity_id": document.entity_id,
|
| 268 |
+
},
|
| 269 |
+
),
|
| 270 |
+
)
|
| 271 |
+
|
| 272 |
+
# §3.3 rule 4 — No PO, vendor resolved → contract match
|
| 273 |
+
return RouteDecision(route="contract")
|
src/Matching Functions/matching_configs.json
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"_comment_two_way": "Two-way / three-way invoice-PO matching. All percentage thresholds are in % units (e.g. 5 means 5%).",
|
| 3 |
+
|
| 4 |
+
"tier3_price_variance_threshold": 0.10,
|
| 5 |
+
|
| 6 |
+
"price_goods_green": 5.0,
|
| 7 |
+
"price_goods_amber": 10.0,
|
| 8 |
+
|
| 9 |
+
"price_services_green": 10.0,
|
| 10 |
+
"price_services_amber": 15.0,
|
| 11 |
+
|
| 12 |
+
"qty_goods_green": 2.0,
|
| 13 |
+
"qty_goods_amber": 4.0,
|
| 14 |
+
|
| 15 |
+
"qty_services_green": 3.0,
|
| 16 |
+
"qty_services_amber": 6.0,
|
| 17 |
+
|
| 18 |
+
"tier4_gl_group_max_lines": 100,
|
| 19 |
+
|
| 20 |
+
"fuzzy_high_threshold": 90.0,
|
| 21 |
+
"fuzzy_medium_threshold": 80.0,
|
| 22 |
+
"fuzzy_score_cutoff": 80.0,
|
| 23 |
+
|
| 24 |
+
"precision": 28,
|
| 25 |
+
|
| 26 |
+
"_comment_contract": "Contract match configuration. Keys are prefixed 'contract_' to allow independent tuning from two-way bands.",
|
| 27 |
+
|
| 28 |
+
"contract_fuzzy_high_threshold": 90.0,
|
| 29 |
+
"contract_fuzzy_medium_threshold": 80.0,
|
| 30 |
+
"contract_fuzzy_score_cutoff": 80.0,
|
| 31 |
+
|
| 32 |
+
"contract_price_goods_green": 5.0,
|
| 33 |
+
"contract_price_goods_amber": 10.0,
|
| 34 |
+
|
| 35 |
+
"contract_price_services_green": 10.0,
|
| 36 |
+
"contract_price_services_amber": 15.0,
|
| 37 |
+
|
| 38 |
+
"contract_precision": 28
|
| 39 |
+
}
|
src/Matching Functions/three_way_match.py
ADDED
|
@@ -0,0 +1,416 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
from collections import defaultdict
|
| 3 |
+
from dataclasses import dataclass
|
| 4 |
+
from datetime import date
|
| 5 |
+
from decimal import Decimal
|
| 6 |
+
from typing import Any, Optional
|
| 7 |
+
|
| 8 |
+
from two_way_match import (
|
| 9 |
+
InvoiceHeader,
|
| 10 |
+
InvoiceLine,
|
| 11 |
+
MatchExceptionRecord,
|
| 12 |
+
POLine,
|
| 13 |
+
TwoWayConfig,
|
| 14 |
+
TwoWayMatchResult,
|
| 15 |
+
_cfg_from_dict,
|
| 16 |
+
_qty_bands,
|
| 17 |
+
to_decimal,
|
| 18 |
+
)
|
| 19 |
+
|
| 20 |
+
D0 = Decimal("0")
|
| 21 |
+
|
| 22 |
+
# ── New input dataclass ────────────────────────────────────────────────────────
|
| 23 |
+
|
| 24 |
+
@dataclass
|
| 25 |
+
class GRLineItem:
|
| 26 |
+
id: int
|
| 27 |
+
po_line_id: Optional[int]
|
| 28 |
+
gr_date: date # DATE column, no timezone
|
| 29 |
+
inspection_status: str # 'accepted'|'partial_accept'|'rejected'|'pending'|'returned'
|
| 30 |
+
quantity_accepted: Optional[Decimal]
|
| 31 |
+
quantity_received: Optional[Decimal]
|
| 32 |
+
deleted_at: Optional[Any] = None # non-None → soft-deleted; excluded everywhere
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
# ── Zone rank (adds 'critical' above 'red' for integrity signals) ──────────────
|
| 36 |
+
|
| 37 |
+
_ZONE_RANK: dict[str, int] = {"green": 0, "amber": 1, "red": 2, "critical": 3}
|
| 38 |
+
|
| 39 |
+
|
| 40 |
+
def _worse(a: Optional[str], b: str) -> str:
|
| 41 |
+
"""Worst-wins comparator. critical > red > amber > green."""
|
| 42 |
+
return b if _ZONE_RANK.get(b, 0) > _ZONE_RANK.get(a or "green", 0) else (a or "green")
|
| 43 |
+
|
| 44 |
+
|
| 45 |
+
def _push_zone(result: TwoWayMatchResult, inv_line_id: Optional[int], zone: str) -> None:
|
| 46 |
+
"""Update a specific matched line's zone and the overall match_zone, worst-wins."""
|
| 47 |
+
if inv_line_id is not None:
|
| 48 |
+
for mlr in result.match_line_results:
|
| 49 |
+
if mlr.invoice_line_id == inv_line_id:
|
| 50 |
+
mlr.line_zone = _worse(mlr.line_zone, zone)
|
| 51 |
+
result.match_zone = _worse(result.match_zone, zone)
|
| 52 |
+
|
| 53 |
+
|
| 54 |
+
# ── Private helpers ────────────────────────────────────────────────────────────
|
| 55 |
+
|
| 56 |
+
def _uninvoiced_qty(
|
| 57 |
+
po_line: POLine,
|
| 58 |
+
accepted_by_po: dict[int, list[GRLineItem]],
|
| 59 |
+
) -> Decimal:
|
| 60 |
+
"""
|
| 61 |
+
SUM(quantity_accepted for accepted/partial_accept GRs on po_line) − po_line.invoiced_qty.
|
| 62 |
+
Raises RuntimeError on IDP17: quantity_accepted IS NULL on an accepted row.
|
| 63 |
+
Callers must NOT fall back to quantity_received — that would silently mask the violation.
|
| 64 |
+
"""
|
| 65 |
+
rows = accepted_by_po.get(po_line.id, [])
|
| 66 |
+
for g in rows:
|
| 67 |
+
if g.quantity_accepted is None:
|
| 68 |
+
raise RuntimeError(
|
| 69 |
+
f"IDP17 violation: GR line id={g.id} inspection_status={g.inspection_status!r} "
|
| 70 |
+
"has quantity_accepted IS NULL."
|
| 71 |
+
)
|
| 72 |
+
total_accepted = sum((g.quantity_accepted for g in rows), D0)
|
| 73 |
+
return total_accepted - (to_decimal(po_line.invoiced_qty) or D0)
|
| 74 |
+
|
| 75 |
+
|
| 76 |
+
def _band_verdict(
|
| 77 |
+
qty_over_pct: Decimal,
|
| 78 |
+
item_type: Optional[str],
|
| 79 |
+
cfg: TwoWayConfig,
|
| 80 |
+
) -> Optional[str]:
|
| 81 |
+
"""Return failing zone ('amber'|'red') if qty_over_pct exceeds tolerance, else None (pass)."""
|
| 82 |
+
qty_green, qty_amber = _qty_bands(item_type, cfg) # reuses existing 2-way helper
|
| 83 |
+
if qty_over_pct == D0 or qty_over_pct <= qty_green:
|
| 84 |
+
return None
|
| 85 |
+
return "amber" if qty_over_pct <= qty_amber else "red"
|
| 86 |
+
|
| 87 |
+
|
| 88 |
+
def _qty_check_single(
|
| 89 |
+
*,
|
| 90 |
+
inv_line: InvoiceLine,
|
| 91 |
+
po_line: POLine,
|
| 92 |
+
accepted_by_po: dict[int, list[GRLineItem]],
|
| 93 |
+
result: TwoWayMatchResult,
|
| 94 |
+
cfg: TwoWayConfig,
|
| 95 |
+
) -> None:
|
| 96 |
+
"""§5.3 quantity-over-received check for one Tier 1-3 (inv_line, po_line) direct match."""
|
| 97 |
+
|
| 98 |
+
# Pre-band guard 1: missing invoice qty — Cat 2 incomplete_fields already owns this flag
|
| 99 |
+
if inv_line.quantity is None:
|
| 100 |
+
result.skipped_steps.append({
|
| 101 |
+
"step": "qty_over_received",
|
| 102 |
+
"skip_reason": "inv_qty_null",
|
| 103 |
+
"line_id": inv_line.id,
|
| 104 |
+
"po_line_id": po_line.id,
|
| 105 |
+
})
|
| 106 |
+
return
|
| 107 |
+
|
| 108 |
+
uninvoiced = _uninvoiced_qty(po_line, accepted_by_po)
|
| 109 |
+
|
| 110 |
+
# Pre-band guard 2: negative uninvoiced → IDP17 forensic exception, zone red
|
| 111 |
+
if uninvoiced < D0:
|
| 112 |
+
result.match_exceptions.append(MatchExceptionRecord(
|
| 113 |
+
exception_type="qty_over_received",
|
| 114 |
+
line_id=inv_line.id,
|
| 115 |
+
po_line_id=po_line.id,
|
| 116 |
+
exception_detail={
|
| 117 |
+
"reason": "uninvoiced_accepted_qty_negative",
|
| 118 |
+
"uninvoiced_accepted_qty": str(uninvoiced),
|
| 119 |
+
},
|
| 120 |
+
))
|
| 121 |
+
_push_zone(result, inv_line.id, "red")
|
| 122 |
+
result.overall_status = "exception"
|
| 123 |
+
return
|
| 124 |
+
|
| 125 |
+
inv_qty = inv_line.quantity
|
| 126 |
+
|
| 127 |
+
# Pre-band guard 3/4: uninvoiced == 0
|
| 128 |
+
if uninvoiced == D0:
|
| 129 |
+
if inv_qty > D0:
|
| 130 |
+
# All accepted GR qty already consumed by prior approved invoices
|
| 131 |
+
result.match_exceptions.append(MatchExceptionRecord(
|
| 132 |
+
exception_type="gr_fully_consumed",
|
| 133 |
+
line_id=inv_line.id,
|
| 134 |
+
po_line_id=po_line.id,
|
| 135 |
+
exception_detail={"reason": "gr_fully_consumed", "inv_qty": str(inv_qty)},
|
| 136 |
+
))
|
| 137 |
+
_push_zone(result, inv_line.id, "red")
|
| 138 |
+
result.overall_status = "exception"
|
| 139 |
+
# inv_qty == 0 → noise line ($0 line), skip silently
|
| 140 |
+
return
|
| 141 |
+
|
| 142 |
+
# Band evaluation
|
| 143 |
+
qty_over_pct = max(D0, (inv_qty - uninvoiced) / uninvoiced * Decimal("100"))
|
| 144 |
+
zone = _band_verdict(qty_over_pct, po_line.item_type, cfg)
|
| 145 |
+
if zone:
|
| 146 |
+
result.match_exceptions.append(MatchExceptionRecord(
|
| 147 |
+
exception_type="qty_over_received",
|
| 148 |
+
line_id=inv_line.id,
|
| 149 |
+
po_line_id=po_line.id,
|
| 150 |
+
exception_detail={
|
| 151 |
+
"reason": "qty_over_received",
|
| 152 |
+
"qty_over_pct": str(qty_over_pct),
|
| 153 |
+
"inv_qty": str(inv_qty),
|
| 154 |
+
"uninvoiced_accepted_qty": str(uninvoiced),
|
| 155 |
+
},
|
| 156 |
+
))
|
| 157 |
+
_push_zone(result, inv_line.id, zone)
|
| 158 |
+
result.overall_status = "exception"
|
| 159 |
+
|
| 160 |
+
|
| 161 |
+
def _qty_check_tier4_group(
|
| 162 |
+
*,
|
| 163 |
+
gl_account: Optional[str],
|
| 164 |
+
group_inv_lines: list[InvoiceLine],
|
| 165 |
+
group_po_lines: list[POLine],
|
| 166 |
+
accepted_by_po: dict[int, list[GRLineItem]],
|
| 167 |
+
accepted_gr: list[GRLineItem], # full accepted list — needed to detect no-GR vs consumed
|
| 168 |
+
result: TwoWayMatchResult,
|
| 169 |
+
cfg: TwoWayConfig,
|
| 170 |
+
) -> None:
|
| 171 |
+
"""
|
| 172 |
+
§5.6 aggregate GR quantity check for one Tier-4 GL group.
|
| 173 |
+
Partitions PO lines into goods / services subsets.
|
| 174 |
+
NULL-item-type PO lines are excluded from both (no GR obligation by default).
|
| 175 |
+
The quantity comparison is conservative: ALL inv-line qty (goods+services) vs
|
| 176 |
+
ONLY the goods-subset uninvoiced accepted qty.
|
| 177 |
+
"""
|
| 178 |
+
po_goods = [pl for pl in group_po_lines if pl.item_type == "goods"]
|
| 179 |
+
po_services = [pl for pl in group_po_lines if pl.item_type == "services"]
|
| 180 |
+
|
| 181 |
+
if not po_goods:
|
| 182 |
+
return # 100 % services or all NULL item_type → no GR obligation for this group
|
| 183 |
+
|
| 184 |
+
goods_ids = {pl.id for pl in po_goods}
|
| 185 |
+
|
| 186 |
+
# IDP17 violations surface here via RuntimeError from _uninvoiced_qty
|
| 187 |
+
uninvoiced_goods = sum((_uninvoiced_qty(pl, accepted_by_po) for pl in po_goods), D0)
|
| 188 |
+
|
| 189 |
+
# Total invoice qty over the WHOLE group (conservative: includes services portion)
|
| 190 |
+
inv_qty_total = sum(
|
| 191 |
+
(il.quantity for il in group_inv_lines if il.quantity is not None), D0
|
| 192 |
+
)
|
| 193 |
+
|
| 194 |
+
inv_ids = [il.id for il in group_inv_lines]
|
| 195 |
+
|
| 196 |
+
base_detail: dict[str, Any] = {
|
| 197 |
+
"group_gl_account": gl_account,
|
| 198 |
+
"po_lines_goods": sorted(goods_ids),
|
| 199 |
+
"po_lines_services": [pl.id for pl in po_services],
|
| 200 |
+
"uninvoiced_accepted_qty_goods": str(uninvoiced_goods),
|
| 201 |
+
"inv_qty_total": str(inv_qty_total),
|
| 202 |
+
}
|
| 203 |
+
|
| 204 |
+
# Pre-band guard: negative uninvoiced_goods → IDP17 forensic
|
| 205 |
+
if uninvoiced_goods < D0:
|
| 206 |
+
result.match_exceptions.append(MatchExceptionRecord(
|
| 207 |
+
exception_type="qty_over_received",
|
| 208 |
+
exception_detail={**base_detail, "reason": "uninvoiced_accepted_qty_negative"},
|
| 209 |
+
))
|
| 210 |
+
for il_id in inv_ids:
|
| 211 |
+
_push_zone(result, il_id, "red")
|
| 212 |
+
result.overall_status = "exception"
|
| 213 |
+
return
|
| 214 |
+
|
| 215 |
+
# Pre-band guard: uninvoiced_goods == 0
|
| 216 |
+
if uninvoiced_goods == D0:
|
| 217 |
+
# Distinguish "no accepted GRs for goods lines yet" from "fully consumed"
|
| 218 |
+
has_accepted_gr_for_goods = any(g.po_line_id in goods_ids for g in accepted_gr)
|
| 219 |
+
exc_type = "gr_fully_consumed" if has_accepted_gr_for_goods else "no_accepted_gr"
|
| 220 |
+
if inv_qty_total > D0:
|
| 221 |
+
result.match_exceptions.append(MatchExceptionRecord(
|
| 222 |
+
exception_type=exc_type,
|
| 223 |
+
exception_detail={**base_detail, "reason": exc_type},
|
| 224 |
+
))
|
| 225 |
+
for il_id in inv_ids:
|
| 226 |
+
_push_zone(result, il_id, "red")
|
| 227 |
+
result.overall_status = "exception"
|
| 228 |
+
# inv_qty_total == 0 → noise group, skip silently
|
| 229 |
+
return
|
| 230 |
+
|
| 231 |
+
# Band evaluation (always uses "goods" tolerance for mixed groups — §5.6 conservative choice)
|
| 232 |
+
qty_over_pct = max(D0, (inv_qty_total - uninvoiced_goods) / uninvoiced_goods * Decimal("100"))
|
| 233 |
+
zone = _band_verdict(qty_over_pct, "goods", cfg)
|
| 234 |
+
if zone:
|
| 235 |
+
result.match_exceptions.append(MatchExceptionRecord(
|
| 236 |
+
exception_type="qty_over_received",
|
| 237 |
+
exception_detail={
|
| 238 |
+
**base_detail,
|
| 239 |
+
"subset_breakdown": {"qty_over_pct": str(qty_over_pct)},
|
| 240 |
+
},
|
| 241 |
+
))
|
| 242 |
+
for il_id in inv_ids:
|
| 243 |
+
_push_zone(result, il_id, zone)
|
| 244 |
+
result.overall_status = "exception"
|
| 245 |
+
|
| 246 |
+
|
| 247 |
+
# ── Public entry point ─────────────────────────────────────────────────────────
|
| 248 |
+
|
| 249 |
+
def run_three_way_match(
|
| 250 |
+
two_way_result: TwoWayMatchResult,
|
| 251 |
+
invoice: InvoiceHeader, # must have invoice_date: Optional[date]
|
| 252 |
+
inv_lines: list[InvoiceLine],
|
| 253 |
+
po_lines: list[POLine],
|
| 254 |
+
gr_lines: list[GRLineItem],
|
| 255 |
+
*,
|
| 256 |
+
matching_configs: Optional[dict] = None,
|
| 257 |
+
) -> TwoWayMatchResult:
|
| 258 |
+
"""
|
| 259 |
+
Layers GR checks (§5.1–5.6) on top of an already-completed 2-way match result.
|
| 260 |
+
Always sets match_type='three_way'.
|
| 261 |
+
|
| 262 |
+
Check order (stop-on-fire for checks 1-3):
|
| 263 |
+
1. no_accepted_gr — stop if no accepted/partial_accept GRs exist
|
| 264 |
+
2. invoice_before_gr — stop if invoice pre-dates earliest accepted GR; zone→critical
|
| 265 |
+
3. gr_inspection_rejected — stop if any routed GR row has status='rejected'
|
| 266 |
+
4. qty_over_received — per matched PO line (Tier 1-3) or GL group (Tier 4)
|
| 267 |
+
|
| 268 |
+
2-way line counts (lines_total / lines_matched / lines_in_exception) are never modified;
|
| 269 |
+
only match_zone, individual line_zone fields, match_exceptions, and overall_status change.
|
| 270 |
+
"""
|
| 271 |
+
cfg = _cfg_from_dict(matching_configs or {})
|
| 272 |
+
|
| 273 |
+
result = two_way_result
|
| 274 |
+
result.match_type = "three_way"
|
| 275 |
+
|
| 276 |
+
inv_by_id: dict[int, InvoiceLine] = {il.id: il for il in inv_lines}
|
| 277 |
+
po_by_id: dict[int, POLine] = {pl.id: pl for pl in po_lines}
|
| 278 |
+
|
| 279 |
+
# Collect matched pairs from 2-way, split by tier
|
| 280 |
+
tier1_3: list[tuple[int, int]] = []
|
| 281 |
+
tier4: list[tuple[int, int]] = []
|
| 282 |
+
for mlr in result.match_line_results:
|
| 283 |
+
if mlr.line_status != "matched":
|
| 284 |
+
continue
|
| 285 |
+
(tier4 if mlr.match_tier == 4 else tier1_3).append(
|
| 286 |
+
(mlr.invoice_line_id, mlr.po_line_id)
|
| 287 |
+
)
|
| 288 |
+
|
| 289 |
+
all_po_ids: set[int] = {po_id for _, po_id in tier1_3 + tier4}
|
| 290 |
+
if not all_po_ids:
|
| 291 |
+
return result # nothing matched in 2-way; nothing to GR-check
|
| 292 |
+
|
| 293 |
+
# Route GR rows to matched PO lines.
|
| 294 |
+
# Exclude: soft-deleted rows, and 'returned' rows (those belong to credit-note matching §5.5).
|
| 295 |
+
routed = [
|
| 296 |
+
g for g in gr_lines
|
| 297 |
+
if g.po_line_id in all_po_ids
|
| 298 |
+
and g.deleted_at is None
|
| 299 |
+
and g.inspection_status != "returned"
|
| 300 |
+
]
|
| 301 |
+
|
| 302 |
+
# ── Check 1: no accepted GR ────────────────────────────────────────────────
|
| 303 |
+
# Timing-fraud source set: accepted / partial_accept only (§5.1)
|
| 304 |
+
accepted = [g for g in routed if g.inspection_status in ("accepted", "partial_accept")]
|
| 305 |
+
if not accepted:
|
| 306 |
+
result.match_exceptions.append(MatchExceptionRecord(
|
| 307 |
+
exception_type="no_accepted_gr",
|
| 308 |
+
exception_detail={"reason": "no_accepted_gr",
|
| 309 |
+
"matched_po_line_ids": sorted(all_po_ids)},
|
| 310 |
+
))
|
| 311 |
+
result.overall_status = "exception"
|
| 312 |
+
return result
|
| 313 |
+
|
| 314 |
+
# ── Check 2: timing fraud ─────────────────────────────────────────────────
|
| 315 |
+
# Skip if invoice_date IS NULL (Cat 2 incomplete_fields already owns missing date).
|
| 316 |
+
if invoice.invoice_date is not None:
|
| 317 |
+
earliest_gr_date = min(g.gr_date for g in accepted) # DATE-level comparison
|
| 318 |
+
if invoice.invoice_date < earliest_gr_date:
|
| 319 |
+
result.match_exceptions.append(MatchExceptionRecord(
|
| 320 |
+
exception_type="invoice_before_gr",
|
| 321 |
+
exception_detail={
|
| 322 |
+
"reason": "invoice_before_gr",
|
| 323 |
+
"invoice_date": str(invoice.invoice_date),
|
| 324 |
+
"earliest_gr_date": str(earliest_gr_date),
|
| 325 |
+
},
|
| 326 |
+
))
|
| 327 |
+
result.risk_flags.append("invoice_before_gr")
|
| 328 |
+
result.match_zone = "critical" # §8.3 — integrity signal overrides normal zones
|
| 329 |
+
result.overall_status = "exception"
|
| 330 |
+
return result
|
| 331 |
+
|
| 332 |
+
# ── Check 3: inspection rejection ─────────────────────────────────────────
|
| 333 |
+
# Source set: ALL routed non-returned GR rows (§5.1) — rejected status fires regardless of
|
| 334 |
+
# whether the PO line also has accepted GRs.
|
| 335 |
+
rejected = [g for g in routed if g.inspection_status == "rejected"]
|
| 336 |
+
if rejected:
|
| 337 |
+
result.match_exceptions.append(MatchExceptionRecord(
|
| 338 |
+
exception_type="gr_inspection_rejected",
|
| 339 |
+
exception_detail={
|
| 340 |
+
"reason": "gr_inspection_rejected",
|
| 341 |
+
"rejected_gr_ids": sorted(g.id for g in rejected),
|
| 342 |
+
},
|
| 343 |
+
))
|
| 344 |
+
result.overall_status = "exception"
|
| 345 |
+
return result
|
| 346 |
+
|
| 347 |
+
# ── Check 4: quantity over-received ────────────────────────────���──────────
|
| 348 |
+
# Build accepted-GR index per PO line, FIFO-ordered (gr_date ASC, id ASC).
|
| 349 |
+
# This ordering is analytical only — nothing is physically decremented (§5.4).
|
| 350 |
+
accepted_by_po: dict[int, list[GRLineItem]] = defaultdict(list)
|
| 351 |
+
for g in accepted:
|
| 352 |
+
if g.po_line_id is not None:
|
| 353 |
+
accepted_by_po[g.po_line_id].append(g)
|
| 354 |
+
for rows in accepted_by_po.values():
|
| 355 |
+
rows.sort(key=lambda g: (g.gr_date, g.id))
|
| 356 |
+
|
| 357 |
+
# Tier 1-3: one check per direct (inv_line, po_line) match
|
| 358 |
+
for inv_id, po_id in tier1_3:
|
| 359 |
+
il = inv_by_id.get(inv_id)
|
| 360 |
+
pl = po_by_id.get(po_id)
|
| 361 |
+
if il is None or pl is None:
|
| 362 |
+
continue
|
| 363 |
+
if pl.item_type in (None, "services"):
|
| 364 |
+
continue # services and NULL-typed lines have no GR obligation
|
| 365 |
+
try:
|
| 366 |
+
_qty_check_single(
|
| 367 |
+
inv_line=il, po_line=pl,
|
| 368 |
+
accepted_by_po=accepted_by_po,
|
| 369 |
+
result=result, cfg=cfg,
|
| 370 |
+
)
|
| 371 |
+
except RuntimeError as exc:
|
| 372 |
+
result.match_exceptions.append(MatchExceptionRecord(
|
| 373 |
+
exception_type="data_integrity_violation",
|
| 374 |
+
line_id=inv_id,
|
| 375 |
+
po_line_id=po_id,
|
| 376 |
+
exception_detail={"reason": "idp17_quantity_accepted_null", "detail": str(exc)},
|
| 377 |
+
))
|
| 378 |
+
_push_zone(result, inv_id, "red")
|
| 379 |
+
result.overall_status = "exception"
|
| 380 |
+
|
| 381 |
+
# Tier 4: aggregate check per GL group (§5.6)
|
| 382 |
+
if tier4:
|
| 383 |
+
# Reconstruct GL groups from inv_line.gl_account
|
| 384 |
+
gl_groups: dict[str, dict[str, set]] = defaultdict(
|
| 385 |
+
lambda: {"inv": set(), "po": set()}
|
| 386 |
+
)
|
| 387 |
+
for inv_id, po_id in tier4:
|
| 388 |
+
il = inv_by_id.get(inv_id)
|
| 389 |
+
if il is None:
|
| 390 |
+
continue
|
| 391 |
+
key = il.gl_account or "__null__"
|
| 392 |
+
gl_groups[key]["inv"].add(inv_id)
|
| 393 |
+
gl_groups[key]["po"].add(po_id)
|
| 394 |
+
|
| 395 |
+
for gl_key, ids in gl_groups.items():
|
| 396 |
+
try:
|
| 397 |
+
_qty_check_tier4_group(
|
| 398 |
+
gl_account = None if gl_key == "__null__" else gl_key,
|
| 399 |
+
group_inv_lines= [inv_by_id[i] for i in ids["inv"] if i in inv_by_id],
|
| 400 |
+
group_po_lines = [po_by_id[i] for i in ids["po"] if i in po_by_id],
|
| 401 |
+
accepted_by_po = accepted_by_po,
|
| 402 |
+
accepted_gr = accepted,
|
| 403 |
+
result=result, cfg=cfg,
|
| 404 |
+
)
|
| 405 |
+
except RuntimeError as exc:
|
| 406 |
+
result.match_exceptions.append(MatchExceptionRecord(
|
| 407 |
+
exception_type="data_integrity_violation",
|
| 408 |
+
exception_detail={
|
| 409 |
+
"reason": "idp17_quantity_accepted_null",
|
| 410 |
+
"gl_account": None if gl_key == "__null__" else gl_key,
|
| 411 |
+
"detail": str(exc),
|
| 412 |
+
},
|
| 413 |
+
))
|
| 414 |
+
result.overall_status = "exception"
|
| 415 |
+
|
| 416 |
+
return result
|
src/Matching Functions/two_way_match.py
ADDED
|
@@ -0,0 +1,1025 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
from dataclasses import dataclass, field
|
| 4 |
+
from datetime import date
|
| 5 |
+
from decimal import Decimal, ROUND_HALF_EVEN, getcontext
|
| 6 |
+
from typing import Any, Callable, Optional
|
| 7 |
+
import re
|
| 8 |
+
import unicodedata
|
| 9 |
+
|
| 10 |
+
from rapidfuzz import fuzz
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
# ============================================================
|
| 14 |
+
# Decimal / arithmetic hygiene
|
| 15 |
+
# ============================================================
|
| 16 |
+
getcontext().prec = 28
|
| 17 |
+
getcontext().rounding = ROUND_HALF_EVEN
|
| 18 |
+
|
| 19 |
+
D0 = Decimal("0")
|
| 20 |
+
D100 = Decimal("100")
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
# ============================================================
|
| 24 |
+
# Data models
|
| 25 |
+
# ============================================================
|
| 26 |
+
@dataclass(frozen=True)
|
| 27 |
+
class InvoiceLine:
|
| 28 |
+
id: int
|
| 29 |
+
description: Optional[str] = None
|
| 30 |
+
description_canonical: Optional[str] = None
|
| 31 |
+
sku: Optional[str] = None
|
| 32 |
+
sku_canonical: Optional[str] = None
|
| 33 |
+
gl_account: Optional[str] = None
|
| 34 |
+
quantity: Optional[Decimal] = None
|
| 35 |
+
unit_price: Optional[Decimal] = None
|
| 36 |
+
amount: Optional[Decimal] = None
|
| 37 |
+
|
| 38 |
+
|
| 39 |
+
@dataclass(frozen=True)
|
| 40 |
+
class POLine:
|
| 41 |
+
id: int
|
| 42 |
+
description: Optional[str] = None
|
| 43 |
+
description_canonical: Optional[str] = None
|
| 44 |
+
sku: Optional[str] = None
|
| 45 |
+
sku_canonical: Optional[str] = None
|
| 46 |
+
gl_account: Optional[str] = None
|
| 47 |
+
quantity: Optional[Decimal] = None
|
| 48 |
+
invoiced_qty: Optional[Decimal] = None
|
| 49 |
+
unit_price: Optional[Decimal] = None
|
| 50 |
+
item_type: Optional[str] = None # 'goods' | 'services' | None
|
| 51 |
+
status: str = "open" # 'open' | 'fully_invoiced' | 'closed' | 'cancelled'
|
| 52 |
+
deleted_at: Any = None
|
| 53 |
+
|
| 54 |
+
|
| 55 |
+
@dataclass(frozen=True)
|
| 56 |
+
class POHeader:
|
| 57 |
+
id: int
|
| 58 |
+
vendor_id: Optional[int] = None
|
| 59 |
+
currency: Optional[str] = None
|
| 60 |
+
status: str = "open"
|
| 61 |
+
deleted_at: Any = None
|
| 62 |
+
|
| 63 |
+
|
| 64 |
+
@dataclass(frozen=True)
|
| 65 |
+
class InvoiceHeader:
|
| 66 |
+
id: int
|
| 67 |
+
status: str
|
| 68 |
+
vendor_id: Optional[int] = None
|
| 69 |
+
currency: Optional[str] = None
|
| 70 |
+
total_amount: Optional[Decimal] = None
|
| 71 |
+
advisory_flags: tuple[str, ...] = ()
|
| 72 |
+
doc_type: str = "invoice"
|
| 73 |
+
tenant_id: Optional[int] = None
|
| 74 |
+
invoice_date: Optional[date] = None
|
| 75 |
+
|
| 76 |
+
|
| 77 |
+
@dataclass
|
| 78 |
+
class MatchExceptionRecord:
|
| 79 |
+
exception_type: str
|
| 80 |
+
line_id: Optional[int] = None
|
| 81 |
+
po_line_id: Optional[int] = None
|
| 82 |
+
exception_detail: dict[str, Any] = field(default_factory=dict)
|
| 83 |
+
|
| 84 |
+
|
| 85 |
+
@dataclass
|
| 86 |
+
class MatchLineResult:
|
| 87 |
+
invoice_line_id: int
|
| 88 |
+
po_line_id: Optional[int]
|
| 89 |
+
match_tier: int
|
| 90 |
+
allocated_inv_amount: Optional[Decimal] = None
|
| 91 |
+
allocated_po_amount: Optional[Decimal] = None
|
| 92 |
+
price_var_pct: Optional[Decimal] = None
|
| 93 |
+
qty_var_pct: Optional[Decimal] = None
|
| 94 |
+
line_zone: str = "green" # green | amber | red
|
| 95 |
+
line_status: str = "matched" # matched | no_match | exception
|
| 96 |
+
match_type: str = "two_way"
|
| 97 |
+
|
| 98 |
+
|
| 99 |
+
@dataclass
|
| 100 |
+
class TwoWayMatchResult:
|
| 101 |
+
match_type: str = "two_way"
|
| 102 |
+
overall_status: str = "exception" # matched | partial_match | exception
|
| 103 |
+
match_zone: str = "green" # green | amber | red
|
| 104 |
+
documents_status: str = "pending_approval"
|
| 105 |
+
|
| 106 |
+
lines_total: int = 0
|
| 107 |
+
lines_matched: int = 0
|
| 108 |
+
lines_in_exception: int = 0
|
| 109 |
+
|
| 110 |
+
risk_flags: list[str] = field(default_factory=list)
|
| 111 |
+
advisory_flags: list[str] = field(default_factory=list)
|
| 112 |
+
skipped_steps: list[dict] = field(default_factory=list)
|
| 113 |
+
header_exceptions: list[MatchExceptionRecord] = field(default_factory=list)
|
| 114 |
+
match_exceptions: list[MatchExceptionRecord] = field(default_factory=list)
|
| 115 |
+
match_line_results: list[MatchLineResult] = field(default_factory=list)
|
| 116 |
+
|
| 117 |
+
|
| 118 |
+
@dataclass(frozen=True)
|
| 119 |
+
class TwoWayConfig:
|
| 120 |
+
tier3_price_variance_threshold: Decimal = Decimal("0.10")
|
| 121 |
+
|
| 122 |
+
price_goods_green: Decimal = Decimal("5")
|
| 123 |
+
price_goods_amber: Decimal = Decimal("10")
|
| 124 |
+
price_services_green: Decimal = Decimal("10")
|
| 125 |
+
price_services_amber: Decimal = Decimal("15")
|
| 126 |
+
|
| 127 |
+
qty_goods_green: Decimal = Decimal("2")
|
| 128 |
+
qty_goods_amber: Decimal = Decimal("4")
|
| 129 |
+
qty_services_green: Decimal = Decimal("3")
|
| 130 |
+
qty_services_amber: Decimal = Decimal("6")
|
| 131 |
+
|
| 132 |
+
tier4_gl_group_max_lines: int = 100
|
| 133 |
+
|
| 134 |
+
fuzzy_high_threshold: float = 90.0
|
| 135 |
+
fuzzy_medium_threshold: float = 80.0
|
| 136 |
+
fuzzy_score_cutoff: float = 80.0
|
| 137 |
+
|
| 138 |
+
precision: int = 28
|
| 139 |
+
|
| 140 |
+
|
| 141 |
+
def _cfg_from_dict(d: dict) -> TwoWayConfig:
|
| 142 |
+
"""Build TwoWayConfig from matching_configs dict. Falls back to dataclass defaults for missing keys."""
|
| 143 |
+
def _d(key: str, default: str) -> Decimal:
|
| 144 |
+
return Decimal(str(d.get(key, default)))
|
| 145 |
+
|
| 146 |
+
def _i(key: str, default: int) -> int:
|
| 147 |
+
return int(d.get(key, default))
|
| 148 |
+
|
| 149 |
+
def _f(key: str, default: float) -> float:
|
| 150 |
+
return float(d.get(key, default))
|
| 151 |
+
|
| 152 |
+
return TwoWayConfig(
|
| 153 |
+
tier3_price_variance_threshold=_d("tier3_price_variance_threshold", "0.10"),
|
| 154 |
+
price_goods_green=_d("price_goods_green", "5"),
|
| 155 |
+
price_goods_amber=_d("price_goods_amber", "10"),
|
| 156 |
+
price_services_green=_d("price_services_green", "10"),
|
| 157 |
+
price_services_amber=_d("price_services_amber", "15"),
|
| 158 |
+
qty_goods_green=_d("qty_goods_green", "2"),
|
| 159 |
+
qty_goods_amber=_d("qty_goods_amber", "4"),
|
| 160 |
+
qty_services_green=_d("qty_services_green", "3"),
|
| 161 |
+
qty_services_amber=_d("qty_services_amber", "6"),
|
| 162 |
+
tier4_gl_group_max_lines=_i("tier4_gl_group_max_lines", 100),
|
| 163 |
+
fuzzy_high_threshold=_f("fuzzy_high_threshold", 90.0),
|
| 164 |
+
fuzzy_medium_threshold=_f("fuzzy_medium_threshold", 80.0),
|
| 165 |
+
fuzzy_score_cutoff=_f("fuzzy_score_cutoff", 80.0),
|
| 166 |
+
precision=_i("precision", 28),
|
| 167 |
+
)
|
| 168 |
+
|
| 169 |
+
|
| 170 |
+
# ============================================================
|
| 171 |
+
# Helper functions
|
| 172 |
+
# ============================================================
|
| 173 |
+
def to_decimal(value: Any) -> Optional[Decimal]:
|
| 174 |
+
if value is None or value == "":
|
| 175 |
+
return None
|
| 176 |
+
if isinstance(value, Decimal):
|
| 177 |
+
return value
|
| 178 |
+
return Decimal(str(value))
|
| 179 |
+
|
| 180 |
+
|
| 181 |
+
def canonicalise_identifier(value: Optional[str]) -> Optional[str]:
|
| 182 |
+
"""
|
| 183 |
+
NFKC + uppercase + strip non-alphanumeric.
|
| 184 |
+
"""
|
| 185 |
+
if value is None:
|
| 186 |
+
return None
|
| 187 |
+
value = unicodedata.normalize("NFKC", str(value)).upper()
|
| 188 |
+
value = re.sub(r"[^A-Z0-9]+", "", value)
|
| 189 |
+
return value or None
|
| 190 |
+
|
| 191 |
+
|
| 192 |
+
def norm_currency(value: Optional[str]) -> Optional[str]:
|
| 193 |
+
return value.upper().strip() if value else None
|
| 194 |
+
|
| 195 |
+
|
| 196 |
+
def price_variance_pct(inv_price: Optional[Decimal], po_price: Optional[Decimal]) -> Optional[Decimal]:
|
| 197 |
+
"""
|
| 198 |
+
abs(inv - po) / po * 100
|
| 199 |
+
Skip if invoice price is NULL or po price is NULL or po price == 0.
|
| 200 |
+
"""
|
| 201 |
+
if inv_price is None or po_price is None or po_price == 0:
|
| 202 |
+
return None
|
| 203 |
+
return (abs(inv_price - po_price) / po_price * D100).quantize(Decimal("0.0001"))
|
| 204 |
+
|
| 205 |
+
|
| 206 |
+
def qty_over_pct(inv_qty: Optional[Decimal], available_qty: Optional[Decimal]) -> Optional[Decimal]:
|
| 207 |
+
"""
|
| 208 |
+
max(0, (inv_qty - available_qty) / available_qty * 100)
|
| 209 |
+
Skip if inv_qty is NULL or available_qty is NULL/zero.
|
| 210 |
+
"""
|
| 211 |
+
if inv_qty is None or available_qty is None or available_qty == 0:
|
| 212 |
+
return None
|
| 213 |
+
return (max(D0, (inv_qty - available_qty) / available_qty * D100)).quantize(Decimal("0.0001"))
|
| 214 |
+
|
| 215 |
+
|
| 216 |
+
def available_qty(po_line: POLine) -> Decimal:
|
| 217 |
+
q = to_decimal(po_line.quantity) or D0
|
| 218 |
+
iq = to_decimal(po_line.invoiced_qty) or D0
|
| 219 |
+
return q - iq
|
| 220 |
+
|
| 221 |
+
|
| 222 |
+
def remaining_po_value(po_lines: list[POLine]) -> Decimal:
|
| 223 |
+
total = D0
|
| 224 |
+
for line in po_lines:
|
| 225 |
+
if line.deleted_at is not None:
|
| 226 |
+
continue
|
| 227 |
+
if line.status != "open":
|
| 228 |
+
continue
|
| 229 |
+
q = to_decimal(line.quantity) or D0
|
| 230 |
+
iq = to_decimal(line.invoiced_qty) or D0
|
| 231 |
+
up = to_decimal(line.unit_price) or D0
|
| 232 |
+
total += (q - iq) * up
|
| 233 |
+
return total
|
| 234 |
+
|
| 235 |
+
|
| 236 |
+
def same_vendor_merge_chain(
|
| 237 |
+
invoice_vendor_id: Optional[int],
|
| 238 |
+
po_vendor_id: Optional[int],
|
| 239 |
+
merge_chain_fn: Optional[Callable[[Optional[int], Optional[int]], bool]] = None,
|
| 240 |
+
) -> bool:
|
| 241 |
+
if invoice_vendor_id is None or po_vendor_id is None:
|
| 242 |
+
return False
|
| 243 |
+
if merge_chain_fn is not None:
|
| 244 |
+
return bool(merge_chain_fn(invoice_vendor_id, po_vendor_id))
|
| 245 |
+
return invoice_vendor_id == po_vendor_id
|
| 246 |
+
|
| 247 |
+
|
| 248 |
+
def zone_from_pct(
|
| 249 |
+
price_pct: Optional[Decimal],
|
| 250 |
+
qty_pct: Optional[Decimal],
|
| 251 |
+
item_type: Optional[str],
|
| 252 |
+
cfg: TwoWayConfig,
|
| 253 |
+
) -> str:
|
| 254 |
+
"""
|
| 255 |
+
Worst-wins among price and qty, ignoring NULL dimensions.
|
| 256 |
+
green -> amber -> red
|
| 257 |
+
"""
|
| 258 |
+
item_type = (item_type or "services").lower()
|
| 259 |
+
|
| 260 |
+
if item_type == "goods":
|
| 261 |
+
price_green, price_amber = cfg.price_goods_green, cfg.price_goods_amber
|
| 262 |
+
qty_green, qty_amber = cfg.qty_goods_green, cfg.qty_goods_amber
|
| 263 |
+
else:
|
| 264 |
+
price_green, price_amber = cfg.price_services_green, cfg.price_services_amber
|
| 265 |
+
qty_green, qty_amber = cfg.qty_services_green, cfg.qty_services_amber
|
| 266 |
+
|
| 267 |
+
def single_zone(pct: Optional[Decimal], green: Decimal, amber: Decimal) -> Optional[str]:
|
| 268 |
+
if pct is None:
|
| 269 |
+
return None
|
| 270 |
+
if pct <= green:
|
| 271 |
+
return "green"
|
| 272 |
+
if pct <= amber:
|
| 273 |
+
return "amber"
|
| 274 |
+
return "red"
|
| 275 |
+
|
| 276 |
+
zones = [
|
| 277 |
+
single_zone(price_pct, price_green, price_amber),
|
| 278 |
+
single_zone(qty_pct, qty_green, qty_amber),
|
| 279 |
+
]
|
| 280 |
+
zones = [z for z in zones if z is not None]
|
| 281 |
+
|
| 282 |
+
if not zones:
|
| 283 |
+
return "green"
|
| 284 |
+
if "red" in zones:
|
| 285 |
+
return "red"
|
| 286 |
+
if "amber" in zones:
|
| 287 |
+
return "amber"
|
| 288 |
+
return "green"
|
| 289 |
+
|
| 290 |
+
|
| 291 |
+
def worse_zone(a: str, b: str) -> str:
|
| 292 |
+
rank = {"green": 0, "amber": 1, "red": 2}
|
| 293 |
+
return a if rank[a] >= rank[b] else b
|
| 294 |
+
|
| 295 |
+
|
| 296 |
+
def _price_bands(item_type: Optional[str], cfg: TwoWayConfig) -> tuple[Decimal, Decimal]:
|
| 297 |
+
item_type = (item_type or "services").lower()
|
| 298 |
+
if item_type == "goods":
|
| 299 |
+
return cfg.price_goods_green, cfg.price_goods_amber
|
| 300 |
+
return cfg.price_services_green, cfg.price_services_amber
|
| 301 |
+
|
| 302 |
+
|
| 303 |
+
def _qty_bands(item_type: Optional[str], cfg: TwoWayConfig) -> tuple[Decimal, Decimal]:
|
| 304 |
+
item_type = (item_type or "services").lower()
|
| 305 |
+
if item_type == "goods":
|
| 306 |
+
return cfg.qty_goods_green, cfg.qty_goods_amber
|
| 307 |
+
return cfg.qty_services_green, cfg.qty_services_amber
|
| 308 |
+
|
| 309 |
+
|
| 310 |
+
def rapidfuzz_best_description_match(
|
| 311 |
+
query: str,
|
| 312 |
+
candidates: list[dict[str, Any]],
|
| 313 |
+
*,
|
| 314 |
+
high_threshold: float = 90.0,
|
| 315 |
+
medium_threshold: float = 80.0,
|
| 316 |
+
score_cutoff: float = 80.0,
|
| 317 |
+
) -> dict[str, Any]:
|
| 318 |
+
"""
|
| 319 |
+
Returns:
|
| 320 |
+
{
|
| 321 |
+
"decision": "matched" | "suggestion" | "unmatched",
|
| 322 |
+
"score_tier": "high" | "medium" | "low" | None,
|
| 323 |
+
"score": float,
|
| 324 |
+
"record_id": int | None
|
| 325 |
+
}
|
| 326 |
+
"""
|
| 327 |
+
if not query or not candidates:
|
| 328 |
+
return {
|
| 329 |
+
"decision": "unmatched",
|
| 330 |
+
"score_tier": None,
|
| 331 |
+
"score": 0.0,
|
| 332 |
+
"record_id": None,
|
| 333 |
+
}
|
| 334 |
+
|
| 335 |
+
best_record_id = None
|
| 336 |
+
best_score = 0.0
|
| 337 |
+
|
| 338 |
+
for cand in candidates:
|
| 339 |
+
record_id = cand.get("record_id")
|
| 340 |
+
names = cand.get("names") or []
|
| 341 |
+
for cand_name in names:
|
| 342 |
+
if not cand_name:
|
| 343 |
+
continue
|
| 344 |
+
score = float(
|
| 345 |
+
fuzz.WRatio(
|
| 346 |
+
query,
|
| 347 |
+
cand_name,
|
| 348 |
+
score_cutoff=score_cutoff,
|
| 349 |
+
)
|
| 350 |
+
)
|
| 351 |
+
if score > best_score:
|
| 352 |
+
best_score = score
|
| 353 |
+
best_record_id = record_id
|
| 354 |
+
|
| 355 |
+
if best_record_id is None or best_score < score_cutoff:
|
| 356 |
+
return {
|
| 357 |
+
"decision": "unmatched",
|
| 358 |
+
"score_tier": None,
|
| 359 |
+
"score": 0.0,
|
| 360 |
+
"record_id": None,
|
| 361 |
+
}
|
| 362 |
+
|
| 363 |
+
if best_score >= high_threshold:
|
| 364 |
+
score_tier = "high"
|
| 365 |
+
elif best_score >= medium_threshold:
|
| 366 |
+
score_tier = "medium"
|
| 367 |
+
else:
|
| 368 |
+
score_tier = "low"
|
| 369 |
+
|
| 370 |
+
decision = "matched" if score_tier in {"high", "medium"} else "suggestion"
|
| 371 |
+
|
| 372 |
+
return {
|
| 373 |
+
"decision": decision,
|
| 374 |
+
"score_tier": score_tier,
|
| 375 |
+
"score": round(best_score, 2),
|
| 376 |
+
"record_id": best_record_id,
|
| 377 |
+
}
|
| 378 |
+
|
| 379 |
+
|
| 380 |
+
# ============================================================
|
| 381 |
+
# Main 2-way matching function
|
| 382 |
+
# ============================================================
|
| 383 |
+
def run_two_way_match_core(
|
| 384 |
+
invoice: InvoiceHeader,
|
| 385 |
+
inv_lines: list[InvoiceLine],
|
| 386 |
+
po: POHeader,
|
| 387 |
+
po_lines: list[POLine],
|
| 388 |
+
*,
|
| 389 |
+
matching_configs: Optional[dict] = None,
|
| 390 |
+
merge_chain_fn: Optional[Callable[[Optional[int], Optional[int]], bool]] = None,
|
| 391 |
+
lock_candidate_po_lines_fn: Optional[Callable[[list[int]], None]] = None,
|
| 392 |
+
) -> TwoWayMatchResult:
|
| 393 |
+
"""
|
| 394 |
+
Core 2-way matching engine.
|
| 395 |
+
|
| 396 |
+
Implements:
|
| 397 |
+
- entry precondition on invoice.status
|
| 398 |
+
- header checks: currency, vendor merge-chain aware, PO value ceiling advisory
|
| 399 |
+
- Tier 1: SKU exact canonical match
|
| 400 |
+
- Tier 2: RapidFuzz description match
|
| 401 |
+
- Tier 3: GL exact + price tolerance
|
| 402 |
+
- Tier 4: aggregate GL matching over residue
|
| 403 |
+
- variance computation and zones
|
| 404 |
+
- exceptions and advisory flags
|
| 405 |
+
- analytical pool consumption only (no DB writes)
|
| 406 |
+
"""
|
| 407 |
+
|
| 408 |
+
cfg = _cfg_from_dict(matching_configs or {})
|
| 409 |
+
getcontext().prec = cfg.precision
|
| 410 |
+
|
| 411 |
+
result = TwoWayMatchResult()
|
| 412 |
+
result.lines_total = len(inv_lines)
|
| 413 |
+
result.advisory_flags = list(invoice.advisory_flags)
|
| 414 |
+
|
| 415 |
+
invoice_currency = norm_currency(invoice.currency)
|
| 416 |
+
po_currency = norm_currency(po.currency)
|
| 417 |
+
|
| 418 |
+
# ---------------------------------------------------------
|
| 419 |
+
# Entry preconditions
|
| 420 |
+
# ---------------------------------------------------------
|
| 421 |
+
if invoice.status not in {"validated", "matching"}:
|
| 422 |
+
raise ValueError(
|
| 423 |
+
f"Precondition violation: invoice.status={invoice.status!r} "
|
| 424 |
+
f"must be in ('validated', 'matching')."
|
| 425 |
+
)
|
| 426 |
+
|
| 427 |
+
# ---------------------------------------------------------
|
| 428 |
+
# Header Check 1: Currency
|
| 429 |
+
# ---------------------------------------------------------
|
| 430 |
+
currency_ok = True
|
| 431 |
+
if invoice_currency is None:
|
| 432 |
+
currency_ok = False
|
| 433 |
+
result.skipped_steps.append({"step": "currency_check", "skip_reason": "ef_currency_null"})
|
| 434 |
+
elif po_currency is None or invoice_currency != po_currency:
|
| 435 |
+
currency_ok = False
|
| 436 |
+
result.header_exceptions.append(
|
| 437 |
+
MatchExceptionRecord(
|
| 438 |
+
exception_type="currency_mismatch",
|
| 439 |
+
exception_detail={
|
| 440 |
+
"invoice_currency": invoice_currency,
|
| 441 |
+
"po_currency": po_currency,
|
| 442 |
+
},
|
| 443 |
+
)
|
| 444 |
+
)
|
| 445 |
+
|
| 446 |
+
# ---------------------------------------------------------
|
| 447 |
+
# Header Check 2: Vendor (merge-chain aware)
|
| 448 |
+
# ---------------------------------------------------------
|
| 449 |
+
vendor_ok = same_vendor_merge_chain(invoice.vendor_id, po.vendor_id, merge_chain_fn)
|
| 450 |
+
if not vendor_ok:
|
| 451 |
+
result.header_exceptions.append(
|
| 452 |
+
MatchExceptionRecord(
|
| 453 |
+
exception_type="vendor_mismatch",
|
| 454 |
+
exception_detail={
|
| 455 |
+
"invoice_vendor_id": invoice.vendor_id,
|
| 456 |
+
"po_vendor_id": po.vendor_id,
|
| 457 |
+
"merge_chain_aware": bool(merge_chain_fn is not None),
|
| 458 |
+
},
|
| 459 |
+
)
|
| 460 |
+
)
|
| 461 |
+
|
| 462 |
+
# ---------------------------------------------------------
|
| 463 |
+
# Header Check 3: PO value ceiling (advisory only)
|
| 464 |
+
# ---------------------------------------------------------
|
| 465 |
+
if not currency_ok:
|
| 466 |
+
result.skipped_steps.append({"step": "po_value_ceiling", "skip_reason": "currency_mismatch_or_null"})
|
| 467 |
+
elif invoice.total_amount is not None:
|
| 468 |
+
rem_value = remaining_po_value(po_lines)
|
| 469 |
+
if invoice.total_amount > rem_value:
|
| 470 |
+
result.advisory_flags.append("exceeds_remaining_po_value_at_match")
|
| 471 |
+
|
| 472 |
+
# ---------------------------------------------------------
|
| 473 |
+
# Build matching pool
|
| 474 |
+
# ---------------------------------------------------------
|
| 475 |
+
pool: list[POLine] = [
|
| 476 |
+
pl for pl in po_lines
|
| 477 |
+
if pl.deleted_at is None and pl.status in {"open", "fully_invoiced"}
|
| 478 |
+
]
|
| 479 |
+
|
| 480 |
+
# Optional lock hook for caller-side DB advisory locks
|
| 481 |
+
if lock_candidate_po_lines_fn is not None:
|
| 482 |
+
lock_candidate_po_lines_fn([pl.id for pl in sorted(pool, key=lambda x: x.id)])
|
| 483 |
+
|
| 484 |
+
consumed_po_line_ids: set[int] = set()
|
| 485 |
+
|
| 486 |
+
def pool_remaining() -> list[POLine]:
|
| 487 |
+
return [pl for pl in pool if pl.id not in consumed_po_line_ids]
|
| 488 |
+
|
| 489 |
+
def consume_po_line(po_line_id: int) -> None:
|
| 490 |
+
consumed_po_line_ids.add(po_line_id)
|
| 491 |
+
|
| 492 |
+
# ---------------------------------------------------------
|
| 493 |
+
# Tier 1-3 per-line waterfall
|
| 494 |
+
# ---------------------------------------------------------
|
| 495 |
+
residue_lines: list[InvoiceLine] = []
|
| 496 |
+
line_exception_map: dict[int, list[MatchExceptionRecord]] = {}
|
| 497 |
+
item_type_unresolved_flagged = False
|
| 498 |
+
|
| 499 |
+
def add_line_exception(line_id: int, exc: MatchExceptionRecord) -> None:
|
| 500 |
+
line_exception_map.setdefault(line_id, []).append(exc)
|
| 501 |
+
result.match_exceptions.append(exc)
|
| 502 |
+
|
| 503 |
+
for inv_line in inv_lines:
|
| 504 |
+
inv_sku_canon = inv_line.sku_canonical or canonicalise_identifier(inv_line.sku)
|
| 505 |
+
inv_desc_canon = inv_line.description_canonical or canonicalise_identifier(inv_line.description)
|
| 506 |
+
|
| 507 |
+
matched_this_line = False
|
| 508 |
+
|
| 509 |
+
# -------------------------
|
| 510 |
+
# Tier 1: SKU exact match
|
| 511 |
+
# -------------------------
|
| 512 |
+
if inv_sku_canon:
|
| 513 |
+
candidates = [
|
| 514 |
+
pl for pl in pool_remaining()
|
| 515 |
+
if (pl.sku_canonical or canonicalise_identifier(pl.sku)) == inv_sku_canon
|
| 516 |
+
]
|
| 517 |
+
candidates.sort(key=lambda x: x.id)
|
| 518 |
+
|
| 519 |
+
if candidates:
|
| 520 |
+
chosen = candidates[0]
|
| 521 |
+
if chosen.item_type is None and not item_type_unresolved_flagged:
|
| 522 |
+
result.advisory_flags.append("item_type_unresolved")
|
| 523 |
+
item_type_unresolved_flagged = True
|
| 524 |
+
matched, line_result = _finalize_tier1_3_line_match(
|
| 525 |
+
inv_line=inv_line,
|
| 526 |
+
po_line=chosen,
|
| 527 |
+
tier=1,
|
| 528 |
+
cfg=cfg,
|
| 529 |
+
)
|
| 530 |
+
if matched:
|
| 531 |
+
result.match_line_results.append(line_result)
|
| 532 |
+
consume_po_line(chosen.id)
|
| 533 |
+
matched_this_line = True
|
| 534 |
+
else:
|
| 535 |
+
add_line_exception(
|
| 536 |
+
inv_line.id,
|
| 537 |
+
MatchExceptionRecord(
|
| 538 |
+
exception_type="po_ceiling_exhausted",
|
| 539 |
+
line_id=inv_line.id,
|
| 540 |
+
po_line_id=chosen.id,
|
| 541 |
+
exception_detail={
|
| 542 |
+
"reason": "available_qty_zero_or_negative",
|
| 543 |
+
"po_line_id": chosen.id,
|
| 544 |
+
},
|
| 545 |
+
),
|
| 546 |
+
)
|
| 547 |
+
matched_this_line = True
|
| 548 |
+
|
| 549 |
+
if matched_this_line:
|
| 550 |
+
continue
|
| 551 |
+
|
| 552 |
+
# -------------------------
|
| 553 |
+
# Tier 2: RapidFuzz description match
|
| 554 |
+
# -------------------------
|
| 555 |
+
pool_now = pool_remaining()
|
| 556 |
+
if inv_desc_canon and pool_now:
|
| 557 |
+
tier2_candidates = [
|
| 558 |
+
{
|
| 559 |
+
"record_id": pl.id,
|
| 560 |
+
"names": [
|
| 561 |
+
pl.description_canonical
|
| 562 |
+
or canonicalise_identifier(pl.description)
|
| 563 |
+
or ""
|
| 564 |
+
],
|
| 565 |
+
}
|
| 566 |
+
for pl in pool_now
|
| 567 |
+
]
|
| 568 |
+
tier2_candidates = [c for c in tier2_candidates if c["names"][0]]
|
| 569 |
+
|
| 570 |
+
if tier2_candidates:
|
| 571 |
+
rf_result = rapidfuzz_best_description_match(
|
| 572 |
+
query=inv_desc_canon,
|
| 573 |
+
candidates=tier2_candidates,
|
| 574 |
+
high_threshold=cfg.fuzzy_high_threshold,
|
| 575 |
+
medium_threshold=cfg.fuzzy_medium_threshold,
|
| 576 |
+
score_cutoff=cfg.fuzzy_score_cutoff,
|
| 577 |
+
)
|
| 578 |
+
|
| 579 |
+
if rf_result["decision"] == "matched" and rf_result["record_id"] is not None:
|
| 580 |
+
chosen = next((pl for pl in pool_now if pl.id == rf_result["record_id"]), None)
|
| 581 |
+
if chosen is not None:
|
| 582 |
+
if chosen.item_type is None and not item_type_unresolved_flagged:
|
| 583 |
+
result.advisory_flags.append("item_type_unresolved")
|
| 584 |
+
item_type_unresolved_flagged = True
|
| 585 |
+
matched, line_result = _finalize_tier1_3_line_match(
|
| 586 |
+
inv_line=inv_line,
|
| 587 |
+
po_line=chosen,
|
| 588 |
+
tier=2,
|
| 589 |
+
cfg=cfg,
|
| 590 |
+
)
|
| 591 |
+
if matched:
|
| 592 |
+
result.match_line_results.append(line_result)
|
| 593 |
+
consume_po_line(chosen.id)
|
| 594 |
+
matched_this_line = True
|
| 595 |
+
else:
|
| 596 |
+
add_line_exception(
|
| 597 |
+
inv_line.id,
|
| 598 |
+
MatchExceptionRecord(
|
| 599 |
+
exception_type="po_ceiling_exhausted",
|
| 600 |
+
line_id=inv_line.id,
|
| 601 |
+
po_line_id=chosen.id,
|
| 602 |
+
exception_detail={
|
| 603 |
+
"reason": "available_qty_zero_or_negative",
|
| 604 |
+
"po_line_id": chosen.id,
|
| 605 |
+
},
|
| 606 |
+
),
|
| 607 |
+
)
|
| 608 |
+
matched_this_line = True
|
| 609 |
+
|
| 610 |
+
if matched_this_line:
|
| 611 |
+
continue
|
| 612 |
+
|
| 613 |
+
# -------------------------
|
| 614 |
+
# Tier 3: GL exact + price tolerance
|
| 615 |
+
# -------------------------
|
| 616 |
+
tier3_candidates: list[POLine] = []
|
| 617 |
+
if inv_line.gl_account is not None:
|
| 618 |
+
for pl in pool_remaining():
|
| 619 |
+
if pl.gl_account is None:
|
| 620 |
+
continue
|
| 621 |
+
if inv_line.gl_account != pl.gl_account:
|
| 622 |
+
continue
|
| 623 |
+
if pl.unit_price is None or pl.unit_price == 0:
|
| 624 |
+
continue
|
| 625 |
+
tier3_candidates.append(pl)
|
| 626 |
+
|
| 627 |
+
tier3_candidates.sort(key=lambda x: x.id)
|
| 628 |
+
|
| 629 |
+
for chosen in tier3_candidates:
|
| 630 |
+
p_var = price_variance_pct(to_decimal(inv_line.unit_price), to_decimal(chosen.unit_price))
|
| 631 |
+
if p_var is None:
|
| 632 |
+
continue
|
| 633 |
+
|
| 634 |
+
if p_var <= (cfg.tier3_price_variance_threshold * D100):
|
| 635 |
+
if chosen.item_type is None and not item_type_unresolved_flagged:
|
| 636 |
+
result.advisory_flags.append("item_type_unresolved")
|
| 637 |
+
item_type_unresolved_flagged = True
|
| 638 |
+
matched, line_result = _finalize_tier1_3_line_match(
|
| 639 |
+
inv_line=inv_line,
|
| 640 |
+
po_line=chosen,
|
| 641 |
+
tier=3,
|
| 642 |
+
cfg=cfg,
|
| 643 |
+
)
|
| 644 |
+
if matched:
|
| 645 |
+
result.match_line_results.append(line_result)
|
| 646 |
+
consume_po_line(chosen.id)
|
| 647 |
+
matched_this_line = True
|
| 648 |
+
else:
|
| 649 |
+
add_line_exception(
|
| 650 |
+
inv_line.id,
|
| 651 |
+
MatchExceptionRecord(
|
| 652 |
+
exception_type="po_ceiling_exhausted",
|
| 653 |
+
line_id=inv_line.id,
|
| 654 |
+
po_line_id=chosen.id,
|
| 655 |
+
exception_detail={
|
| 656 |
+
"reason": "available_qty_zero_or_negative",
|
| 657 |
+
"po_line_id": chosen.id,
|
| 658 |
+
},
|
| 659 |
+
),
|
| 660 |
+
)
|
| 661 |
+
matched_this_line = True
|
| 662 |
+
break
|
| 663 |
+
|
| 664 |
+
if matched_this_line:
|
| 665 |
+
continue
|
| 666 |
+
|
| 667 |
+
# No Tier 1-3 hit
|
| 668 |
+
residue_lines.append(inv_line)
|
| 669 |
+
|
| 670 |
+
# ---------------------------------------------------------
|
| 671 |
+
# Tier 4: Aggregate GL match on residue
|
| 672 |
+
# ---------------------------------------------------------
|
| 673 |
+
residue_lines_by_gl: dict[str, list[InvoiceLine]] = {}
|
| 674 |
+
for line in residue_lines:
|
| 675 |
+
if line.gl_account is None:
|
| 676 |
+
add_line_exception(
|
| 677 |
+
line.id,
|
| 678 |
+
MatchExceptionRecord(
|
| 679 |
+
exception_type="no_match",
|
| 680 |
+
line_id=line.id,
|
| 681 |
+
exception_detail={
|
| 682 |
+
"reason": "gl_account_null_tier4_skip",
|
| 683 |
+
},
|
| 684 |
+
),
|
| 685 |
+
)
|
| 686 |
+
continue
|
| 687 |
+
residue_lines_by_gl.setdefault(line.gl_account, []).append(line)
|
| 688 |
+
|
| 689 |
+
residue_po_lines = pool_remaining()
|
| 690 |
+
po_lines_by_gl: dict[str, list[POLine]] = {}
|
| 691 |
+
for pl in residue_po_lines:
|
| 692 |
+
if pl.gl_account is None:
|
| 693 |
+
continue
|
| 694 |
+
po_lines_by_gl.setdefault(pl.gl_account, []).append(pl)
|
| 695 |
+
|
| 696 |
+
for gl, group_inv_lines in residue_lines_by_gl.items():
|
| 697 |
+
group_po_lines = po_lines_by_gl.get(gl, [])
|
| 698 |
+
if not group_po_lines:
|
| 699 |
+
for il in group_inv_lines:
|
| 700 |
+
add_line_exception(
|
| 701 |
+
il.id,
|
| 702 |
+
MatchExceptionRecord(
|
| 703 |
+
exception_type="no_match",
|
| 704 |
+
line_id=il.id,
|
| 705 |
+
exception_detail={
|
| 706 |
+
"reason": "no_po_lines_in_same_gl_group",
|
| 707 |
+
"gl_account": gl,
|
| 708 |
+
},
|
| 709 |
+
),
|
| 710 |
+
)
|
| 711 |
+
continue
|
| 712 |
+
|
| 713 |
+
group_inv_lines.sort(key=lambda x: x.id)
|
| 714 |
+
group_po_lines.sort(key=lambda x: x.id)
|
| 715 |
+
|
| 716 |
+
# Tier 4 group cap
|
| 717 |
+
if len(group_inv_lines) > cfg.tier4_gl_group_max_lines:
|
| 718 |
+
for il in group_inv_lines:
|
| 719 |
+
add_line_exception(
|
| 720 |
+
il.id,
|
| 721 |
+
MatchExceptionRecord(
|
| 722 |
+
exception_type="no_match",
|
| 723 |
+
line_id=il.id,
|
| 724 |
+
exception_detail={
|
| 725 |
+
"reason": "tier4_group_cap_exceeded",
|
| 726 |
+
"group_gl_account": gl,
|
| 727 |
+
"group_line_count": len(group_inv_lines),
|
| 728 |
+
"cap": cfg.tier4_gl_group_max_lines,
|
| 729 |
+
},
|
| 730 |
+
),
|
| 731 |
+
)
|
| 732 |
+
continue
|
| 733 |
+
|
| 734 |
+
# Invoice amount sum
|
| 735 |
+
if all(il.amount is None for il in group_inv_lines):
|
| 736 |
+
for il in group_inv_lines:
|
| 737 |
+
add_line_exception(
|
| 738 |
+
il.id,
|
| 739 |
+
MatchExceptionRecord(
|
| 740 |
+
exception_type="no_match",
|
| 741 |
+
line_id=il.id,
|
| 742 |
+
exception_detail={
|
| 743 |
+
"reason": "all_invoice_amounts_null",
|
| 744 |
+
"group_gl_account": gl,
|
| 745 |
+
},
|
| 746 |
+
),
|
| 747 |
+
)
|
| 748 |
+
continue
|
| 749 |
+
|
| 750 |
+
invoice_sum = sum((il.amount if il.amount is not None else D0 for il in group_inv_lines), D0)
|
| 751 |
+
|
| 752 |
+
# PO group sum — SDD §4.3: status='open' only.
|
| 753 |
+
# any_goods checks all lines (fully_invoiced included) because goods-first is a
|
| 754 |
+
# type-classification rule, not an availability rule.
|
| 755 |
+
open_group_po_lines = [pl for pl in group_po_lines if pl.status == "open"]
|
| 756 |
+
po_group_sum = D0
|
| 757 |
+
any_goods = False
|
| 758 |
+
for pl in group_po_lines:
|
| 759 |
+
if (pl.item_type or "").lower() == "goods":
|
| 760 |
+
any_goods = True
|
| 761 |
+
for pl in open_group_po_lines:
|
| 762 |
+
rem_qty = available_qty(pl)
|
| 763 |
+
up = to_decimal(pl.unit_price)
|
| 764 |
+
if up is None:
|
| 765 |
+
continue
|
| 766 |
+
po_group_sum += rem_qty * up
|
| 767 |
+
|
| 768 |
+
if any(pl.item_type is None for pl in group_po_lines) and not item_type_unresolved_flagged:
|
| 769 |
+
result.advisory_flags.append("item_type_unresolved")
|
| 770 |
+
item_type_unresolved_flagged = True
|
| 771 |
+
|
| 772 |
+
if po_group_sum == 0:
|
| 773 |
+
all_zero_price = (
|
| 774 |
+
bool(open_group_po_lines)
|
| 775 |
+
and all((to_decimal(pl.unit_price) or D0) == D0 for pl in open_group_po_lines)
|
| 776 |
+
)
|
| 777 |
+
exc_type = "zero_price_po_line_at_match" if all_zero_price else "no_match"
|
| 778 |
+
for il in group_inv_lines:
|
| 779 |
+
add_line_exception(
|
| 780 |
+
il.id,
|
| 781 |
+
MatchExceptionRecord(
|
| 782 |
+
exception_type=exc_type,
|
| 783 |
+
line_id=il.id,
|
| 784 |
+
exception_detail={
|
| 785 |
+
"reason": "po_group_sum_zero",
|
| 786 |
+
"group_gl_account": gl,
|
| 787 |
+
"po_line_ids": [pl.id for pl in group_po_lines],
|
| 788 |
+
},
|
| 789 |
+
),
|
| 790 |
+
)
|
| 791 |
+
continue
|
| 792 |
+
|
| 793 |
+
group_variance_pct = (abs(invoice_sum - po_group_sum) / po_group_sum * D100).quantize(Decimal("0.0001"))
|
| 794 |
+
group_item_type = "goods" if any_goods else "services"
|
| 795 |
+
|
| 796 |
+
# Quantity sanity (before accepting Tier 4)
|
| 797 |
+
inv_qty_sum = sum((to_decimal(il.quantity) or D0 for il in group_inv_lines), D0)
|
| 798 |
+
po_available_qty_sum = sum((available_qty(pl) for pl in group_po_lines), D0)
|
| 799 |
+
|
| 800 |
+
if inv_qty_sum > 0 and po_available_qty_sum > 0:
|
| 801 |
+
qty_var = qty_over_pct(inv_qty_sum, po_available_qty_sum)
|
| 802 |
+
if qty_var is not None:
|
| 803 |
+
_, qty_amber = _qty_bands(group_item_type, cfg)
|
| 804 |
+
if qty_var > qty_amber:
|
| 805 |
+
for il in group_inv_lines:
|
| 806 |
+
add_line_exception(
|
| 807 |
+
il.id,
|
| 808 |
+
MatchExceptionRecord(
|
| 809 |
+
exception_type="no_match",
|
| 810 |
+
line_id=il.id,
|
| 811 |
+
exception_detail={
|
| 812 |
+
"reason": "tier4_qty_sanity_failed",
|
| 813 |
+
"group_gl_account": gl,
|
| 814 |
+
"inv_qty_sum": str(inv_qty_sum),
|
| 815 |
+
"po_available_qty_sum": str(po_available_qty_sum),
|
| 816 |
+
"qty_over_pct": str(qty_var),
|
| 817 |
+
},
|
| 818 |
+
),
|
| 819 |
+
)
|
| 820 |
+
continue
|
| 821 |
+
|
| 822 |
+
# Price tolerance for Tier 4
|
| 823 |
+
price_green, price_amber = _price_bands(group_item_type, cfg)
|
| 824 |
+
if group_variance_pct <= price_amber:
|
| 825 |
+
# Match the whole group and persist deterministic proportional allocations.
|
| 826 |
+
for il in group_inv_lines:
|
| 827 |
+
il_amount = il.amount if il.amount is not None else D0
|
| 828 |
+
|
| 829 |
+
raw_allocations: list[tuple[POLine, Decimal]] = []
|
| 830 |
+
for pl in open_group_po_lines:
|
| 831 |
+
rem_qty = available_qty(pl)
|
| 832 |
+
up = to_decimal(pl.unit_price) or D0
|
| 833 |
+
allocated_po_amount = rem_qty * up
|
| 834 |
+
raw_allocations.append((pl, allocated_po_amount))
|
| 835 |
+
|
| 836 |
+
po_sum = sum((amt for _, amt in raw_allocations), D0)
|
| 837 |
+
if po_sum == 0:
|
| 838 |
+
add_line_exception(
|
| 839 |
+
il.id,
|
| 840 |
+
MatchExceptionRecord(
|
| 841 |
+
exception_type="zero_price_po_line_at_match",
|
| 842 |
+
line_id=il.id,
|
| 843 |
+
exception_detail={
|
| 844 |
+
"reason": "po_sum_zero_in_allocation",
|
| 845 |
+
"group_gl_account": gl,
|
| 846 |
+
"po_line_ids": [pl.id for pl, _ in raw_allocations],
|
| 847 |
+
},
|
| 848 |
+
),
|
| 849 |
+
)
|
| 850 |
+
continue
|
| 851 |
+
|
| 852 |
+
quant = Decimal("0.01")
|
| 853 |
+
alloc_rows: list[tuple[POLine, Decimal, Decimal]] = []
|
| 854 |
+
for pl, allocated_po_amount in raw_allocations:
|
| 855 |
+
allocated_inv_amount = (allocated_po_amount / po_sum * il_amount).quantize(quant)
|
| 856 |
+
alloc_rows.append((pl, allocated_po_amount, allocated_inv_amount))
|
| 857 |
+
|
| 858 |
+
allocated_inv_total = sum((row[2] for row in alloc_rows), D0)
|
| 859 |
+
residue = (il_amount - allocated_inv_total).quantize(quant)
|
| 860 |
+
|
| 861 |
+
if residue != 0:
|
| 862 |
+
# Largest allocated PO amount, tie-break by smallest po_line.id
|
| 863 |
+
target_idx = min(
|
| 864 |
+
range(len(alloc_rows)),
|
| 865 |
+
key=lambda i: (-alloc_rows[i][1], alloc_rows[i][0].id),
|
| 866 |
+
)
|
| 867 |
+
pl, a_po, a_inv = alloc_rows[target_idx]
|
| 868 |
+
alloc_rows[target_idx] = (pl, a_po, (a_inv + residue).quantize(quant))
|
| 869 |
+
|
| 870 |
+
group_line_zone = "green" if group_variance_pct <= price_green else ("amber" if group_variance_pct <= price_amber else "red")
|
| 871 |
+
|
| 872 |
+
for pl, allocated_po_amount, allocated_inv_amount in alloc_rows:
|
| 873 |
+
result.match_line_results.append(
|
| 874 |
+
MatchLineResult(
|
| 875 |
+
invoice_line_id=il.id,
|
| 876 |
+
po_line_id=pl.id,
|
| 877 |
+
match_tier=4,
|
| 878 |
+
allocated_inv_amount=allocated_inv_amount,
|
| 879 |
+
allocated_po_amount=allocated_po_amount.quantize(Decimal("0.01")),
|
| 880 |
+
price_var_pct=group_variance_pct,
|
| 881 |
+
qty_var_pct=None,
|
| 882 |
+
line_zone=group_line_zone,
|
| 883 |
+
line_status="matched",
|
| 884 |
+
match_type="two_way",
|
| 885 |
+
)
|
| 886 |
+
)
|
| 887 |
+
|
| 888 |
+
for pl in group_po_lines:
|
| 889 |
+
consume_po_line(pl.id)
|
| 890 |
+
|
| 891 |
+
continue
|
| 892 |
+
|
| 893 |
+
# Outside tolerance -> no_match for every invoice line in the group
|
| 894 |
+
for il in group_inv_lines:
|
| 895 |
+
add_line_exception(
|
| 896 |
+
il.id,
|
| 897 |
+
MatchExceptionRecord(
|
| 898 |
+
exception_type="no_match",
|
| 899 |
+
line_id=il.id,
|
| 900 |
+
exception_detail={
|
| 901 |
+
"reason": "tier4_outside_tolerance",
|
| 902 |
+
"group_gl_account": gl,
|
| 903 |
+
"invoice_sum": str(invoice_sum),
|
| 904 |
+
"po_sum": str(po_group_sum),
|
| 905 |
+
"variance_pct": str(group_variance_pct),
|
| 906 |
+
"po_line_ids": [pl.id for pl in group_po_lines],
|
| 907 |
+
},
|
| 908 |
+
),
|
| 909 |
+
)
|
| 910 |
+
|
| 911 |
+
# ---------------------------------------------------------
|
| 912 |
+
# Final summary
|
| 913 |
+
# ---------------------------------------------------------
|
| 914 |
+
matched_line_ids = {r.invoice_line_id for r in result.match_line_results if r.line_status == "matched"}
|
| 915 |
+
result.lines_matched = len(matched_line_ids)
|
| 916 |
+
|
| 917 |
+
exception_line_ids = set(line_exception_map.keys())
|
| 918 |
+
|
| 919 |
+
# Any invoice line not matched and not explicitly captured is a no_match exception
|
| 920 |
+
for line in inv_lines:
|
| 921 |
+
if line.id not in matched_line_ids and line.id not in exception_line_ids:
|
| 922 |
+
exception_line_ids.add(line.id)
|
| 923 |
+
|
| 924 |
+
result.lines_in_exception = len(exception_line_ids)
|
| 925 |
+
|
| 926 |
+
# Final overall status and zone
|
| 927 |
+
if result.header_exceptions:
|
| 928 |
+
result.overall_status = "exception"
|
| 929 |
+
result.match_zone = "red"
|
| 930 |
+
else:
|
| 931 |
+
any_amber = any(r.line_zone == "amber" for r in result.match_line_results)
|
| 932 |
+
any_red = any(r.line_zone == "red" for r in result.match_line_results)
|
| 933 |
+
|
| 934 |
+
if result.lines_matched == result.lines_total and result.lines_in_exception == 0:
|
| 935 |
+
result.overall_status = "matched"
|
| 936 |
+
result.match_zone = "red" if any_red else ("amber" if any_amber else "green")
|
| 937 |
+
elif result.lines_matched > 0:
|
| 938 |
+
result.overall_status = "partial_match"
|
| 939 |
+
if any_red or result.match_exceptions:
|
| 940 |
+
result.match_zone = "red"
|
| 941 |
+
elif any_amber:
|
| 942 |
+
result.match_zone = "amber"
|
| 943 |
+
else:
|
| 944 |
+
result.match_zone = "green"
|
| 945 |
+
else:
|
| 946 |
+
result.overall_status = "exception"
|
| 947 |
+
result.match_zone = "red"
|
| 948 |
+
|
| 949 |
+
# documents.status is always pending_approval after task_match
|
| 950 |
+
result.documents_status = "pending_approval"
|
| 951 |
+
result.match_type = "two_way"
|
| 952 |
+
|
| 953 |
+
return result
|
| 954 |
+
|
| 955 |
+
|
| 956 |
+
# ============================================================
|
| 957 |
+
# Internal helper for Tier 1-3 finalization
|
| 958 |
+
# ============================================================
|
| 959 |
+
def _finalize_tier1_3_line_match(
|
| 960 |
+
*,
|
| 961 |
+
inv_line: InvoiceLine,
|
| 962 |
+
po_line: POLine,
|
| 963 |
+
tier: int,
|
| 964 |
+
cfg: TwoWayConfig,
|
| 965 |
+
) -> tuple[bool, MatchLineResult]:
|
| 966 |
+
"""
|
| 967 |
+
Finalize a Tier 1-3 match candidate.
|
| 968 |
+
|
| 969 |
+
Returns:
|
| 970 |
+
(matched_successfully, result_row)
|
| 971 |
+
|
| 972 |
+
If available_qty == 0, the line is blocked with po_ceiling_exhausted.
|
| 973 |
+
"""
|
| 974 |
+
inv_qty = to_decimal(inv_line.quantity)
|
| 975 |
+
inv_price = to_decimal(inv_line.unit_price)
|
| 976 |
+
po_price = to_decimal(po_line.unit_price)
|
| 977 |
+
|
| 978 |
+
avail_qty = available_qty(po_line)
|
| 979 |
+
if avail_qty < 0:
|
| 980 |
+
return False, MatchLineResult(
|
| 981 |
+
invoice_line_id=inv_line.id,
|
| 982 |
+
po_line_id=po_line.id,
|
| 983 |
+
match_tier=tier,
|
| 984 |
+
line_zone="red",
|
| 985 |
+
line_status="exception",
|
| 986 |
+
match_type="two_way",
|
| 987 |
+
)
|
| 988 |
+
|
| 989 |
+
if avail_qty == 0:
|
| 990 |
+
return False, MatchLineResult(
|
| 991 |
+
invoice_line_id=inv_line.id,
|
| 992 |
+
po_line_id=po_line.id,
|
| 993 |
+
match_tier=tier,
|
| 994 |
+
price_var_pct=price_variance_pct(inv_price, po_price),
|
| 995 |
+
qty_var_pct=None,
|
| 996 |
+
line_zone="red",
|
| 997 |
+
line_status="exception",
|
| 998 |
+
match_type="two_way",
|
| 999 |
+
)
|
| 1000 |
+
|
| 1001 |
+
p_var = price_variance_pct(inv_price, po_price)
|
| 1002 |
+
|
| 1003 |
+
if inv_qty is None:
|
| 1004 |
+
q_var = None
|
| 1005 |
+
else:
|
| 1006 |
+
q_var = qty_over_pct(inv_qty, avail_qty)
|
| 1007 |
+
|
| 1008 |
+
item_type = (po_line.item_type or "services").lower()
|
| 1009 |
+
line_zone = zone_from_pct(p_var, q_var, item_type, cfg)
|
| 1010 |
+
|
| 1011 |
+
allocated_inv_amount = inv_line.amount if inv_line.amount is not None else None
|
| 1012 |
+
allocated_po_amount = ((to_decimal(po_line.quantity) or D0) - (to_decimal(po_line.invoiced_qty) or D0)) * (po_price or D0)
|
| 1013 |
+
|
| 1014 |
+
return True, MatchLineResult(
|
| 1015 |
+
invoice_line_id=inv_line.id,
|
| 1016 |
+
po_line_id=po_line.id,
|
| 1017 |
+
match_tier=tier,
|
| 1018 |
+
allocated_inv_amount=allocated_inv_amount,
|
| 1019 |
+
allocated_po_amount=allocated_po_amount.quantize(Decimal("0.01")),
|
| 1020 |
+
price_var_pct=p_var,
|
| 1021 |
+
qty_var_pct=q_var,
|
| 1022 |
+
line_zone=line_zone,
|
| 1023 |
+
line_status="matched",
|
| 1024 |
+
match_type="two_way",
|
| 1025 |
+
)
|