Release v4928.1.4.2 stable
This commit is contained in:
733
app/jasmin_backfill_service.py
Normal file
733
app/jasmin_backfill_service.py
Normal 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
|
||||
Reference in New Issue
Block a user