bugfix reconciliation extractor

This commit is contained in:
2026-09-07 20:17:01 -03:00
parent 76c8cdc738
commit 811d4fd175
194 changed files with 1031 additions and 41 deletions

View File

@@ -151,9 +151,26 @@ def _invoice_subject_catalog(args: dict[str, Any]) -> list[dict[str, Any]]:
key = _norm_invoice_name(canonical)
if not key:
return
item = found.setdefault(key, {"name": canonical, "value": value, "category": str(category or "").strip(), "contestable": contestable})
if item.get("value") in (None, "") and value not in (None, ""):
item["value"] = value
item = found.setdefault(key, {
"name": canonical,
"value": None,
"values": [],
"category": str(category or "").strip(),
"contestable": contestable,
})
if value not in (None, ""):
# Preserve every distinct billed value for the canonical subject.
# A subject may occur more than once on the same invoice; in that case
# no single value is authoritative until the user-selected amount is
# validated against this set.
raw = str(value).strip().replace(",", ".")
try:
normalized_value: Any = float(raw)
except (TypeError, ValueError):
normalized_value = str(value).strip()
if normalized_value not in item["values"]:
item["values"].append(normalized_value)
item["value"] = item["values"][0] if len(item["values"]) == 1 else None
if not item.get("category") and category:
item["category"] = str(category).strip()
if item.get("contestable") is None and contestable is not None:
@@ -283,6 +300,13 @@ def _preflight_subject(name: str, args: dict[str, Any]) -> dict[str, Any] | None
subject = canonical
if explicit.get("value") not in (None, ""):
args["resolved_value"] = str(explicit.get("value"))
args["resolved_value_source"] = "invoice_unique_subject_value"
else:
# Multiple billed occurrences for the same subject (or no billed
# value) mean the subject alone cannot resolve an amount. Never
# preserve a stale/user-claimed value as resolved_value.
args.pop("resolved_value", None)
args.pop("resolved_value_source", None)
category = str(explicit.get("category") or "").strip()
if category:
args["resolved_category"] = category
@@ -334,8 +358,21 @@ def _preflight_subject(name: str, args: dict[str, Any]) -> dict[str, Any] | None
args.setdefault("item_msisdn", resolved.msisdn)
if resolved.charge_date:
args.setdefault("charge_date", resolved.charge_date)
if getattr(resolved, "value", None) is not None:
args.setdefault("resolved_value", str(resolved.value))
subject_key = _norm_invoice_name(resolved.canonical_name)
subject_record = next((
item for item in _invoice_subject_catalog(args)
if _norm_invoice_name(item.get("name")) == subject_key
), None)
unique_subject_value = subject_record.get("value") if isinstance(subject_record, dict) else None
if unique_subject_value not in (None, ""):
args["resolved_value"] = str(unique_subject_value)
args["resolved_value_source"] = "invoice_unique_subject_value"
else:
# Do not select one arbitrary occurrence when the same subject has
# multiple billed values. The user-selected value must be checked by
# CVAL against the subject before it can become resolved_value.
args.pop("resolved_value", None)
args.pop("resolved_value_source", None)
# Paridade do backend original: se o cliente pediu cancelamento avulso,
# mas a própria fatura classifica o item como estratégico/bundle, não
# executamos a operação errada. A resolução determinística do domínio
@@ -1040,7 +1077,14 @@ async def _validate_contestation(args: dict[str, Any]) -> dict[str, Any]:
"parameter": "subject",
"reason": "subject_not_resolved",
"subject": args.get("resolved_subject") or args.get("subject"),
"resolved_value": requested_value,
# requested_value is only a candidate while the subject itself is
# unresolved. It must never be promoted to resolved_value.
"candidate_value": requested_value,
"resolved_value": None,
"clear_fields": [
"resolved_subject", "resolved_value", "resolved_category",
"item_msisdn", "charge_date",
],
"validation_log": validation_log,
"items": validated,
"metadata": {
@@ -1082,7 +1126,14 @@ async def _validate_contestation(args: dict[str, Any]) -> dict[str, Any]:
"reason": "CVAL",
"recoverable_reason": "amount_not_supported_by_invoice",
"subject": canonical_subject,
"resolved_value": billed,
# The claimed value was rejected. Keep it only as diagnostic
# candidate data. The billed value may be shown to the user but
# is not persisted as the selected resolved_value until the next
# candidate is validated relationally against this subject.
"candidate_value": requested_value,
"billed_value": billed,
"resolved_value": None,
"clear_fields": ["resolved_value"],
"parameter_message": message,
"validation_log": validation_log,
"items": validated,
@@ -1105,14 +1156,25 @@ async def _validate_contestation(args: dict[str, Any]) -> dict[str, Any]:
"items": validated,
"metadata": {"side_effect_free": True, "target_tool": args.get("target_tool") or "contestar_cobranca", "guardrail_code": "CVAL"},
}
canonical_subject = str(args.get("resolved_subject") or args.get("subject") or "").strip()
return {
"eligible": True,
"status": "ELIGIBLE",
"subject": args.get("resolved_subject") or args.get("subject"),
"subject": canonical_subject,
# Only the successful subject+amount CVAL relationship promotes the
# candidate into resolved_value.
"resolved_value": requested_value,
"category": args.get("resolved_category"),
"validation_log": validation_log,
"items": validated,
"transaction_decision": {
"resolved_arguments": {
"subject": canonical_subject,
"resolved_subject": canonical_subject,
"resolved_value": requested_value,
"resolved_value_source": "contestation_cval_subject_amount",
}
},
"metadata": {"side_effect_free": True, "target_tool": args.get("target_tool") or "contestar_cobranca", "guardrail_code": "CVAL"},
}