diff --git a/app/external_reconciliation_sync.py b/app/external_reconciliation_sync.py index ac03331..7b57ed6 100644 --- a/app/external_reconciliation_sync.py +++ b/app/external_reconciliation_sync.py @@ -221,7 +221,21 @@ def _looks_like_company_name(value: Any) -> bool: def _external_customer_key(record: Dict[str, Any], *, source_system: str) -> str: 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": return _clean(_first(record, "partner_external_id", "id")) 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]: - 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]]: diff --git a/scripts/repair_jasmin_reconciliation_item.py b/scripts/repair_jasmin_reconciliation_item.py new file mode 100644 index 0000000..c553e68 --- /dev/null +++ b/scripts/repair_jasmin_reconciliation_item.py @@ -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()) diff --git a/tests/test_external_reconciliation_jasmin_mapping.py b/tests/test_external_reconciliation_jasmin_mapping.py new file mode 100644 index 0000000..4a1eaf9 --- /dev/null +++ b/tests/test_external_reconciliation_jasmin_mapping.py @@ -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"