#!/usr/bin/env python3 """Backfill Jasmin document details into existing opportunities. Use when an opportunity was created from a Jasmin reconciliation item before v4926.6, so it still has no commercial_documents/opportunity_items/value. Examples: PYTHONPATH=. python scripts/backfill_jasmin_opportunity_details.py \ --opportunity-id a4e210f5-b870-48b1-882d-b55c71fbfd38 PYTHONPATH=. python scripts/backfill_jasmin_opportunity_details.py \ --document-number ORC.ORC2026.156 """ from __future__ import annotations import argparse import asyncio import json import sys from decimal import Decimal, InvalidOperation from typing import Any, Dict, Iterable, List, Optional from sqlalchemy import text from app.db import engine from app.reconciliation_service import ( _apply_jasmin_documents_to_opportunity, # noqa: PLC2701 - deliberate operator backfill script _jasmin_document_lines_from_item, # noqa: PLC2701 _jasmin_document_totals, # noqa: PLC2701 _payload_record, # noqa: PLC2701 ) def _as_text(value: Any) -> str: return str(value or "").strip() def _money_value(value: Any) -> Any: if isinstance(value, dict): for key in ("amount", "baseAmount", "reportingAmount", "value"): if value.get(key) not in (None, ""): return value.get(key) return None return value def _decimal_or_none(value: Any) -> Optional[str]: value = _money_value(value) if value in (None, ""): return None try: return str(Decimal(str(value).replace(",", ".")).quantize(Decimal("0.01"))) except (InvalidOperation, ValueError): return None def _json(value: Any) -> str: return json.dumps(value, ensure_ascii=False, default=str) def _ids_from_metadata(metadata: Any) -> List[str]: if not isinstance(metadata, dict): return [] ids: List[str] = [] for key in ("created_from_reconciliation_item_id", "reconciliation_item_id"): value = metadata.get(key) if value: ids.append(str(value)) for key in ("item_ids", "reconciliation_item_ids"): value = metadata.get(key) if isinstance(value, list): ids.extend(str(v) for v in value if v) return list(dict.fromkeys(ids)) def _load_opportunity(opportunity_id: Optional[str], document_number: Optional[str]) -> Optional[Dict[str, Any]]: with engine.begin() as conn: if opportunity_id: row = conn.execute(text(""" SELECT id::text, title, value_amount, product_interest, local_customer_id::text, customer_name, customer_email, metadata FROM opportunities WHERE id = CAST(:id AS UUID) LIMIT 1 """), {"id": opportunity_id}).mappings().first() return dict(row) if row else None if document_number: row = conn.execute(text(""" SELECT id::text, title, value_amount, product_interest, local_customer_id::text, customer_name, customer_email, metadata FROM opportunities WHERE metadata->>'document_number' = :document_number OR title ILIKE '%' || :document_number || '%' ORDER BY updated_at DESC LIMIT 1 """), {"document_number": document_number}).mappings().first() return dict(row) if row else None return None def _load_candidate_items(opportunity: Dict[str, Any]) -> List[Dict[str, Any]]: metadata = opportunity.get("metadata") if isinstance(opportunity.get("metadata"), dict) else {} ids = _ids_from_metadata(metadata) external_id = _as_text(metadata.get("external_id")) document_number = _as_text(metadata.get("document_number")) opportunity_id = _as_text(opportunity.get("id")) with engine.begin() as conn: rows = conn.execute(text(""" SELECT id::text, source_system, external_type, external_id, title, description, status, priority, suggested_action, confidence, opportunity_id::text, customer_id::text, customer_name, customer_email, customer_tax_id, document_number, document_date, amount, currency, payload, resolution_note, created_at, updated_at, resolved_at FROM reconciliation_items WHERE source_system = 'jasmin' AND ( opportunity_id = CAST(:opportunity_id AS UUID) OR (CAST(:ids AS TEXT[]) IS NOT NULL AND id::text = ANY(CAST(:ids AS TEXT[]))) OR (CAST(:external_id AS TEXT) <> '' AND external_id = CAST(:external_id AS TEXT)) OR (CAST(:document_number AS TEXT) <> '' AND document_number = CAST(:document_number AS TEXT)) OR (CAST(:document_number AS TEXT) <> '' AND payload::text ILIKE '%' || CAST(:document_number AS TEXT) || '%') ) ORDER BY updated_at DESC, created_at DESC """), { "opportunity_id": opportunity_id, "ids": ids or [], "external_id": external_id, "document_number": document_number, }).mappings().all() # De-duplicate while keeping recency order. seen = set() result = [] for row in rows: item = dict(row) item_id = item.get("id") if item_id in seen: continue seen.add(item_id) result.append(item) return result async def _fetch_jasmin_detail_async(item: Dict[str, Any]) -> Optional[Dict[str, Any]]: external_type = _as_text(item.get("external_type")) external_id = _as_text(item.get("external_id")) if not external_id: return None from app.jasmin_client import JasminClient client = JasminClient() if external_type == "jasmin_quotation": return await client.get_quotation(external_id) if external_type == "jasmin_invoice": return await client.get_invoice(external_id) # Some tenants represent pro-forma as a quotation. Try quotation detail as a # conservative fallback when the external id is present. if external_type == "jasmin_proforma": try: return await client.get_quotation(external_id) except Exception: return None return None def _with_jasmin_detail(item: Dict[str, Any], *, fetch_detail: bool) -> Dict[str, Any]: if not fetch_detail: return item existing_lines = _jasmin_document_lines_from_item(item) if existing_lines: return item try: detail = asyncio.run(_fetch_jasmin_detail_async(item)) except Exception as exc: item = dict(item) payload = item.get("payload") if isinstance(item.get("payload"), dict) else {} item["payload"] = { **payload, "detail_fetch_error": f"{type(exc).__name__}: {exc}", } return item if not isinstance(detail, dict): return item payload = item.get("payload") if isinstance(item.get("payload"), dict) else {} enriched = dict(item) enriched["payload"] = { **payload, "record": detail, "detail_source": "jasmin_api", "previous_record": payload.get("record"), } # Fill top-level fields if the detailed document exposes them only there. record_number = detail.get("documentNumber") or detail.get("naturalKey") or detail.get("number") if record_number and not enriched.get("document_number"): enriched["document_number"] = record_number total = ( detail.get("payableAmount") or detail.get("totalAmount") or detail.get("grossAmount") or detail.get("amount") ) if total and not enriched.get("amount"): enriched["amount"] = _decimal_or_none(total) or total return enriched def _summary_for_items(items: Iterable[Dict[str, Any]]) -> List[Dict[str, Any]]: result = [] for item in items: totals = _jasmin_document_totals(item) lines = _jasmin_document_lines_from_item(item) result.append({ "id": item.get("id"), "external_type": item.get("external_type"), "external_id": item.get("external_id"), "document_number": item.get("document_number"), "amount": item.get("amount"), "totals": totals, "lines": len(lines), "payload_keys": list((item.get("payload") or {}).keys()) if isinstance(item.get("payload"), dict) else [], }) return result def _post_import_summary(opportunity_id: str) -> Dict[str, Any]: with engine.begin() as conn: opportunity = conn.execute(text(""" SELECT id::text, title, value_amount, product_interest, metadata FROM opportunities WHERE id = CAST(:id AS UUID) """), {"id": opportunity_id}).mappings().first() docs = conn.execute(text(""" SELECT id::text, document_kind, document_number, amount, total_amount, currency, document_date FROM commercial_documents WHERE opportunity_id = CAST(:id AS UUID) ORDER BY created_at DESC """), {"id": opportunity_id}).mappings().all() items = conn.execute(text(""" SELECT product_name, quantity, unit_price, total_price, jasmin_sales_item FROM opportunity_items WHERE opportunity_id = CAST(:id AS UUID) ORDER BY created_at """), {"id": opportunity_id}).mappings().all() lines = conn.execute(text(""" SELECT cdl.description, cdl.quantity, cdl.unit_price, cdl.total_amount, cdl.jasmin_sales_item FROM commercial_document_lines cdl JOIN commercial_documents cd ON cd.id = cdl.document_id WHERE cd.opportunity_id = CAST(:id AS UUID) ORDER BY cdl.line_index """), {"id": opportunity_id}).mappings().all() return { "opportunity": dict(opportunity or {}), "documents": [dict(r) for r in docs], "opportunity_items": [dict(r) for r in items], "document_lines": [dict(r) for r in lines], } def main() -> int: parser = argparse.ArgumentParser() parser.add_argument("--opportunity-id") parser.add_argument("--document-number") parser.add_argument("--no-fetch-jasmin-detail", action="store_true") parser.add_argument("--dry-run", action="store_true") parser.add_argument("--actor", default="operator_backfill") args = parser.parse_args() if not args.opportunity_id and not args.document_number: parser.error("usa --opportunity-id ou --document-number") opportunity = _load_opportunity(args.opportunity_id, args.document_number) if not opportunity: print(json.dumps({"ok": False, "error": "opportunity_not_found"}, ensure_ascii=False, indent=2)) return 2 items = _load_candidate_items(opportunity) enriched_items = [ _with_jasmin_detail(item, fetch_detail=not args.no_fetch_jasmin_detail) for item in items ] print(json.dumps({ "opportunity_id": opportunity.get("id"), "title": opportunity.get("title"), "candidate_items": _summary_for_items(enriched_items), "dry_run": args.dry_run, }, ensure_ascii=False, indent=2, default=str)) if not enriched_items: print(json.dumps({"ok": False, "error": "no_jasmin_reconciliation_items_found"}, ensure_ascii=False, indent=2)) return 3 if args.dry_run: return 0 with engine.begin() as conn: result = _apply_jasmin_documents_to_opportunity( conn, enriched_items, str(opportunity["id"]), actor=args.actor, ) print(json.dumps({"ok": True, "import_result": result}, ensure_ascii=False, indent=2, default=str)) print(json.dumps(_post_import_summary(str(opportunity["id"])), ensure_ascii=False, indent=2, default=str)) return 0 if __name__ == "__main__": raise SystemExit(main())