Implement document reconciliation v2

This commit is contained in:
plx
2026-08-05 23:55:33 +00:00
parent 4a60607503
commit 29382ec6c1
25 changed files with 2082 additions and 343 deletions

View File

@@ -1588,8 +1588,8 @@ def _upsert_jasmin_document_from_item(conn: Any, item: Dict[str, Any], opportuni
external_id = _clean(item.get("external_id") or _first_value(record, "id", "key", "documentKey", "naturalKey"))
document_number = _clean(item.get("document_number") or _first_value(record, "documentNumber", "number", "naturalKey", "name", "reference") or external_id)
customer_id = _uuid_or_none(item.get("customer_id"))
existing = conn.execute(text("""
SELECT id::text
existing_row = conn.execute(text("""
SELECT id::text, status
FROM commercial_documents
WHERE system = 'jasmin'
AND (
@@ -1599,7 +1599,9 @@ def _upsert_jasmin_document_from_item(conn: Any, item: Dict[str, Any], opportuni
AND (opportunity_id = CAST(:opportunity_id AS UUID) OR opportunity_id IS NULL)
ORDER BY opportunity_id NULLS LAST, created_at DESC
LIMIT 1
"""), {"external_id": external_id, "document_number": document_number, "opportunity_id": opportunity_id}).scalar()
"""), {"external_id": external_id, "document_number": document_number, "opportunity_id": opportunity_id}).mappings().first()
existing = existing_row.get("id") if existing_row else None
old_status = existing_row.get("status") if existing_row else None
payload = {
"source": "reconciliation_jasmin_import",
@@ -1627,23 +1629,11 @@ def _upsert_jasmin_document_from_item(conn: Any, item: Dict[str, Any], opportuni
"document_date": _date_or_none_value(item.get("document_date") or _first_value(record, "documentDate", "date", "creationDate", "postingDate")),
"due_date": _date_or_none_value(_first_value(record, "dueDate", "paymentDueDate")),
"payload": _json(payload),
"role": "current" if document_kind in {"quotation", "proforma", "invoice"} else "related",
"is_primary": True,
# Compatibility defaults only. v2 relationship resolution below is
# authoritative and never overwrites an existing manual decision.
"role": "related",
"is_primary": False,
}
if params["role"] in {"current", "accepted"}:
conn.execute(text("""
UPDATE commercial_documents
SET role = CASE WHEN COALESCE(role, 'current') = 'current' THEN 'historical' ELSE role END,
is_primary = FALSE,
is_active = CASE WHEN COALESCE(role, 'current') = 'current' THEN FALSE ELSE COALESCE(is_active, TRUE) END,
updated_at = now()
WHERE opportunity_id = CAST(:opportunity_id AS UUID)
AND system = 'jasmin'
AND document_kind = :document_kind
AND id <> CAST(:id AS UUID)
AND COALESCE(role, 'current') IN ('current', 'accepted')
AND COALESCE(is_primary, TRUE) = TRUE
"""), params)
if existing:
conn.execute(text("""
UPDATE commercial_documents
@@ -1664,9 +1654,6 @@ def _upsert_jasmin_document_from_item(conn: Any, item: Dict[str, Any], opportuni
currency = COALESCE(:currency, currency),
document_date = COALESCE(CAST(:document_date AS DATE), document_date),
due_date = COALESCE(CAST(:due_date AS DATE), due_date),
role = :role,
is_primary = :is_primary,
is_active = TRUE,
payload = COALESCE(payload, '{}'::jsonb) || CAST(:payload AS JSONB),
updated_at = now()
WHERE id = CAST(:id AS UUID)
@@ -1691,19 +1678,23 @@ def _upsert_jasmin_document_from_item(conn: Any, item: Dict[str, Any], opportuni
"""), params)
document_id = params["id"]
# All relationship transitions and their audit events are owned by the
# canonical domain service and remain in this sync transaction. Before 007,
# the additive sync keeps legacy document data flowing without inventing a
# v2 decision; the later group backfill classifies it atomically.
from app.document_reconciliation_service import (
classify_sync_document, document_reconciliation_v2_available,
)
v2_available = document_reconciliation_v2_available(conn)
link = (classify_sync_document(conn, opportunity_id, document_id, document_kind,
status=params.get("status"), old_status=old_status, actor="jasmin_sync")
if v2_available else {"id": None, "relationship": None})
for line in _jasmin_document_lines_from_item(item):
mapping = _resolve_product_mapping_for_jasmin_line(conn, line)
conn.execute(text("""
INSERT INTO commercial_document_lines (
document_id, line_index, local_product_id, jasmin_sales_item, description,
quantity, unit, unit_price, tax_schema, total_amount, payload
) VALUES (
CAST(:document_id AS UUID), :line_index, CAST(:local_product_id AS UUID), :jasmin_sales_item,
:description, CAST(:quantity AS NUMERIC), :unit, CAST(:unit_price AS NUMERIC),
:tax_schema, CAST(:total_amount AS NUMERIC), CAST(:payload AS JSONB)
)
"""), {
line_params = {
"document_id": document_id,
"link_id": str(link["id"]) if link.get("id") else None,
"line_index": line.get("line_index"),
"local_product_id": mapping.get("product_id"),
"jasmin_sales_item": mapping.get("jasmin_sales_item") or line.get("jasmin_sales_item"),
@@ -1714,7 +1705,28 @@ def _upsert_jasmin_document_from_item(conn: Any, item: Dict[str, Any], opportuni
"tax_schema": line.get("tax_schema"),
"total_amount": line.get("total_amount"),
"payload": _json({"source_line_id": line.get("source_line_id"), "mapping": mapping, "raw": line.get("payload")}),
})
}
if v2_available:
conn.execute(text("""
INSERT INTO commercial_document_lines (
document_id, commercial_document_id, opportunity_document_link_id,
line_index, local_product_id, jasmin_sales_item, description,
quantity, unit, unit_price, tax_schema, total_amount, payload
) VALUES (
CAST(:document_id AS UUID), CAST(:document_id AS UUID), CAST(:link_id AS UUID),
:line_index, CAST(:local_product_id AS UUID), :jasmin_sales_item,
:description, CAST(:quantity AS NUMERIC), :unit, CAST(:unit_price AS NUMERIC),
:tax_schema, CAST(:total_amount AS NUMERIC), CAST(:payload AS JSONB)
)
"""), line_params)
else:
conn.execute(text("""INSERT INTO commercial_document_lines
(document_id,line_index,local_product_id,jasmin_sales_item,description,quantity,unit,
unit_price,tax_schema,total_amount,payload)
VALUES(CAST(:document_id AS UUID),:line_index,CAST(:local_product_id AS UUID),
:jasmin_sales_item,:description,CAST(:quantity AS NUMERIC),:unit,
CAST(:unit_price AS NUMERIC),:tax_schema,CAST(:total_amount AS NUMERIC),CAST(:payload AS JSONB))"""),
line_params)
return document_id
@@ -1722,6 +1734,16 @@ def _upsert_opportunity_items_from_jasmin_item(conn: Any, item: Dict[str, Any],
if _clean(item.get("source_system")) != "jasmin" or not _clean(item.get("external_type")).startswith("jasmin_"):
return 0
document_ref = _clean(item.get("document_number") or item.get("external_id"))
from app.document_reconciliation_service import resolve_document_links
external_id = _clean(item.get("external_id"))
document_number = _clean(item.get("document_number"))
matched = next((row for row in resolve_document_links(opportunity_id, conn=conn)
if external_id and row.get("external_id") == external_id
or document_number and row.get("document_number") == document_number), None)
origin = ({"commercial_document_id": matched.get("document_id"),
"opportunity_document_link_id": matched.get("id")
if matched.get("resolution_source") == "v2" else None}
if matched else {})
upserted = 0
for line in _jasmin_document_lines_from_item(item):
mapping = _resolve_product_mapping_for_jasmin_line(conn, line)
@@ -1746,6 +1768,8 @@ def _upsert_opportunity_items_from_jasmin_item(conn: Any, item: Dict[str, Any],
"source_line_id": source_line_id,
"source_external_id": item.get("external_id"),
"source_external_type": item.get("external_type"),
"commercial_document_id": origin.get("commercial_document_id"),
"opportunity_document_link_id": origin.get("opportunity_document_link_id"),
"resolved_sku": mapping.get("sku"),
"resolved_jasmin_sales_item": mapping.get("jasmin_sales_item"),
"product_mapping_status": mapping.get("mapping_status"),
@@ -1992,16 +2016,10 @@ def _apply_reconstructed_process_to_opportunity(conn: Any, items: List[Dict[str,
AND EXISTS (SELECT 1 FROM opportunity_items WHERE opportunity_id = CAST(:opportunity_id AS UUID))
"""), {"opportunity_id": opportunity_id})
has_invoice = bool(conn.execute(text("""
SELECT EXISTS (
SELECT 1
FROM commercial_documents
WHERE opportunity_id = CAST(:opportunity_id AS UUID)
AND document_kind = 'invoice'
AND COALESCE(is_active, TRUE) = TRUE
AND COALESCE(role, 'current') IN ('current', 'accepted', 'historical', 'history')
)
"""), {"opportunity_id": opportunity_id}).scalar())
from app.document_reconciliation_service import resolve_document_links, select_valid_primary
# Only a valid effective PRIMARY invoice may drive SEND_INVOICE workflow.
# REMOVED/REASSIGNED/IGNORED/REVIEW_REQUIRED/HISTORICAL never qualify.
has_invoice = select_valid_primary(resolve_document_links(opportunity_id, conn=conn), "invoice") is not None
if odoo_items and effective_action_code == "SEND_INVOICE" and not has_invoice:
_ensure_pending_task_for_reconstruction(
conn,