Fix Jasmin customer key and amount mapping
This commit is contained in:
@@ -221,7 +221,21 @@ def _looks_like_company_name(value: Any) -> bool:
|
|||||||
|
|
||||||
def _external_customer_key(record: Dict[str, Any], *, source_system: str) -> str:
|
def _external_customer_key(record: Dict[str, Any], *, source_system: str) -> str:
|
||||||
if source_system == "jasmin":
|
if source_system == "jasmin":
|
||||||
return _clean(_first(record, "partyKey", "customerPartyKey", "naturalKey", "key", "id"))
|
# A document naturalKey identifies the commercial document
|
||||||
|
# (for example ORC.ORC2026.136), not the customer. Prefer the
|
||||||
|
# customer party code exposed by Jasmin and never fall back to the
|
||||||
|
# document naturalKey when seeding/updating a fiscal customer.
|
||||||
|
return _clean(
|
||||||
|
_first(
|
||||||
|
record,
|
||||||
|
"partyKey",
|
||||||
|
"customerPartyKey",
|
||||||
|
"buyerCustomerParty",
|
||||||
|
"accountingParty",
|
||||||
|
"buyerCustomerPartyKey",
|
||||||
|
"accountingPartyKey",
|
||||||
|
)
|
||||||
|
)
|
||||||
if source_system == "odoo":
|
if source_system == "odoo":
|
||||||
return _clean(_first(record, "partner_external_id", "id"))
|
return _clean(_first(record, "partner_external_id", "id"))
|
||||||
return _clean(_first(record, "id", "key", "externalId"))
|
return _clean(_first(record, "id", "key", "externalId"))
|
||||||
@@ -355,7 +369,36 @@ def _jasmin_external_type(record: Dict[str, Any], default_type: str) -> str:
|
|||||||
|
|
||||||
|
|
||||||
def _jasmin_amount(record: Dict[str, Any]) -> Optional[str]:
|
def _jasmin_amount(record: Dict[str, Any]) -> Optional[str]:
|
||||||
return _decimal_or_none(_first(record, "payableAmount", "totalAmount", "total", "grossAmount", "amount"))
|
# Recent Jasmin payloads expose both flattened numeric fields and nested
|
||||||
|
# money objects. Prefer the payable total including tax.
|
||||||
|
direct = _first(
|
||||||
|
record,
|
||||||
|
"payableAmountAmount",
|
||||||
|
"totalAmount",
|
||||||
|
"grossValueAmount",
|
||||||
|
"taxExclusiveAmountAmount",
|
||||||
|
"total",
|
||||||
|
"grossAmount",
|
||||||
|
"amount",
|
||||||
|
)
|
||||||
|
if direct not in (None, ""):
|
||||||
|
parsed = _decimal_or_none(direct)
|
||||||
|
if parsed is not None:
|
||||||
|
return parsed
|
||||||
|
|
||||||
|
for key in ("payableAmount", "grossValue", "taxExclusiveAmount"):
|
||||||
|
money = record.get(key)
|
||||||
|
if isinstance(money, dict):
|
||||||
|
parsed = _decimal_or_none(
|
||||||
|
_first(money, "amount", "baseAmount", "reportingAmount")
|
||||||
|
)
|
||||||
|
if parsed is not None:
|
||||||
|
return parsed
|
||||||
|
elif money not in (None, ""):
|
||||||
|
parsed = _decimal_or_none(money)
|
||||||
|
if parsed is not None:
|
||||||
|
return parsed
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
def _jasmin_candidate_from_record(record: Dict[str, Any], *, default_type: str) -> Optional[Dict[str, Any]]:
|
def _jasmin_candidate_from_record(record: Dict[str, Any], *, default_type: str) -> Optional[Dict[str, Any]]:
|
||||||
|
|||||||
157
scripts/repair_jasmin_reconciliation_item.py
Normal file
157
scripts/repair_jasmin_reconciliation_item.py
Normal file
@@ -0,0 +1,157 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Repair a Jasmin reconciliation item affected by incorrect customer mapping.
|
||||||
|
|
||||||
|
Dry-run is the default. Use --apply only after reviewing the printed plan.
|
||||||
|
This script does not create/link opportunities and does not resolve the item.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
import sys
|
||||||
|
from decimal import Decimal, InvalidOperation
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from sqlalchemy import text
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parents[1]
|
||||||
|
if str(ROOT) not in sys.path:
|
||||||
|
sys.path.insert(0, str(ROOT))
|
||||||
|
|
||||||
|
from app.db import engine
|
||||||
|
|
||||||
|
|
||||||
|
def _clean(value: object) -> str:
|
||||||
|
return str(value or "").strip()
|
||||||
|
|
||||||
|
|
||||||
|
def _money(record: dict) -> Decimal | None:
|
||||||
|
values = [
|
||||||
|
record.get("payableAmountAmount"),
|
||||||
|
(record.get("payableAmount") or {}).get("amount")
|
||||||
|
if isinstance(record.get("payableAmount"), dict)
|
||||||
|
else record.get("payableAmount"),
|
||||||
|
record.get("grossValueAmount"),
|
||||||
|
]
|
||||||
|
for value in values:
|
||||||
|
if value in (None, ""):
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
return Decimal(str(value)).quantize(Decimal("0.01"))
|
||||||
|
except (InvalidOperation, ValueError):
|
||||||
|
continue
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
parser = argparse.ArgumentParser()
|
||||||
|
parser.add_argument("document_number", help="Ex.: ORC.ORC2026.136")
|
||||||
|
parser.add_argument("--apply", action="store_true", help="Aplicar a reparação; sem esta flag faz dry-run")
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
with engine.begin() as conn:
|
||||||
|
item = conn.execute(
|
||||||
|
text("""
|
||||||
|
SELECT ri.id::text, ri.status, ri.opportunity_id::text,
|
||||||
|
ri.customer_id::text, ri.customer_tax_id,
|
||||||
|
ri.document_number, ri.amount, ri.payload,
|
||||||
|
c.name AS customer_name, c.tax_id,
|
||||||
|
c.jasmin_customer_party_key
|
||||||
|
FROM reconciliation_items ri
|
||||||
|
LEFT JOIN customers c ON c.id = ri.customer_id
|
||||||
|
WHERE ri.document_number = :document_number
|
||||||
|
FOR UPDATE OF ri
|
||||||
|
"""),
|
||||||
|
{"document_number": args.document_number},
|
||||||
|
).mappings().first()
|
||||||
|
|
||||||
|
if not item:
|
||||||
|
raise SystemExit(f"Documento não encontrado: {args.document_number}")
|
||||||
|
if not item["customer_id"]:
|
||||||
|
raise SystemExit("O item não tem customer_id; reparação automática recusada")
|
||||||
|
|
||||||
|
payload = item["payload"] or {}
|
||||||
|
record = payload.get("record") if isinstance(payload, dict) else None
|
||||||
|
if not isinstance(record, dict):
|
||||||
|
raise SystemExit("payload.record não existe ou não é um objeto")
|
||||||
|
|
||||||
|
party_key = _clean(record.get("buyerCustomerParty") or record.get("accountingParty"))
|
||||||
|
payload_tax_id = _clean(record.get("buyerCustomerPartyTaxId") or record.get("accountingPartyTaxId"))
|
||||||
|
amount = _money(record)
|
||||||
|
|
||||||
|
if not party_key:
|
||||||
|
raise SystemExit("Não foi possível obter buyerCustomerParty/accountingParty")
|
||||||
|
if payload_tax_id and _clean(item["tax_id"]) and payload_tax_id != _clean(item["tax_id"]):
|
||||||
|
raise SystemExit(
|
||||||
|
f"NIF divergente: cliente={item['tax_id']} payload={payload_tax_id}; reparação recusada"
|
||||||
|
)
|
||||||
|
|
||||||
|
conflict = conn.execute(
|
||||||
|
text("""
|
||||||
|
SELECT id::text, name, tax_id
|
||||||
|
FROM customers
|
||||||
|
WHERE jasmin_customer_party_key = :party_key
|
||||||
|
AND id <> CAST(:customer_id AS UUID)
|
||||||
|
LIMIT 1
|
||||||
|
"""),
|
||||||
|
{"party_key": party_key, "customer_id": item["customer_id"]},
|
||||||
|
).mappings().first()
|
||||||
|
if conflict:
|
||||||
|
raise SystemExit(
|
||||||
|
"Party key já pertence a outro cliente: "
|
||||||
|
+ json.dumps(dict(conflict), ensure_ascii=False, default=str)
|
||||||
|
)
|
||||||
|
|
||||||
|
plan = {
|
||||||
|
"mode": "apply" if args.apply else "dry-run",
|
||||||
|
"document_number": item["document_number"],
|
||||||
|
"reconciliation_item_id": item["id"],
|
||||||
|
"customer_id": item["customer_id"],
|
||||||
|
"customer_name": item["customer_name"],
|
||||||
|
"tax_id": item["tax_id"],
|
||||||
|
"party_key_before": item["jasmin_customer_party_key"],
|
||||||
|
"party_key_after": party_key,
|
||||||
|
"amount_before": str(item["amount"]) if item["amount"] is not None else None,
|
||||||
|
"amount_after": str(amount) if amount is not None else None,
|
||||||
|
"status_unchanged": item["status"],
|
||||||
|
"opportunity_id_unchanged": item["opportunity_id"],
|
||||||
|
}
|
||||||
|
print(json.dumps(plan, ensure_ascii=False, indent=2, default=str))
|
||||||
|
|
||||||
|
if not args.apply:
|
||||||
|
conn.rollback()
|
||||||
|
print("DRY-RUN: nenhuma alteração aplicada.")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
conn.execute(
|
||||||
|
text("""
|
||||||
|
UPDATE customers
|
||||||
|
SET jasmin_customer_party_key = :party_key,
|
||||||
|
metadata = jsonb_set(
|
||||||
|
COALESCE(metadata, '{}'::jsonb),
|
||||||
|
'{external_customer_key}',
|
||||||
|
to_jsonb(CAST(:party_key AS text)),
|
||||||
|
true
|
||||||
|
),
|
||||||
|
updated_at = now()
|
||||||
|
WHERE id = CAST(:customer_id AS UUID)
|
||||||
|
"""),
|
||||||
|
{"party_key": party_key, "customer_id": item["customer_id"]},
|
||||||
|
)
|
||||||
|
if amount is not None:
|
||||||
|
conn.execute(
|
||||||
|
text("""
|
||||||
|
UPDATE reconciliation_items
|
||||||
|
SET amount = :amount,
|
||||||
|
updated_at = now()
|
||||||
|
WHERE id = CAST(:item_id AS UUID)
|
||||||
|
"""),
|
||||||
|
{"amount": amount, "item_id": item["id"]},
|
||||||
|
)
|
||||||
|
|
||||||
|
print("Reparação aplicada. O item permanece aberto e sem opportunity_id.")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
38
tests/test_external_reconciliation_jasmin_mapping.py
Normal file
38
tests/test_external_reconciliation_jasmin_mapping.py
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
from app.external_reconciliation_sync import _external_customer_key, _jasmin_amount
|
||||||
|
|
||||||
|
|
||||||
|
def test_jasmin_customer_key_prefers_party_code_over_document_natural_key():
|
||||||
|
record = {
|
||||||
|
"naturalKey": "ORC.ORC2026.136",
|
||||||
|
"buyerCustomerParty": "0569",
|
||||||
|
"accountingParty": "0569",
|
||||||
|
}
|
||||||
|
|
||||||
|
assert _external_customer_key(record, source_system="jasmin") == "0569"
|
||||||
|
|
||||||
|
|
||||||
|
def test_jasmin_customer_key_does_not_use_document_natural_key():
|
||||||
|
record = {"naturalKey": "ORC.ORC2026.136", "id": "document-uuid"}
|
||||||
|
|
||||||
|
assert _external_customer_key(record, source_system="jasmin") == ""
|
||||||
|
|
||||||
|
|
||||||
|
def test_jasmin_amount_reads_flattened_payable_total():
|
||||||
|
record = {
|
||||||
|
"payableAmountAmount": 441.57,
|
||||||
|
"grossValueAmount": 359.00,
|
||||||
|
}
|
||||||
|
|
||||||
|
assert _jasmin_amount(record) == "441.57"
|
||||||
|
|
||||||
|
|
||||||
|
def test_jasmin_amount_reads_nested_money_object():
|
||||||
|
record = {
|
||||||
|
"payableAmount": {
|
||||||
|
"amount": 441.57,
|
||||||
|
"baseAmount": 441.57,
|
||||||
|
"reportingAmount": 441.57,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
assert _jasmin_amount(record) == "441.57"
|
||||||
Reference in New Issue
Block a user