#!/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())