Release v4928.1.4.2 stable

This commit is contained in:
2026-06-09 22:55:58 +01:00
commit 6445044ac6
280 changed files with 41775 additions and 0 deletions

View File

@@ -0,0 +1,733 @@
"""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()
is_deleted = _field_bool(record, "isDeleted", "deleted")
is_draft = _field_bool(record, "isDraft")
is_completed = _field_bool(record, "statusWasCompleted", "wasCompleted", "completed")
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",
}
open_terms = {"open", "aberto", "aberta", "active", "ativo", "ativa"}
has_closed_term = any(term in text_blob for term in closed_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_completed:
invalid_reasons.append("completed")
if has_closed_term:
invalid_reasons.append("closed_status")
if is_draft:
invalid_reasons.append("draft")
is_open = (has_open_term or code_open) and not invalid_reasons
is_valid = is_open and _as_text(item.get("external_type")) in {"jasmin_quotation", "jasmin_proforma"}
if not is_valid and not invalid_reasons:
invalid_reasons.append("not_open_or_not_quotation")
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. Newer valid
# documents still appear as replacement candidates.
recency_key = _jasmin_item_recency_tuple(item)
if 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