"""Operator helpers to reimport Jasmin document details into opportunities. Used by both CLI backfills and the opportunity detail UI. The function is idempotent because the underlying reconciliation import upserts commercial documents, document lines and opportunity items by Jasmin identifiers. """ from __future__ import annotations from decimal import Decimal, InvalidOperation from typing import Any, Dict, Iterable, List, Optional import json from sqlalchemy import text from app.db import engine from app.reconciliation_service import ( _apply_jasmin_documents_to_opportunity, # noqa: PLC2701 - deliberate operator maintenance helper _jasmin_document_lines_from_item, # noqa: PLC2701 _jasmin_document_totals, # noqa: PLC2701 ) def _as_text(value: Any) -> str: return str(value or "").strip() def _json(value: Any) -> str: return json.dumps(value, ensure_ascii=False, default=str) 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 _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 _record_from_jasmin_item(item: Dict[str, Any]) -> Dict[str, Any]: payload = item.get("payload") if isinstance(item.get("payload"), dict) else {} record = payload.get("record") if isinstance(payload.get("record"), dict) else {} return record def _field_text(record: Dict[str, Any], *keys: str) -> str: for key in keys: value = record.get(key) if value not in (None, ""): return _as_text(value) return "" def _field_bool(record: Dict[str, Any], *keys: str) -> bool: for key in keys: value = record.get(key) if isinstance(value, bool): return value if isinstance(value, str) and value.strip().lower() in {"true", "1", "yes", "sim"}: return True if isinstance(value, (int, float)) and value == 1: return True return False def jasmin_document_lifecycle(item: Dict[str, Any]) -> Dict[str, Any]: """Classify a Jasmin reconciliation item before linking/importing it. The Jasmin API stores lifecycle information inside payload.record, not in reconciliation_items.status (which is ClientFlow resolution status such as linked/rejected). The UI must not import an old/closed quotation when a newer open quotation exists for the same fiscal customer. """ record = _record_from_jasmin_item(item) status_desc = _field_text(record, "documentStatusDescription", "statusDescription", "documentLineStatusDescription") status_code = _field_text(record, "documentStatus", "status") line_status = " ".join( _field_text(line, "documentLineStatusDescription", "lineStatusDescription", "statusDescription") for line in (record.get("documentLines") or []) if isinstance(line, dict) ).strip() text_blob = " ".join([status_desc, status_code, line_status]).casefold() external_type = _as_text(item.get("external_type")) is_invoice = external_type == "jasmin_invoice" is_deleted = _field_bool(record, "isDeleted", "deleted") is_draft = _field_bool(record, "isDraft") is_completed = _field_bool(record, "statusWasCompleted", "wasCompleted", "completed") # Quotations/proformas must be open because importing an old converted quote # can replace the active proposal. Invoices are different: a valid issued # invoice may be Open, Completed or Paid and still belongs as a downstream # document of the same process. Only destructive/cancelled invoice states # should block operator association. closed_terms = { "closed", "close", "fechado", "fechada", "completed", "complete", "concluido", "concluído", "concluida", "concluída", "converted", "convertido", "convertida", "cancelled", "canceled", "cancelado", "cancelada", "anulado", "anulada", "void", "deleted", "apagado", "apagada", } invoice_block_terms = { "cancelled", "canceled", "cancelado", "cancelada", "anulado", "anulada", "void", "deleted", "apagado", "apagada", } open_terms = {"open", "aberto", "aberta", "active", "ativo", "ativa"} has_closed_term = any(term in text_blob for term in closed_terms) has_invoice_block_term = any(term in text_blob for term in invoice_block_terms) has_open_term = any(term in text_blob for term in open_terms) # In Jasmin quotations observed in production, documentStatus=1 + # documentStatusDescription=Open means valid/open. Keep code 1 as a weak # positive only when no closed term exists. code_open = status_code.strip() == "1" invalid_reasons: List[str] = [] if is_deleted: invalid_reasons.append("deleted") if is_draft: invalid_reasons.append("draft") if is_invoice: if has_invoice_block_term: invalid_reasons.append("cancelled_or_void_invoice") else: if is_completed: invalid_reasons.append("completed") if has_closed_term: invalid_reasons.append("closed_status") is_open = (has_open_term or code_open) and not invalid_reasons is_valid = ( (external_type in {"jasmin_quotation", "jasmin_proforma"} and is_open) or (is_invoice and not invalid_reasons) ) if not is_valid and not invalid_reasons: invalid_reasons.append("not_open_or_supported_jasmin_document") label = status_desc or ("Open" if is_open else "Unknown") return { "status_label": label, "status_code": status_code, "line_status_label": line_status, "is_open": bool(is_open), "is_valid_candidate": bool(is_valid), "invalid_reason": ",".join(dict.fromkeys(invalid_reasons)), } def _jasmin_candidate_sort_tuple(item: Dict[str, Any]) -> tuple: lifecycle = item.get("lifecycle") if isinstance(item.get("lifecycle"), dict) else jasmin_document_lifecycle(item) valid_rank = 1 if lifecycle.get("is_valid_candidate") else 0 external_type = _as_text(item.get("external_type")) type_rank = {"jasmin_quotation": 3, "jasmin_proforma": 2, "jasmin_invoice": 1}.get(external_type, 0) match_score = int(item.get("match_score") or 0) record = _record_from_jasmin_item(item) series_number = 0 try: series_number = int(record.get("seriesNumber") or 0) except Exception: series_number = 0 doc_date = _as_text(item.get("document_date") or record.get("documentDate") or record.get("postingDate") or item.get("updated_at")) return (valid_rank, type_rank, doc_date, series_number, match_score, _as_text(item.get("updated_at"))) def _jasmin_item_recency_tuple(item: Dict[str, Any]) -> tuple: """Comparable recency key for Jasmin documents. Jasmin quotations are naturally ordered by document date and series number. We use this to prevent an old still-open quotation from being offered as a replacement when the opportunity already has a newer Jasmin document. """ record = _record_from_jasmin_item(item) series_number = 0 try: series_number = int(record.get("seriesNumber") or item.get("series_number") or 0) except Exception: series_number = 0 doc_date = _as_text( item.get("document_date") or record.get("documentDate") or record.get("postingDate") or item.get("created_at") or item.get("updated_at") )[:10] return (doc_date, series_number, _as_text(item.get("document_number") or item.get("external_id"))) def _commercial_doc_as_jasmin_item(doc: Dict[str, Any]) -> Dict[str, Any]: payload = doc.get("payload") if isinstance(doc.get("payload"), dict) else {} record = payload.get("record") if isinstance(payload.get("record"), dict) else payload return { "external_type": f"jasmin_{doc.get('document_kind') or 'document'}", "external_id": doc.get("external_id"), "document_number": doc.get("document_number"), "document_date": doc.get("document_date"), "created_at": doc.get("created_at"), "updated_at": doc.get("updated_at"), "payload": {"record": record} if isinstance(record, dict) else {}, } def load_jasmin_backfill_opportunity(opportunity_id: Optional[str] = None, document_number: Optional[str] = None) -> 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_jasmin_candidate_items_for_opportunity(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() seen = set() result: List[Dict[str, Any]] = [] 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) if external_type == "jasmin_proforma": try: return await client.get_quotation(external_id) except Exception: return None return None async def enrich_jasmin_item_with_detail(item: Dict[str, Any], *, fetch_detail: bool = True) -> Dict[str, Any]: if not fetch_detail: return item existing_lines = _jasmin_document_lines_from_item(item) if existing_lines: return item try: detail = await _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"), } 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_jasmin_items(items: Iterable[Dict[str, Any]]) -> List[Dict[str, Any]]: result = [] for item in items: lifecycle = jasmin_document_lifecycle(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": _jasmin_document_totals(item), "lines": len(_jasmin_document_lines_from_item(item)), "payload_keys": list((item.get("payload") or {}).keys()) if isinstance(item.get("payload"), dict) else [], "lifecycle": lifecycle, }) return result def jasmin_backfill_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], } async def backfill_jasmin_opportunity_details_async( *, opportunity_id: Optional[str] = None, document_number: Optional[str] = None, fetch_detail: bool = True, actor: str = "operator_ui_reimport", dry_run: bool = False, ) -> Dict[str, Any]: opportunity = load_jasmin_backfill_opportunity(opportunity_id=opportunity_id, document_number=document_number) if not opportunity: return {"ok": False, "error": "opportunity_not_found"} items = load_jasmin_candidate_items_for_opportunity(opportunity) enriched_items = [await enrich_jasmin_item_with_detail(item, fetch_detail=fetch_detail) for item in items] result: Dict[str, Any] = { "ok": bool(enriched_items), "opportunity_id": opportunity.get("id"), "title": opportunity.get("title"), "candidate_items": summary_for_jasmin_items(enriched_items), "dry_run": dry_run, } if not enriched_items: result["error"] = "no_jasmin_reconciliation_items_found" return result if dry_run: return result with engine.begin() as conn: import_result = _apply_jasmin_documents_to_opportunity( conn, enriched_items, str(opportunity["id"]), actor=actor, ) result["import_result"] = import_result result["summary"] = jasmin_backfill_post_import_summary(str(opportunity["id"])) return result def _email_domain(email: Any) -> str: text_value = _as_text(email).lower().strip().strip(';') if "@" not in text_value: return "" return text_value.rsplit("@", 1)[-1].strip() def find_jasmin_document_candidates_for_opportunity(opportunity_id: str, *, limit: int = 5) -> List[Dict[str, Any]]: """Find Jasmin documents that probably belong to an opportunity but are not imported yet. This is deliberately conservative and prioritizes fiscal identity (NIF/customer_id) over weak name/domain matching. It is used in the opportunity detail UI before offering to create a new Jasmin quotation, so operators can associate an existing quotation instead of duplicating it. """ with engine.begin() as conn: opp = conn.execute(text(""" SELECT o.id::text, o.customer_name, o.customer_email, o.local_customer_id::text, c.name AS fiscal_customer_name, c.tax_id AS fiscal_customer_tax_id, c.email AS fiscal_customer_email FROM opportunities o LEFT JOIN customers c ON c.id = o.local_customer_id WHERE o.id = CAST(:id AS UUID) LIMIT 1 """), {"id": opportunity_id}).mappings().first() if not opp: return [] fiscal_tax_id = _as_text(opp.get("fiscal_customer_tax_id")) fiscal_customer_id = _as_text(opp.get("local_customer_id")) fiscal_name = _as_text(opp.get("fiscal_customer_name")) fiscal_email = _as_text(opp.get("fiscal_customer_email") or opp.get("customer_email")).lower() domain = _email_domain(fiscal_email) current_docs = conn.execute(text(""" SELECT id::text, document_number, external_id, document_kind, document_date, total_amount, amount, payload, created_at, updated_at FROM commercial_documents WHERE opportunity_id = CAST(:opportunity_id AS UUID) AND system = 'jasmin' AND document_kind IN ('quotation', 'proforma') ORDER BY document_date DESC NULLS LAST, created_at DESC """), {"opportunity_id": opportunity_id}).mappings().all() rows = conn.execute(text(""" SELECT ri.id::text, ri.source_system, ri.external_type, ri.external_id, ri.title, ri.status, ri.opportunity_id::text, ri.customer_id::text, ri.customer_name, ri.customer_email, ri.customer_tax_id, ri.document_number, ri.document_date, ri.amount, ri.currency, ri.payload, ri.created_at, ri.updated_at, CASE WHEN CAST(:fiscal_customer_id AS TEXT) <> '' AND ri.customer_id::text = CAST(:fiscal_customer_id AS TEXT) THEN 100 WHEN CAST(:fiscal_tax_id AS TEXT) <> '' AND ri.customer_tax_id = CAST(:fiscal_tax_id AS TEXT) THEN 98 WHEN CAST(:fiscal_tax_id AS TEXT) <> '' AND ri.payload::text ILIKE '%' || CAST(:fiscal_tax_id AS TEXT) || '%' THEN 96 WHEN CAST(:fiscal_email AS TEXT) <> '' AND LOWER(COALESCE(ri.customer_email, '')) = CAST(:fiscal_email AS TEXT) THEN 90 WHEN CAST(:domain AS TEXT) <> '' AND ri.payload::text ILIKE '%' || CAST(:domain AS TEXT) || '%' THEN 78 WHEN CAST(:fiscal_name AS TEXT) <> '' AND LOWER(COALESCE(ri.customer_name, '')) = LOWER(CAST(:fiscal_name AS TEXT)) THEN 75 ELSE 0 END AS match_score, CASE WHEN CAST(:fiscal_customer_id AS TEXT) <> '' AND ri.customer_id::text = CAST(:fiscal_customer_id AS TEXT) THEN 'customer_id' WHEN CAST(:fiscal_tax_id AS TEXT) <> '' AND ri.customer_tax_id = CAST(:fiscal_tax_id AS TEXT) THEN 'nif' WHEN CAST(:fiscal_tax_id AS TEXT) <> '' AND ri.payload::text ILIKE '%' || CAST(:fiscal_tax_id AS TEXT) || '%' THEN 'payload_nif' WHEN CAST(:fiscal_email AS TEXT) <> '' AND LOWER(COALESCE(ri.customer_email, '')) = CAST(:fiscal_email AS TEXT) THEN 'email' WHEN CAST(:domain AS TEXT) <> '' AND ri.payload::text ILIKE '%' || CAST(:domain AS TEXT) || '%' THEN 'domain_payload' WHEN CAST(:fiscal_name AS TEXT) <> '' AND LOWER(COALESCE(ri.customer_name, '')) = LOWER(CAST(:fiscal_name AS TEXT)) THEN 'exact_name' ELSE 'none' END AS match_reason FROM reconciliation_items ri WHERE ri.source_system = 'jasmin' AND ri.external_type IN ('jasmin_quotation', 'jasmin_proforma', 'jasmin_invoice') AND (ri.opportunity_id IS NULL OR ri.opportunity_id = CAST(:opportunity_id AS UUID)) AND NOT EXISTS ( SELECT 1 FROM commercial_documents cd WHERE cd.opportunity_id = CAST(:opportunity_id AS UUID) AND ( (ri.document_number IS NOT NULL AND cd.document_number = ri.document_number) OR (ri.external_id IS NOT NULL AND cd.external_id = ri.external_id) ) ) AND ( (CAST(:fiscal_customer_id AS TEXT) <> '' AND ri.customer_id::text = CAST(:fiscal_customer_id AS TEXT)) OR (CAST(:fiscal_tax_id AS TEXT) <> '' AND ri.customer_tax_id = CAST(:fiscal_tax_id AS TEXT)) OR (CAST(:fiscal_tax_id AS TEXT) <> '' AND ri.payload::text ILIKE '%' || CAST(:fiscal_tax_id AS TEXT) || '%') OR (CAST(:fiscal_email AS TEXT) <> '' AND LOWER(COALESCE(ri.customer_email, '')) = CAST(:fiscal_email AS TEXT)) OR (CAST(:domain AS TEXT) <> '' AND ri.payload::text ILIKE '%' || CAST(:domain AS TEXT) || '%') OR (CAST(:fiscal_name AS TEXT) <> '' AND LOWER(COALESCE(ri.customer_name, '')) = LOWER(CAST(:fiscal_name AS TEXT))) ) ORDER BY match_score DESC, ri.document_date DESC NULLS LAST, ri.updated_at DESC LIMIT :limit """), { "opportunity_id": opportunity_id, "fiscal_customer_id": fiscal_customer_id, "fiscal_tax_id": fiscal_tax_id, "fiscal_email": fiscal_email, "domain": domain, "fiscal_name": fiscal_name, "limit": int(limit), }).mappings().all() current_doc_items = [_commercial_doc_as_jasmin_item(dict(row)) for row in current_docs] current_latest_key = max((_jasmin_item_recency_tuple(item) for item in current_doc_items), default=None) current_doc_numbers = { _as_text(row.get("document_number")) for row in current_docs if _as_text(row.get("document_number")) } current_external_ids = { _as_text(row.get("external_id")) for row in current_docs if _as_text(row.get("external_id")) } candidates: List[Dict[str, Any]] = [] ignored: List[Dict[str, Any]] = [] hidden_old_count = 0 for row in rows: item = dict(row) item_doc_number = _as_text(item.get("document_number")) item_external_id = _as_text(item.get("external_id")) if item_doc_number in current_doc_numbers or item_external_id in current_external_ids: continue totals = _jasmin_document_totals(item) lines = _jasmin_document_lines_from_item(item) lifecycle = jasmin_document_lifecycle(item) # If the opportunity already has a Jasmin quotation/proforma, do not show # older/equal still-open quotations as actionable candidates. They caused # operators to import ORC.137 after ORC.158 already existed. Downstream # invoices are not replacement proposals; keep them visible so operators # can associate the issued invoice to the same process. recency_key = _jasmin_item_recency_tuple(item) if _as_text(item.get("external_type")) in {"jasmin_quotation", "jasmin_proforma"} and current_latest_key and recency_key <= current_latest_key: hidden_old_count += 1 continue item["totals"] = totals item["line_count"] = len(lines) item["lifecycle"] = lifecycle item["jasmin_status_label"] = lifecycle.get("status_label") item["jasmin_status_code"] = lifecycle.get("status_code") item["is_valid_candidate"] = lifecycle.get("is_valid_candidate") item["invalid_reason"] = lifecycle.get("invalid_reason") if lifecycle.get("is_valid_candidate"): candidates.append(item) else: ignored.append(item) candidates.sort(key=_jasmin_candidate_sort_tuple, reverse=True) ignored.sort(key=_jasmin_candidate_sort_tuple, reverse=True) result = (candidates + ignored)[: int(limit)] if hidden_old_count and result: result[0]["hidden_older_candidates_count"] = hidden_old_count return result async def replace_jasmin_document_for_opportunity_async( *, opportunity_id: str, item_id: str, actor: str = "operator_replace_jasmin_document", dry_run: bool = False, ) -> Dict[str, Any]: """Remove current imported Jasmin quotation/proforma details and import a valid candidate. This is intended for operator repair when an old/closed quotation was linked by mistake. It only removes ClientFlow imported Jasmin artifacts from this opportunity; it does not delete anything in Jasmin. """ with engine.begin() as conn: current_docs = conn.execute(text(""" SELECT id::text, document_number, external_id, document_kind, status, total_amount FROM commercial_documents WHERE opportunity_id = CAST(:opportunity_id AS UUID) AND system = 'jasmin' AND document_kind IN ('quotation', 'proforma') ORDER BY created_at DESC """), {"opportunity_id": opportunity_id}).mappings().all() current_items = conn.execute(text(""" SELECT id::text, product_name, total_price, metadata FROM opportunity_items WHERE opportunity_id = CAST(:opportunity_id AS UUID) AND (metadata->>'source_system' = 'jasmin' OR status = 'JASMIN_IMPORTED') ORDER BY created_at """), {"opportunity_id": opportunity_id}).mappings().all() if dry_run: return { "ok": True, "dry_run": True, "would_remove_documents": [dict(r) for r in current_docs], "would_remove_items": [dict(r) for r in current_items], "candidate_item_id": item_id, } # Validate candidate before deleting anything. with engine.begin() as conn: candidate = conn.execute(text(""" SELECT id::text, source_system, external_type, payload, document_number, opportunity_id::text FROM reconciliation_items WHERE id = CAST(:item_id AS UUID) LIMIT 1 """), {"item_id": item_id}).mappings().first() if not candidate: return {"ok": False, "error": "jasmin_item_not_found"} lifecycle = jasmin_document_lifecycle(dict(candidate)) if candidate.get("source_system") != "jasmin" or not lifecycle.get("is_valid_candidate"): return {"ok": False, "error": "jasmin_candidate_not_open_or_valid", "lifecycle": lifecycle} with engine.begin() as conn: conn.execute(text(""" DELETE FROM commercial_documents WHERE opportunity_id = CAST(:opportunity_id AS UUID) AND system = 'jasmin' AND document_kind IN ('quotation', 'proforma') """), {"opportunity_id": opportunity_id}) conn.execute(text(""" DELETE FROM opportunity_items WHERE opportunity_id = CAST(:opportunity_id AS UUID) AND (metadata->>'source_system' = 'jasmin' OR status = 'JASMIN_IMPORTED') """), {"opportunity_id": opportunity_id}) conn.execute(text(""" UPDATE reconciliation_items SET opportunity_id = NULL, status = CASE WHEN status = 'linked' THEN 'open' ELSE status END, updated_at = now(), resolution_note = COALESCE(resolution_note, '') || '\nDesassociado por substituição de orçamento Jasmin em ClientFlow.' WHERE source_system = 'jasmin' AND opportunity_id = CAST(:opportunity_id AS UUID) AND id <> CAST(:item_id AS UUID) """), {"opportunity_id": opportunity_id, "item_id": item_id}) result = await link_and_import_jasmin_candidate_async(opportunity_id=opportunity_id, item_id=item_id, actor=actor) result["removed_documents"] = len(current_docs) result["removed_items"] = len(current_items) return result async def link_and_import_jasmin_candidate_async( *, opportunity_id: str, item_id: str, actor: str = "operator_ui_link_existing_jasmin", ) -> Dict[str, Any]: """Link an existing Jasmin reconciliation item to an opportunity and import its details.""" with engine.begin() as conn: item = conn.execute(text(""" SELECT id::text, source_system, external_type, opportunity_id::text, document_number, payload FROM reconciliation_items WHERE id = CAST(:item_id AS UUID) LIMIT 1 """), {"item_id": item_id}).mappings().first() if not item: return {"ok": False, "error": "jasmin_item_not_found"} if item.get("source_system") != "jasmin": return {"ok": False, "error": "item_is_not_jasmin"} lifecycle = jasmin_document_lifecycle(item) if not lifecycle.get("is_valid_candidate"): return { "ok": False, "error": "jasmin_candidate_not_open_or_valid", "status": lifecycle.get("status_label"), "reason": lifecycle.get("invalid_reason"), } current_opportunity_id = _as_text(item.get("opportunity_id")) if current_opportunity_id and current_opportunity_id != opportunity_id: return {"ok": False, "error": "jasmin_item_already_linked_to_other_opportunity", "linked_opportunity_id": current_opportunity_id} conn.execute(text(""" UPDATE reconciliation_items SET opportunity_id = CAST(:opportunity_id AS UUID), status = 'linked', resolved_at = COALESCE(resolved_at, now()), updated_at = now(), resolution_note = COALESCE(resolution_note, 'Associado a oportunidade pela ficha comercial'), payload = COALESCE(payload, '{}'::jsonb) || jsonb_build_object( 'manual_opportunity_link', jsonb_build_object( 'actor', CAST(:actor AS TEXT), 'opportunity_id', CAST(:opportunity_id AS TEXT), 'linked_at', now() ) ) WHERE id = CAST(:item_id AS UUID) """), {"item_id": item_id, "opportunity_id": opportunity_id, "actor": actor}) conn.execute(text(""" INSERT INTO opportunity_events (id, opportunity_id, event_type, note, payload, created_by) VALUES (gen_random_uuid(), CAST(:opportunity_id AS UUID), 'jasmin_existing_document_linked', :note, CAST(:payload AS JSONB), :actor) """), { "opportunity_id": opportunity_id, "note": "Documento Jasmin existente associado à oportunidade.", "payload": _json({"reconciliation_item_id": item_id, "document_number": item.get("document_number")}), "actor": actor, }) result = await backfill_jasmin_opportunity_details_async( opportunity_id=opportunity_id, fetch_detail=True, actor=actor, dry_run=False, ) result["linked_item_id"] = item_id return result