"""Operational reconciliation and external intake service. v4.9.1 introduces a controlled staging area for information that exists outside ClientFlow: Jasmin documents, Odoo sales, manual WhatsApp/phone/email requests and payment proofs. The service deliberately creates reconciliation candidates first; critical process changes still need operator confirmation. """ from __future__ import annotations import json import uuid import logging import re import unicodedata from datetime import datetime from decimal import Decimal, InvalidOperation from typing import Any, Dict, List, Optional, Tuple from sqlalchemy import text from app.action_catalog import get_action_config from app.db import engine from app.work_center_action_policy import ( RECONSTRUCTED_SENSITIVE_ACTIONS, canonical_action_code, reconstructed_review_metadata_patch, reconstructed_review_status, ) _SCHEMA_READY = False logger = logging.getLogger(__name__) RECONCILIATION_OPEN_STATUSES = {"open", "needs_review", "conflict"} DOCUMENT_EXTERNAL_TYPES = {"jasmin_quotation", "jasmin_invoice", "jasmin_proforma", "odoo_sale_order", "packlink_shipment"} PAYMENT_EXTERNAL_TYPES = {"payment_proof", "payment_receipt"} STAGE_BY_EXTERNAL_TYPE = { "jasmin_quotation": "QUOTE_SENT", "jasmin_proforma": "WAITING_PAYMENT", "jasmin_invoice": "INVOICE_SENT", "odoo_sale_order": "ODOO_ORDER_CREATED", "packlink_shipment": "SHIPMENT_CREATED", "payment_proof": "WAITING_PAYMENT", "manual_request": "QUOTE_REQUESTED", } NEXT_ACTION_BY_EXTERNAL_TYPE = { "jasmin_quotation": "SEND_PROFORMA", "jasmin_proforma": "CONFIRM_PAYMENT", "jasmin_invoice": "CONFIRM_PAYMENT", "odoo_sale_order": "SEND_INVOICE", "packlink_shipment": "REVIEW_MANUALLY", "payment_proof": "CONFIRM_PAYMENT", "manual_request": "SEND_QUOTE", } def _json(value: Any) -> str: return json.dumps(value or {}, ensure_ascii=False, default=str) def _clean(value: Any) -> str: return str(value or "").strip() def _uuid_or_none(value: Any) -> Optional[str]: value = _clean(value) return value or None def _money_or_none(value: Any) -> Optional[str]: raw = _clean(value).replace("€", "").replace(" ", "").replace(",", ".") if not raw: return None try: return str(Decimal(raw).quantize(Decimal("0.01"))) except (InvalidOperation, ValueError): return None def _normalize_tax_id(value: Any) -> str: """Normalize NIF/VAT values for exact matching across external systems.""" raw = _clean(value).upper().replace(" ", "").replace("-", "").replace(".", "") if raw.startswith("PT"): raw = raw[2:] return raw def _strip_accents(value: Any) -> str: text_value = unicodedata.normalize("NFKD", _clean(value)) return "".join(ch for ch in text_value if not unicodedata.combining(ch)) def _normalize_company_name(value: Any) -> str: """Normalize fiscal/company names for identity matching. Business rule: company fiscal names and NIFs are unique. Therefore an exact/contained normalized company name is a strong identity signal when a source omits NIF (common in Odoo). Weak token overlap is still treated only as a helper for ranking, never as a process bridge. """ text_value = _strip_accents(value).upper() text_value = re.sub(r"[^A-Z0-9]+", " ", text_value) stop_words = { "LDA", "LTDA", "LIMITADA", "UNIPESSOAL", "SA", "S", "A", "SOCIEDADE", "COMERCIAL", "PORTUGAL", "PT", "THE", "COMPANY", } tokens = [tok for tok in text_value.split() if tok and tok not in stop_words] return " ".join(tokens) def _name_match_score(external_name: Any, *candidate_names: Any) -> int: external_key = _normalize_company_name(external_name) if not external_key or len(external_key) < 3: return 0 external_tokens = set(external_key.split()) best = 0 for candidate in candidate_names: candidate_key = _normalize_company_name(candidate) if not candidate_key or len(candidate_key) < 3: continue if candidate_key == external_key: best = max(best, 75) continue if external_key in candidate_key or candidate_key in external_key: best = max(best, 60) continue candidate_tokens = set(candidate_key.split()) if external_tokens and candidate_tokens: overlap = len(external_tokens & candidate_tokens) ratio = overlap / max(len(external_tokens), len(candidate_tokens)) if overlap >= 2 and ratio >= 0.50: best = max(best, 30) elif overlap >= 1 and ratio >= 0.50: best = max(best, 20) return best def _looks_like_company_fiscal_name(value: Any) -> bool: """Return true when a name has company/legal-name signals. Company names are unique in this project, but not every free-text name is a company fiscal name. This keeps personal contacts such as "Bruno Oliveira" out of the high-confidence path while allowing "ACZCO BRAGA ENERGY, LDA" or Odoo display names that include the fiscal name plus location suffix. """ text_value = _strip_accents(value).upper() tokens = set(re.sub(r"[^A-Z0-9]+", " ", text_value).split()) company_tokens = { "LDA", "LTDA", "LIMITADA", "UNIPESSOAL", "SA", "S", "A", "SOCIEDADE", "COMERCIAL", "EMPRESA", "ENERGY", "SOLUTIONS", } if tokens & company_tokens: return True # Typical official/commercial company names often have three or more # non-address tokens even when the legal suffix was omitted by the source. normalized = _normalize_company_name(value) return len(normalized.split()) >= 3 def _process_group_identity_label(match_key: str, items: List[Dict[str, Any]]) -> Tuple[str, str]: """Return UI confidence + explanation for a reconstructed process group. NIF remains absolute. Company fiscal name is also high-confidence by the project rule that company names are unique; personal/free-text names stay at medium confidence. """ if match_key == "nif": has_name_bridge = any( not _normalize_tax_id(item.get("customer_tax_id") or "") and _looks_like_company_fiscal_name(item.get("customer_name") or item.get("linked_customer_name")) for item in items ) if has_name_bridge: return "alta", "NIF + nome fiscal" return "alta", "NIF exato" if match_key == "email": return "média", "email" if match_key == "name": if any(_looks_like_company_fiscal_name(item.get("customer_name") or item.get("linked_customer_name")) for item in items): return "alta", "nome fiscal" return "média", "nome" return "média", match_key or "identidade" def recent_window_start(days: int = 3) -> str: """Inclusive start date for a short operational reconciliation window. "Últimos 3 dias" means today and the previous two calendar days. This keeps Reconciliação as a compact work queue instead of a historical import. """ from datetime import datetime, timedelta, timezone days = max(int(days or 1), 1) return (datetime.now(timezone.utc).date() - timedelta(days=days - 1)).isoformat() def _priority_for_action(action_code: str, *, fallback: str = "normal") -> str: code = str(action_code or "").upper() if code in {"SEND_PROFORMA", "SEND_INVOICE", "CONFIRM_PAYMENT", "PREPARE_ORDER", "VALIDATE_PHYSICAL_ORDER", "CREATE_SHIPMENT"}: return "alta" if code in {"REVIEW_MANUALLY", "REMOVE_FROM_LIST", "MARK_NO_INTEREST", "IGNORE_SPAM", "NO_ACTION", "IGNORE_BOUNCE"}: return "baixa" return fallback def ensure_reconciliation_schema() -> None: """Create additive reconciliation tables. This is intentionally not a destructive migration: it only creates tables, indexes and optional columns that allow ClientFlow to stage external data before an operator links or turns it into a commercial process. """ global _SCHEMA_READY if _SCHEMA_READY: return with engine.begin() as conn: conn.execute(text(""" CREATE TABLE IF NOT EXISTS reconciliation_items ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), source_system TEXT NOT NULL, external_type TEXT NOT NULL, external_id TEXT, title TEXT NOT NULL, description TEXT, status TEXT NOT NULL DEFAULT 'open', priority TEXT NOT NULL DEFAULT 'normal', suggested_action TEXT, confidence NUMERIC(4,3), opportunity_id UUID REFERENCES opportunities(id) ON DELETE SET NULL, customer_id UUID REFERENCES customers(id) ON DELETE SET NULL, customer_name TEXT, customer_email TEXT, customer_tax_id TEXT, document_number TEXT, document_date DATE, amount NUMERIC(12,2), currency TEXT NOT NULL DEFAULT 'EUR', payload JSONB NOT NULL DEFAULT '{}'::jsonb, resolution_note TEXT, idempotency_key TEXT, created_at TIMESTAMPTZ NOT NULL DEFAULT now(), updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), resolved_at TIMESTAMPTZ ) """)) for stmt in [ "ALTER TABLE reconciliation_items ADD COLUMN IF NOT EXISTS suggested_action TEXT", "ALTER TABLE reconciliation_items ADD COLUMN IF NOT EXISTS confidence NUMERIC(4,3)", "ALTER TABLE reconciliation_items ADD COLUMN IF NOT EXISTS resolution_note TEXT", "ALTER TABLE reconciliation_items ADD COLUMN IF NOT EXISTS idempotency_key TEXT", "ALTER TABLE reconciliation_items ADD COLUMN IF NOT EXISTS customer_tax_id TEXT", ]: conn.execute(text(stmt)) conn.execute(text(""" CREATE UNIQUE INDEX IF NOT EXISTS ux_reconciliation_items_source_external ON reconciliation_items(source_system, external_type, external_id) WHERE external_id IS NOT NULL AND external_id <> '' """)) conn.execute(text(""" CREATE UNIQUE INDEX IF NOT EXISTS ux_reconciliation_items_idempotency_key ON reconciliation_items(idempotency_key) WHERE idempotency_key IS NOT NULL AND idempotency_key <> '' """)) conn.execute(text("CREATE INDEX IF NOT EXISTS idx_reconciliation_status ON reconciliation_items(status, created_at DESC)")) conn.execute(text("CREATE INDEX IF NOT EXISTS idx_reconciliation_type ON reconciliation_items(external_type, status)")) conn.execute(text("CREATE INDEX IF NOT EXISTS idx_reconciliation_opportunity ON reconciliation_items(opportunity_id)")) conn.execute(text("CREATE INDEX IF NOT EXISTS idx_reconciliation_customer_tax_id ON reconciliation_items(customer_tax_id) WHERE customer_tax_id IS NOT NULL AND customer_tax_id <> ''")) conn.execute(text(""" CREATE TABLE IF NOT EXISTS operation_links ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), opportunity_id UUID NOT NULL REFERENCES opportunities(id) ON DELETE CASCADE, system TEXT NOT NULL, external_type TEXT NOT NULL, external_id TEXT, external_name TEXT, external_url TEXT, status TEXT NOT NULL DEFAULT 'pending', payload JSONB NOT NULL DEFAULT '{}'::jsonb, last_synced_at TIMESTAMPTZ, created_at TIMESTAMPTZ NOT NULL DEFAULT now(), updated_at TIMESTAMPTZ NOT NULL DEFAULT now() ) """)) conn.execute(text("CREATE UNIQUE INDEX IF NOT EXISTS ux_operation_links_key ON operation_links(opportunity_id, system, external_type)")) conn.execute(text("CREATE INDEX IF NOT EXISTS idx_operation_links_external_id ON operation_links(opportunity_id, system, external_type, external_id)")) conn.execute(text(""" CREATE TABLE IF NOT EXISTS external_customer_mappings ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), source_system TEXT NOT NULL, external_party_id TEXT NOT NULL, external_party_name TEXT, customer_id UUID REFERENCES customers(id) ON DELETE CASCADE, confidence TEXT NOT NULL DEFAULT 'confirmed', created_by TEXT NOT NULL DEFAULT 'operator', payload JSONB NOT NULL DEFAULT '{}'::jsonb, created_at TIMESTAMPTZ NOT NULL DEFAULT now(), updated_at TIMESTAMPTZ NOT NULL DEFAULT now() ) """)) conn.execute(text(""" CREATE UNIQUE INDEX IF NOT EXISTS ux_external_customer_mappings_source_party ON external_customer_mappings(source_system, external_party_id) """)) conn.execute(text("CREATE INDEX IF NOT EXISTS idx_external_customer_mappings_customer ON external_customer_mappings(customer_id)")) # v4.9.24: store operator decisions so rebuilds do not erase manual # knowledge about merge/split/link/ignore/history choices. conn.execute(text(""" CREATE TABLE IF NOT EXISTS reconciliation_decisions ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), decision_type TEXT NOT NULL, status TEXT, item_ids TEXT[] NOT NULL DEFAULT ARRAY[]::TEXT[], opportunity_id UUID REFERENCES opportunities(id) ON DELETE SET NULL, customer_id UUID REFERENCES customers(id) ON DELETE SET NULL, note TEXT, actor TEXT NOT NULL DEFAULT 'operator', payload JSONB NOT NULL DEFAULT '{}'::jsonb, created_at TIMESTAMPTZ NOT NULL DEFAULT now() ) """)) conn.execute(text("CREATE INDEX IF NOT EXISTS idx_reconciliation_decisions_type ON reconciliation_decisions(decision_type, created_at DESC)")) conn.execute(text("CREATE INDEX IF NOT EXISTS idx_reconciliation_decisions_opportunity ON reconciliation_decisions(opportunity_id)")) # v4.9.13: reconstructed Odoo processes can import the commercial # lines into the opportunity. Keep the schema guard additive so older # installs where the product module has not run yet still work. conn.execute(text(""" CREATE TABLE IF NOT EXISTS opportunity_items ( id UUID PRIMARY KEY, opportunity_id UUID NOT NULL REFERENCES opportunities(id) ON DELETE CASCADE, product_id UUID, sku TEXT, jasmin_sales_item TEXT, product_name TEXT NOT NULL, description TEXT, quantity NUMERIC(12,2) NOT NULL DEFAULT 1, unit_price NUMERIC(12,2) NOT NULL DEFAULT 0, discount_amount NUMERIC(12,2) NOT NULL DEFAULT 0, total_price NUMERIC(12,2) NOT NULL DEFAULT 0, status TEXT NOT NULL DEFAULT 'INTERESTED', metadata JSONB NOT NULL DEFAULT '{}'::jsonb, created_at TIMESTAMPTZ NOT NULL DEFAULT now(), updated_at TIMESTAMPTZ NOT NULL DEFAULT now() ) """)) for stmt in [ "ALTER TABLE opportunity_items ADD COLUMN IF NOT EXISTS metadata JSONB NOT NULL DEFAULT '{}'::jsonb", "ALTER TABLE opportunity_items ADD COLUMN IF NOT EXISTS status TEXT NOT NULL DEFAULT 'INTERESTED'", "CREATE INDEX IF NOT EXISTS idx_opportunity_items_opp ON opportunity_items(opportunity_id)", ]: conn.execute(text(stmt)) conn.execute(text(""" CREATE TABLE IF NOT EXISTS payment_proofs ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), opportunity_id UUID REFERENCES opportunities(id) ON DELETE SET NULL, customer_id UUID REFERENCES customers(id) ON DELETE SET NULL, reconciliation_item_id UUID REFERENCES reconciliation_items(id) ON DELETE SET NULL, source_system TEXT NOT NULL DEFAULT 'manual', source_ref TEXT, filename TEXT, file_url TEXT, amount NUMERIC(12,2), currency TEXT NOT NULL DEFAULT 'EUR', proof_date DATE, status TEXT NOT NULL DEFAULT 'pending_validation', note TEXT, payload JSONB NOT NULL DEFAULT '{}'::jsonb, created_at TIMESTAMPTZ NOT NULL DEFAULT now(), updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), validated_at TIMESTAMPTZ, validated_by TEXT ) """)) conn.execute(text("CREATE INDEX IF NOT EXISTS idx_payment_proofs_status ON payment_proofs(status, created_at DESC)")) conn.execute(text("CREATE INDEX IF NOT EXISTS idx_payment_proofs_opportunity ON payment_proofs(opportunity_id)")) # v4.9.7: keep the reconciliation page resilient on installs where # the commercial customer/opportunity link migration has not run yet. # These are additive guards only; they do not change existing data. conn.execute(text(""" CREATE TABLE IF NOT EXISTS customers ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), name TEXT NOT NULL DEFAULT '', tax_id TEXT, email TEXT, phone TEXT, street_name TEXT, postal_zone TEXT, city_name TEXT, country TEXT NOT NULL DEFAULT 'PT', jasmin_customer_party_key TEXT, jasmin_customer_id TEXT, metadata JSONB NOT NULL DEFAULT '{}'::jsonb, created_at TIMESTAMPTZ NOT NULL DEFAULT now(), updated_at TIMESTAMPTZ NOT NULL DEFAULT now() ) """)) for stmt in [ "ALTER TABLE customers ADD COLUMN IF NOT EXISTS tax_id TEXT", "ALTER TABLE customers ADD COLUMN IF NOT EXISTS email TEXT", "ALTER TABLE opportunities ADD COLUMN IF NOT EXISTS local_customer_id UUID REFERENCES customers(id) ON DELETE SET NULL", # v4.9.14: the process-link action touches several columns that may # be missing on long-lived installs where the table was created by # an older ClientFlow version. Keep all guards additive. "ALTER TABLE opportunities ADD COLUMN IF NOT EXISTS stage TEXT NOT NULL DEFAULT 'NEW_LEAD'", "ALTER TABLE opportunities ADD COLUMN IF NOT EXISTS status TEXT NOT NULL DEFAULT 'open'", "ALTER TABLE opportunities ADD COLUMN IF NOT EXISTS value_amount NUMERIC(12,2)", "ALTER TABLE opportunities ADD COLUMN IF NOT EXISTS currency TEXT NOT NULL DEFAULT 'EUR'", "ALTER TABLE opportunities ADD COLUMN IF NOT EXISTS last_action_code TEXT", "ALTER TABLE opportunities ADD COLUMN IF NOT EXISTS metadata JSONB NOT NULL DEFAULT '{}'::jsonb", "ALTER TABLE opportunities ADD COLUMN IF NOT EXISTS updated_at TIMESTAMPTZ NOT NULL DEFAULT now()", "ALTER TABLE operation_links ADD COLUMN IF NOT EXISTS external_id TEXT", "ALTER TABLE operation_links ADD COLUMN IF NOT EXISTS external_name TEXT", "ALTER TABLE operation_links ADD COLUMN IF NOT EXISTS external_url TEXT", "ALTER TABLE operation_links ADD COLUMN IF NOT EXISTS payload JSONB NOT NULL DEFAULT '{}'::jsonb", "ALTER TABLE operation_links ADD COLUMN IF NOT EXISTS last_synced_at TIMESTAMPTZ", "ALTER TABLE operation_links ADD COLUMN IF NOT EXISTS created_at TIMESTAMPTZ NOT NULL DEFAULT now()", "ALTER TABLE operation_links ADD COLUMN IF NOT EXISTS updated_at TIMESTAMPTZ NOT NULL DEFAULT now()", "ALTER TABLE opportunity_events ADD COLUMN IF NOT EXISTS task_id UUID", "ALTER TABLE opportunity_events ADD COLUMN IF NOT EXISTS action_code TEXT", "ALTER TABLE opportunity_events ADD COLUMN IF NOT EXISTS from_stage TEXT", "ALTER TABLE opportunity_events ADD COLUMN IF NOT EXISTS to_stage TEXT", "ALTER TABLE opportunity_events ADD COLUMN IF NOT EXISTS note TEXT", "ALTER TABLE opportunity_events ADD COLUMN IF NOT EXISTS payload JSONB NOT NULL DEFAULT '{}'::jsonb", "ALTER TABLE opportunity_events ADD COLUMN IF NOT EXISTS created_by TEXT NOT NULL DEFAULT 'system'", "ALTER TABLE opportunity_events ADD COLUMN IF NOT EXISTS created_at TIMESTAMPTZ NOT NULL DEFAULT now()", "ALTER TABLE opportunity_items ADD COLUMN IF NOT EXISTS product_id UUID", "ALTER TABLE opportunity_items ADD COLUMN IF NOT EXISTS sku TEXT", "ALTER TABLE opportunity_items ADD COLUMN IF NOT EXISTS jasmin_sales_item TEXT", "ALTER TABLE opportunity_items ADD COLUMN IF NOT EXISTS product_name TEXT", "ALTER TABLE opportunity_items ADD COLUMN IF NOT EXISTS description TEXT", "ALTER TABLE opportunity_items ADD COLUMN IF NOT EXISTS quantity NUMERIC(12,2) NOT NULL DEFAULT 1", "ALTER TABLE opportunity_items ADD COLUMN IF NOT EXISTS unit_price NUMERIC(12,2) NOT NULL DEFAULT 0", "ALTER TABLE opportunity_items ADD COLUMN IF NOT EXISTS discount_amount NUMERIC(12,2) NOT NULL DEFAULT 0", "ALTER TABLE opportunity_items ADD COLUMN IF NOT EXISTS total_price NUMERIC(12,2) NOT NULL DEFAULT 0", "ALTER TABLE opportunity_items ADD COLUMN IF NOT EXISTS status TEXT NOT NULL DEFAULT 'INTERESTED'", "ALTER TABLE opportunity_items ADD COLUMN IF NOT EXISTS metadata JSONB NOT NULL DEFAULT '{}'::jsonb", "ALTER TABLE opportunity_items ADD COLUMN IF NOT EXISTS created_at TIMESTAMPTZ NOT NULL DEFAULT now()", "ALTER TABLE opportunity_items ADD COLUMN IF NOT EXISTS updated_at TIMESTAMPTZ NOT NULL DEFAULT now()", ]: conn.execute(text(stmt)) conn.execute(text("CREATE INDEX IF NOT EXISTS idx_opportunities_local_customer ON opportunities(local_customer_id)")) _SCHEMA_READY = True def upsert_reconciliation_item( *, source_system: str, external_type: str, external_id: Optional[str] = None, title: str, description: str = "", status: str = "open", priority: str = "normal", suggested_action: Optional[str] = None, confidence: Optional[float] = None, opportunity_id: Optional[str] = None, customer_id: Optional[str] = None, customer_name: Optional[str] = None, customer_email: Optional[str] = None, customer_tax_id: Optional[str] = None, document_number: Optional[str] = None, document_date: Optional[str] = None, amount: Any = None, currency: str = "EUR", payload: Optional[Dict[str, Any]] = None, idempotency_key: Optional[str] = None, ) -> Dict[str, Any]: ensure_reconciliation_schema() source_system = _clean(source_system) or "external" external_type = _clean(external_type) or "external_record" external_id = _clean(external_id) or None idempotency_key = _clean(idempotency_key) or ( f"{source_system}:{external_type}:{external_id}" if external_id else None ) amount_value = _money_or_none(amount) customer_tax_id = _normalize_tax_id(customer_tax_id) or None with engine.begin() as conn: row = conn.execute(text(""" INSERT INTO reconciliation_items ( source_system, external_type, external_id, title, description, status, priority, suggested_action, confidence, opportunity_id, customer_id, customer_name, customer_email, customer_tax_id, document_number, document_date, amount, currency, payload, idempotency_key, updated_at ) VALUES ( :source_system, :external_type, :external_id, :title, :description, :status, :priority, :suggested_action, :confidence, CAST(:opportunity_id AS UUID), CAST(:customer_id AS UUID), :customer_name, :customer_email, :customer_tax_id, :document_number, CAST(:document_date AS DATE), :amount, :currency, CAST(:payload AS JSONB), :idempotency_key, now() ) ON CONFLICT (idempotency_key) WHERE idempotency_key IS NOT NULL AND idempotency_key <> '' DO UPDATE SET title = EXCLUDED.title, description = EXCLUDED.description, priority = EXCLUDED.priority, suggested_action = EXCLUDED.suggested_action, confidence = EXCLUDED.confidence, opportunity_id = COALESCE(reconciliation_items.opportunity_id, EXCLUDED.opportunity_id), customer_id = COALESCE(reconciliation_items.customer_id, EXCLUDED.customer_id), customer_name = COALESCE(EXCLUDED.customer_name, reconciliation_items.customer_name), customer_email = COALESCE(EXCLUDED.customer_email, reconciliation_items.customer_email), customer_tax_id = COALESCE(EXCLUDED.customer_tax_id, reconciliation_items.customer_tax_id), document_number = COALESCE(EXCLUDED.document_number, reconciliation_items.document_number), document_date = COALESCE(EXCLUDED.document_date, reconciliation_items.document_date), amount = COALESCE(EXCLUDED.amount, reconciliation_items.amount), currency = COALESCE(EXCLUDED.currency, reconciliation_items.currency), status = CASE WHEN reconciliation_items.status = 'ignored' AND reconciliation_items.payload ? 'window_cleanup' THEN EXCLUDED.status ELSE reconciliation_items.status END, resolution_note = CASE WHEN reconciliation_items.status = 'ignored' AND reconciliation_items.payload ? 'window_cleanup' THEN NULL ELSE reconciliation_items.resolution_note END, resolved_at = CASE WHEN reconciliation_items.status = 'ignored' AND reconciliation_items.payload ? 'window_cleanup' THEN NULL ELSE reconciliation_items.resolved_at END, payload = (CASE WHEN reconciliation_items.status = 'ignored' AND reconciliation_items.payload ? 'window_cleanup' THEN reconciliation_items.payload - 'window_cleanup' ELSE reconciliation_items.payload END) || EXCLUDED.payload, updated_at = now() RETURNING 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 """), { "source_system": source_system, "external_type": external_type, "external_id": external_id, "title": _clean(title) or "Item de reconciliação", "description": _clean(description), "status": _clean(status) or "open", "priority": _clean(priority) or "normal", "suggested_action": _clean(suggested_action) or None, "confidence": confidence, "opportunity_id": _uuid_or_none(opportunity_id), "customer_id": _uuid_or_none(customer_id), "customer_name": _clean(customer_name) or None, "customer_email": _clean(customer_email) or None, "customer_tax_id": customer_tax_id, "document_number": _clean(document_number) or None, "document_date": _clean(document_date) or None, "amount": amount_value, "currency": _clean(currency) or "EUR", "payload": _json(payload or {}), "idempotency_key": idempotency_key, }).mappings().first() return dict(row or {}) def list_reconciliation_items(*, status: str = "open", external_type: Optional[str] = None, limit: int = 200, days: Optional[int] = None) -> List[Dict[str, Any]]: ensure_reconciliation_schema() where = [] params: Dict[str, Any] = {"limit": int(limit)} status = _clean(status) or "open" if status != "all": where.append("ri.status = :status") params["status"] = status if external_type: where.append("ri.external_type = :external_type") params["external_type"] = external_type if days and status in {"open", "needs_review", "all"}: # Keep the default page focused on the recent operational window. # Manual/payment-proof items without a document_date stay visible; # dated external API candidates outside the window move out of view # until the operator chooses a wider historical filter/export. where.append("(ri.document_date IS NULL OR ri.document_date >= CAST(:recent_cutoff AS DATE))") params["recent_cutoff"] = recent_window_start(days) where_sql = "WHERE " + " AND ".join(where) if where else "" with engine.begin() as conn: rows = conn.execute(text(f""" SELECT ri.id::text, ri.source_system, ri.external_type, ri.external_id, ri.title, ri.description, ri.status, ri.priority, ri.suggested_action, ri.confidence, 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.resolution_note, ri.created_at, ri.updated_at, ri.resolved_at, o.title AS opportunity_title, c.name AS linked_customer_name FROM reconciliation_items ri LEFT JOIN opportunities o ON o.id = ri.opportunity_id LEFT JOIN customers c ON c.id = ri.customer_id {where_sql} ORDER BY CASE ri.priority WHEN 'alta' THEN 0 WHEN 'high' THEN 0 WHEN 'normal' THEN 1 ELSE 2 END, ri.updated_at DESC, ri.created_at DESC LIMIT :limit """), params).mappings().all() items = [dict(row) for row in rows] for item in items: if str(item.get("status") or "") in RECONCILIATION_OPEN_STATUSES: try: item["operation_suggestions"] = find_open_operation_suggestions_for_reconciliation(item, limit=2) except Exception as exc: # pragma: no cover - production safety guard # Suggestion lookup is helpful, but it must never break the # reconciliation page. If an older database/schema has a gap, # show the items without suggestions and log the cause. logger.warning("reconciliation suggestions failed for item %s: %s", item.get("id"), exc) item["operation_suggestions"] = [] item["operation_suggestions_error"] = str(exc) else: item["operation_suggestions"] = [] return items def get_reconciliation_item(item_id: str) -> Optional[Dict[str, Any]]: ensure_reconciliation_schema() with engine.begin() as conn: row = 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 id = CAST(:id AS UUID) LIMIT 1 """), {"id": item_id}).mappings().first() return dict(row) if row else None def reconciliation_summary(*, days: Optional[int] = None) -> Dict[str, int]: ensure_reconciliation_schema() where_sql = "" params: Dict[str, Any] = {} if days: where_sql = "WHERE document_date IS NULL OR document_date >= CAST(:recent_cutoff AS DATE)" params["recent_cutoff"] = recent_window_start(days) with engine.begin() as conn: rows = conn.execute(text(f""" SELECT status, external_type, COUNT(*) AS total FROM reconciliation_items {where_sql} GROUP BY status, external_type """), params).mappings().all() result: Dict[str, int] = { "open": 0, "needs_review": 0, "conflict": 0, "historical": 0, "documents": 0, "payments": 0, "external_sales": 0, "shipments": 0, "resolved": 0, "ignored": 0, } for row in rows: status = str(row.get("status") or "") typ = str(row.get("external_type") or "") total = int(row.get("total") or 0) if status in RECONCILIATION_OPEN_STATUSES: result["open"] += total if status == "needs_review": result["needs_review"] += total if status == "conflict": result["conflict"] += total if typ in DOCUMENT_EXTERNAL_TYPES: result["documents"] += total if typ in PAYMENT_EXTERNAL_TYPES: result["payments"] += total if typ == "odoo_sale_order": result["external_sales"] += total if typ == "packlink_shipment": result["shipments"] += total if status in {"resolved", "linked"}: result["resolved"] += total if status == "ignored": result["ignored"] += total if status == "historical": result["historical"] += total return result def cleanup_reconciliation_outside_window( *, days: int = 3, source_system: Optional[str] = None, limit: int = 1000, actor: str = "cleanup_reconciliation_window", ) -> Dict[str, Any]: """Mark open reconciliation candidates older than the working window as ignored. The reconciliation page is meant to be a short operational queue, not a historical import list. This helper keeps recent candidates visible and moves older API candidates out of the operator workflow without deleting source records or external documents. """ ensure_reconciliation_schema() from datetime import datetime, timedelta, timezone days = max(int(days or 3), 1) limit = min(max(int(limit or 1000), 1), 5000) source_system = _clean(source_system) or None cutoff = recent_window_start(days) params: Dict[str, Any] = {"cutoff": cutoff, "limit": limit} source_sql = "" if source_system: source_sql = "AND source_system = :source_system" params["source_system"] = source_system with engine.begin() as conn: rows = conn.execute(text(f""" SELECT id::text, source_system, external_type, document_number, customer_name, document_date, title FROM reconciliation_items WHERE status IN ('open', 'needs_review') {source_sql} AND document_date IS NOT NULL AND document_date < CAST(:cutoff AS DATE) ORDER BY document_date ASC, updated_at DESC LIMIT :limit """), params).mappings().all() ids = [str(r["id"]) for r in rows] if ids: conn.execute(text(""" UPDATE reconciliation_items SET status = 'ignored', resolution_note = COALESCE(NULLIF(resolution_note, ''), 'Ignorado automaticamente: fora da janela operacional de reconciliação.'), resolved_at = now(), updated_at = now(), payload = COALESCE(payload, '{}'::jsonb) || CAST(:payload AS JSONB) WHERE id = ANY(CAST(:ids AS UUID[])) """), { "ids": ids, "payload": _json({"window_cleanup": {"days": days, "cutoff": cutoff, "actor": actor, "source_system": source_system or "all"}}), }) return { "days": days, "cutoff": cutoff, "source_system": source_system or "all", "matched": len(rows), "ignored": len(ids), "items": [dict(row) for row in rows[:50]], } def reset_generated_reconciliation_items( *, days: Optional[int] = None, sources: Optional[list[str]] = None, statuses: Optional[list[str]] = None, limit: int = 1000, actor: str = "operator_ui", apply: bool = False, ) -> Dict[str, Any]: """Preview or delete generated reconciliation staging rows safely. This is the UI-safe version of scripts/reset_reconciliation_generated.py. It only targets generated external candidates and always protects rows that are already linked to an opportunity. When apply=True, a timestamped backup table is created before deleting rows. """ ensure_reconciliation_schema() from datetime import datetime, timezone allowed_sources = {"jasmin", "odoo", "packlink"} allowed_statuses = {"open", "needs_review", "ignored"} selected_sources = [str(s) for s in (sources or ["jasmin", "odoo", "packlink"]) if str(s) in allowed_sources] selected_statuses = [str(s) for s in (statuses or ["open", "needs_review", "ignored"]) if str(s) in allowed_statuses] if not selected_sources: selected_sources = ["jasmin", "odoo", "packlink"] if not selected_statuses: selected_statuses = ["open", "needs_review", "ignored"] clauses = [ "source_system = ANY(CAST(:sources AS TEXT[]))", "status = ANY(CAST(:statuses AS TEXT[]))", "opportunity_id IS NULL", ] params: Dict[str, Any] = {"sources": selected_sources, "statuses": selected_statuses} if days is not None: cutoff = recent_window_start(max(int(days or 1), 1)) params["cutoff"] = cutoff clauses.append("(document_date IS NULL OR document_date >= CAST(:cutoff AS DATE))") where_sql = " AND ".join(clauses) sample_limit = min(max(int(limit or 1000), 1), 5000) with engine.begin() as conn: counts = conn.execute(text(f""" SELECT source_system, external_type, status, COUNT(*) AS total FROM reconciliation_items WHERE {where_sql} GROUP BY source_system, external_type, status ORDER BY source_system, external_type, status """), params).mappings().all() rows = conn.execute(text(f""" SELECT id::text, source_system, external_type, status, document_number, customer_name, customer_tax_id, document_date, amount, title FROM reconciliation_items WHERE {where_sql} ORDER BY updated_at DESC, created_at DESC LIMIT :limit """), {**params, "limit": sample_limit}).mappings().all() matched = conn.execute(text(f"SELECT COUNT(*) FROM reconciliation_items WHERE {where_sql}"), params).scalar() or 0 backup_table = None deleted = 0 if apply and int(matched) > 0: backup_table = "reconciliation_items_reset_backup_" + datetime.now(timezone.utc).strftime("%Y%m%d_%H%M%S") conn.execute(text(f"CREATE TABLE {backup_table} AS SELECT * FROM reconciliation_items WHERE {where_sql}"), params) deleted = conn.execute(text(f"DELETE FROM reconciliation_items WHERE {where_sql}"), params).rowcount or 0 return { "days": days, "sources": selected_sources, "statuses": selected_statuses, "matched": int(matched), "deleted": int(deleted), "backup_table": backup_table, "counts": [dict(r) for r in counts], "items": [dict(r) for r in rows], "actor": actor, "applied": bool(apply), } def _norm_match_value(value: Any) -> str: """Normalize loose names/emails for conservative matching suggestions.""" return _clean(value).lower() def find_open_operation_suggestions_for_reconciliation(item: Dict[str, Any], *, limit: int = 3) -> List[Dict[str, Any]]: """Suggest open ClientFlow operations/opportunities that may match an external item. v4.9.11 makes this lookup resilient and less dependent on NIF. Exact NIF remains the strongest signal, but if Odoo/Jasmin do not provide VAT/NIF the system can still suggest by normalized company name or email. It never links automatically. """ ensure_reconciliation_schema() if not item or item.get("opportunity_id"): return [] customer_id = _uuid_or_none(item.get("customer_id")) customer_email = _norm_match_value(item.get("customer_email")) payload = item.get("payload") if isinstance(item.get("payload"), dict) else {} customer_tax_id = _normalize_tax_id( item.get("customer_tax_id") or payload.get("customer_tax_id") or payload.get("tax_id") or "" ) customer_name = _clean(item.get("customer_name") or item.get("linked_customer_name")) odoo_partner_external_id = _odoo_partner_external_id_from_item(item) if str(item.get("source_system") or "") == "odoo" else "" mapped_customer_id = "" if odoo_partner_external_id: try: with engine.begin() as conn: mapped_customer_id = _clean(conn.execute(text(""" SELECT customer_id::text FROM external_customer_mappings WHERE source_system = 'odoo' AND external_party_id = :external_party_id LIMIT 1 """), {"external_party_id": odoo_partner_external_id}).scalar()) except Exception: mapped_customer_id = "" amount_value = _money_or_none(item.get("amount")) amount_decimal: Optional[Decimal] = None if amount_value is not None: try: amount_decimal = Decimal(amount_value) except (InvalidOperation, ValueError): amount_decimal = None limit = min(max(int(limit or 3), 1), 5) # Keep the old broad guard semantics: if not any([customer_id, customer_tax_id, customer_email, customer_name, amount_value]) if not any([customer_id, customer_tax_id, customer_email, customer_name, amount_decimal is not None]): return [] # Fetch a bounded set of open opportunities and score in Python. This is # more robust than a large SQL CASE expression across installations with # slightly different schemas/data types, and avoids a single bad record # breaking /reconciliation. with engine.begin() as conn: rows = conn.execute(text(""" SELECT o.id::text AS opportunity_id, o.title AS opportunity_title, o.stage, o.status, o.customer_name, o.customer_email, o.value_amount, o.local_customer_id::text AS local_customer_id, o.updated_at, lc.name AS linked_customer_name, lc.email AS linked_customer_email, lc.tax_id AS linked_customer_tax_id, t.id::text AS task_id, t.action_code, t.action, t.route, t.created_at AS task_created_at FROM opportunities o LEFT JOIN customers lc ON lc.id = o.local_customer_id LEFT JOIN LATERAL ( SELECT id, action_code, action, route, created_at FROM tasks WHERE opportunity_id = o.id AND status = 'pending' ORDER BY CASE WHEN action_code IN ('SEND_INVOICE','SEND_PROFORMA','CONFIRM_PAYMENT','SEND_QUOTE') THEN 0 ELSE 1 END, created_at DESC LIMIT 1 ) t ON true WHERE o.status = 'open' ORDER BY CASE WHEN t.id IS NOT NULL THEN 0 ELSE 1 END, o.updated_at DESC LIMIT 250 """)).mappings().all() suggestions: List[Dict[str, Any]] = [] for row in rows: score = 0 reasons: List[str] = [] if mapped_customer_id and str(row.get("local_customer_id") or "") == mapped_customer_id: score += 120 reasons.append("mapeamento Odoo confirmado") if customer_id and str(row.get("local_customer_id") or "") == customer_id: score += 60 reasons.append("cliente fiscal") linked_tax = _normalize_tax_id(row.get("linked_customer_tax_id") or "") # Equivalent of the old SQL signal: COALESCE(lc.tax_id, '') = :customer_tax_id THEN 90 ELSE 0 # but normalized in Python so PT prefixes/spaces do not break matching. if customer_tax_id and linked_tax and linked_tax == customer_tax_id: score += 100 reasons.append("NIF exato") row_emails = { _norm_match_value(row.get("customer_email")), _norm_match_value(row.get("linked_customer_email")), } if customer_email and customer_email in row_emails: score += 45 reasons.append("email") name_score = _name_match_score( customer_name, row.get("linked_customer_name"), row.get("customer_name"), row.get("opportunity_title"), ) name_is_strong = name_score >= 35 if name_score: score += name_score reasons.append("nome fiscal" if name_is_strong else "nome parcial") if amount_decimal is not None and row.get("value_amount") is not None: try: row_amount = Decimal(str(row.get("value_amount"))) if row_amount >= (amount_decimal * Decimal("0.90")) and row_amount <= (amount_decimal * Decimal("1.10")): score += 10 reasons.append("valor aproximado") except (InvalidOperation, ValueError, TypeError): pass if row.get("task_id"): score += 20 reasons.append("operação aberta") # NIF/email/customer-id are strong. By project rule, a strong normalized # company fiscal-name match is also a useful identity signal. Weak token # overlap must not become a suggestion just because there is a pending # task or a similar amount. strong_identity = bool(mapped_customer_id and str(row.get("local_customer_id") or "") == mapped_customer_id) or bool(customer_tax_id and linked_tax == customer_tax_id) or bool(customer_email and customer_email in row_emails) or bool(customer_id and str(row.get("local_customer_id") or "") == customer_id) if not strong_identity and name_score and not name_is_strong: continue threshold = 35 if strong_identity else 40 if score < threshold: continue suggestions.append({ "opportunity_id": row.get("opportunity_id"), "opportunity_title": row.get("opportunity_title") or "Oportunidade", "customer_name": row.get("linked_customer_name") or row.get("customer_name") or "", "task_id": row.get("task_id"), "action_code": row.get("action_code") or "", "action": row.get("action") or "", "score": int(score), "reason": ", ".join(dict.fromkeys(reasons)) or "possível correspondência", }) suggestions.sort(key=lambda s: int(s.get("score") or 0), reverse=True) return suggestions[:limit] # v4.9.9 — process timeline reconstruction # ------------------------------------------------------------ # Reconciliação is more useful when loose external items are shown as a # possible commercial process instead of isolated rows. These helpers group # open candidates by strong identity keys (NIF first, then email/name) and infer # a conservative timeline/state proposal. They never link or create anything # until the operator explicitly confirms the action in the UI. def _payload_record(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 _odoo_partner_external_id_from_item(item: Dict[str, Any]) -> str: record = _payload_record(item) partner_id = record.get("partner_external_id") if partner_id: return _clean(partner_id) value = record.get("partner_id") if isinstance(value, (list, tuple)) and value: return _clean(value[0]) return _clean(value) def _odoo_fulfilment_from_item(item: Dict[str, Any]) -> Dict[str, Any]: record = _payload_record(item) fulfilment = record.get("fulfilment") if isinstance(fulfilment, dict): return fulfilment # Compatibility with older payloads: infer only the invoice pending flag. invoice_status = _clean(record.get("invoice_status")) return { "invoice_status": invoice_status, "invoice_pending": invoice_status in {"to invoice", "no"}, "physical_status": "order_created", "label": "Venda criada", "stage": "ODOO_ORDER_CREATED", "lines": record.get("order_lines") if isinstance(record.get("order_lines"), list) else [], "outgoing_pickings": record.get("pickings") if isinstance(record.get("pickings"), list) else [], } def _odoo_sale_link_payload(item: Dict[str, Any]) -> Dict[str, Any]: record = _payload_record(item) return { "sale_order": { "id": record.get("id") or item.get("external_id"), "name": record.get("name") or item.get("document_number"), "state": record.get("state"), "partner_id": record.get("partner_id"), "partner_external_id": record.get("partner_external_id"), "partner_name": record.get("partner_name") or item.get("customer_name"), "amount_total": record.get("amount_total") or item.get("amount"), "date_order": record.get("date_order") or item.get("document_date"), "invoice_status": record.get("invoice_status"), }, "order_lines": record.get("order_lines") if isinstance(record.get("order_lines"), list) else [], "fulfilment": _odoo_fulfilment_from_item(item), } def _upsert_external_customer_mapping_from_process(conn: Any, item: Dict[str, Any], opportunity_id: str, *, actor: str) -> None: if str(item.get("source_system") or "") != "odoo": return external_party_id = _odoo_partner_external_id_from_item(item) if not external_party_id: return row = conn.execute(text(""" SELECT o.local_customer_id::text AS customer_id, c.name AS customer_name FROM opportunities o LEFT JOIN customers c ON c.id = o.local_customer_id WHERE o.id = CAST(:opportunity_id AS UUID) LIMIT 1 """), {"opportunity_id": opportunity_id}).mappings().first() customer_id = _clean(row.get("customer_id") if row else "") if not customer_id: return record = _payload_record(item) external_party_name = _clean(record.get("partner_name") or item.get("customer_name")) conn.execute(text(""" INSERT INTO external_customer_mappings (source_system, external_party_id, external_party_name, customer_id, confidence, created_by, payload, updated_at) VALUES ('odoo', :external_party_id, :external_party_name, CAST(:customer_id AS UUID), 'confirmed', :actor, CAST(:payload AS JSONB), now()) ON CONFLICT (source_system, external_party_id) DO UPDATE SET customer_id = EXCLUDED.customer_id, external_party_name = EXCLUDED.external_party_name, confidence = 'confirmed', created_by = EXCLUDED.created_by, payload = external_customer_mappings.payload || EXCLUDED.payload, updated_at = now() """), { "external_party_id": external_party_id, "external_party_name": external_party_name, "customer_id": customer_id, "actor": actor, "payload": _json({"confirmed_from_opportunity_id": opportunity_id, "customer_name": row.get("customer_name") if row else ""}), }) def _upsert_odoo_operation_links_from_item(conn: Any, item: Dict[str, Any], opportunity_id: str) -> None: if str(item.get("external_type") or "") != "odoo_sale_order": return record = _payload_record(item) fulfilment = _odoo_fulfilment_from_item(item) sale_name = _clean(record.get("name") or item.get("document_number") or item.get("external_id")) sale_id = _clean(record.get("id") or item.get("external_id")) conn.execute(text(""" INSERT INTO operation_links (opportunity_id, system, external_type, external_id, external_name, external_url, status, payload, last_synced_at, updated_at) VALUES (CAST(:opportunity_id AS UUID), 'odoo', 'sale_order', :external_id, :external_name, NULL, 'created', CAST(:payload AS JSONB), now(), now()) ON CONFLICT (opportunity_id, system, external_type) DO UPDATE SET external_id = EXCLUDED.external_id, external_name = EXCLUDED.external_name, status = EXCLUDED.status, payload = operation_links.payload || EXCLUDED.payload, last_synced_at = now(), updated_at = now() """), { "opportunity_id": opportunity_id, "external_id": sale_id, "external_name": sale_name, "payload": _json(_odoo_sale_link_payload(item)), }) conn.execute(text(""" INSERT INTO operation_links (opportunity_id, system, external_type, external_id, external_name, external_url, status, payload, last_synced_at, updated_at) VALUES (CAST(:opportunity_id AS UUID), 'odoo', 'physical_status', :external_id, :external_name, NULL, :status, CAST(:payload AS JSONB), now(), now()) ON CONFLICT (opportunity_id, system, external_type) DO UPDATE SET external_id = EXCLUDED.external_id, external_name = EXCLUDED.external_name, status = EXCLUDED.status, payload = operation_links.payload || EXCLUDED.payload, last_synced_at = now(), updated_at = now() """), { "opportunity_id": opportunity_id, "external_id": sale_id, "external_name": sale_name, "status": _clean(fulfilment.get("physical_status")) or "order_created", "payload": _json({"sale_order": sale_name, **fulfilment}), }) productions = fulfilment.get("productions") if isinstance(fulfilment.get("productions"), list) else [] if productions: done_count = sum(1 for production in productions if _clean(production.get("state")) == "done") status = "done" if done_count == len(productions) else "in_progress" label = f"{done_count}/{len(productions)} produções concluídas" if done_count != len(productions) else f"{done_count} produções concluídas" conn.execute(text(""" INSERT INTO operation_links (opportunity_id, system, external_type, external_id, external_name, external_url, status, payload, last_synced_at, updated_at) VALUES (CAST(:opportunity_id AS UUID), 'odoo', 'production', :external_id, :external_name, NULL, :status, CAST(:payload AS JSONB), now(), now()) ON CONFLICT (opportunity_id, system, external_type) DO UPDATE SET external_id = EXCLUDED.external_id, external_name = EXCLUDED.external_name, status = EXCLUDED.status, payload = operation_links.payload || EXCLUDED.payload, last_synced_at = now(), updated_at = now() """), { "opportunity_id": opportunity_id, "external_id": sale_id, "external_name": label, "status": status, "payload": _json({"sale_order": sale_name, "productions": productions}), }) # v132: Odoo ``assigned`` is only a reserved picking. Never create a # physical_validation link from it. That evidence is created exclusively # by completing VALIDATE_PHYSICAL_ORDER (or by an unequivocal Odoo done). if fulfilment.get("delivery_done"): conn.execute(text(""" INSERT INTO operation_links (opportunity_id, system, external_type, external_id, external_name, external_url, status, payload, last_synced_at, updated_at) VALUES (CAST(:opportunity_id AS UUID), 'odoo', 'physical_validation', :external_id, :external_name, NULL, 'validated', CAST(:payload AS JSONB), now(), now()) ON CONFLICT (opportunity_id, system, external_type) DO UPDATE SET external_id = EXCLUDED.external_id, external_name = EXCLUDED.external_name, status = EXCLUDED.status, payload = operation_links.payload || EXCLUDED.payload, last_synced_at = now(), updated_at = now() """), { "opportunity_id": opportunity_id, "external_id": sale_id, "external_name": "Entrega concluída", "payload": _json({"sale_order": sale_name, **fulfilment}), }) from app.odoo_delivery_task_reconciliation import reconcile_odoo_delivery_done reconcile_odoo_delivery_done( conn, opportunity_id, evidence={"sale_order": {"id": sale_id, "name": sale_name}, **fulfilment}, actor="reconciliation_odoo_sync", upsert_validation=False, ) elif fulfilment.get("delivery_ready"): conn.execute(text(""" DELETE FROM operation_links WHERE opportunity_id = CAST(:opportunity_id AS UUID) AND system = 'odoo' AND external_type = 'physical_validation' AND status IN ('ready_to_ship','pending') """), {"opportunity_id": opportunity_id}) def _odoo_importable_lines(item: Dict[str, Any]) -> List[Dict[str, Any]]: fulfilment = _odoo_fulfilment_from_item(item) lines = fulfilment.get("lines") if isinstance(fulfilment.get("lines"), list) else [] result: List[Dict[str, Any]] = [] for line in lines: if not isinstance(line, dict): continue name = _clean(line.get("product_name") or line.get("description")) if not name: continue total = _money_or_none(line.get("price_total")) or "0.00" unit = _money_or_none(line.get("price_unit")) or "0.00" qty = _money_or_none(line.get("qty_ordered") or line.get("quantity") or "1") or "1.00" # Zero-value Odoo delivery carrier lines are operational context, not # commercial product lines for Jasmin invoicing. Keep them in the # operation link payload but do not pollute Produtos. if Decimal(total) == Decimal("0.00") and ("delivery" in name.lower() or "shipping" in name.lower()): continue result.append({**line, "product_name": name, "price_total": total, "price_unit": unit, "qty_ordered": qty}) return result def _odoo_product_sku_from_line(line: Dict[str, Any]) -> Optional[str]: """Return the ClientFlow SKU used to map Odoo products to Jasmin items. The Odoo API usually returns numeric product ids in sale order lines, while the ClientFlow catalogue stores them as ``ODOO-``. Using the raw id as SKU (``3``) made imported opportunity lines look unmapped even when the catalogue had ``ODOO-3 -> CARREGADOR_TRIF_22KW`` configured. """ raw = _clean(line.get("product_id") or line.get("odoo_product_id") or line.get("sku")) if not raw: return None if raw.upper().startswith("ODOO-"): return raw.upper() if raw.isdigit(): return f"ODOO-{raw}" return raw def _normalize_product_name(value: Any) -> str: text_value = unicodedata.normalize("NFKD", _clean(value)).encode("ascii", "ignore").decode("ascii") return re.sub(r"[^A-Z0-9]+", "", text_value.upper()) def _resolve_product_mapping_for_odoo_line(conn: Any, line: Dict[str, Any]) -> Dict[str, Any]: """Resolve an imported Odoo line against the ClientFlow product catalogue. Priority: 1. ``products.sku = ODOO-`` 2. ``products.metadata->odoo->product_id`` 3. exact normalized product name fallback """ sku = _odoo_product_sku_from_line(line) product_id_raw = _clean(line.get("product_id")) row = None if sku: row = conn.execute(text(""" SELECT id::text, sku, jasmin_sales_item, name FROM products WHERE sku = :sku LIMIT 1 """), {"sku": sku}).mappings().first() if row is None and product_id_raw: row = conn.execute(text(""" SELECT id::text, sku, jasmin_sales_item, name FROM products WHERE metadata->'odoo'->>'product_id' = :product_id LIMIT 1 """), {"product_id": product_id_raw}).mappings().first() if row is None: wanted = _normalize_product_name(line.get("product_name") or line.get("description")) if wanted: for candidate in conn.execute(text(""" SELECT id::text, sku, jasmin_sales_item, name FROM products WHERE active IS TRUE LIMIT 500 """)).mappings().all(): if _normalize_product_name(candidate.get("name")) == wanted: row = candidate break if row is None: return {"product_id": None, "sku": sku, "jasmin_sales_item": None, "catalog_name": None, "mapping_status": "missing"} return { "product_id": row.get("id"), "sku": row.get("sku") or sku, "jasmin_sales_item": row.get("jasmin_sales_item"), "catalog_name": row.get("name"), "mapping_status": "mapped" if row.get("jasmin_sales_item") else "missing_jasmin", } def _upsert_opportunity_items_from_odoo_item(conn: Any, item: Dict[str, Any], opportunity_id: str) -> None: if str(item.get("external_type") or "") != "odoo_sale_order": return record = _payload_record(item) sale_name = _clean(record.get("name") or item.get("document_number") or item.get("external_id")) for line in _odoo_importable_lines(item): line_id = _clean(line.get("id") or line.get("product_id") or line.get("product_name")) qty = _money_or_none(line.get("qty_ordered") or "1") or "1.00" unit_price = _money_or_none(line.get("price_unit") or "0") or "0.00" total_price = _money_or_none(line.get("price_total") or "0") or "0.00" status = "DELIVERED" if float(line.get("qty_delivered") or 0) >= float(line.get("qty_ordered") or 0 or 0) and float(line.get("qty_ordered") or 0) > 0 else "ODOO_IMPORTED" product_mapping = _resolve_product_mapping_for_odoo_line(conn, line) conn.execute(text(""" INSERT INTO opportunity_items ( id, opportunity_id, product_id, sku, jasmin_sales_item, product_name, description, quantity, unit_price, discount_amount, total_price, status, metadata ) SELECT CAST(:id AS UUID), CAST(:opportunity_id AS UUID), CAST(:product_id AS UUID), :sku, :jasmin_sales_item, :product_name, :description, :quantity, :unit_price, 0, :total_price, :status, CAST(:metadata AS JSONB) WHERE NOT EXISTS ( SELECT 1 FROM opportunity_items WHERE opportunity_id = CAST(:opportunity_id AS UUID) AND metadata->>'source_system' = 'odoo' AND metadata->>'source_line_id' = :source_line_id ) """), { "id": str(uuid.uuid4()), "opportunity_id": opportunity_id, "product_id": product_mapping.get("product_id"), "sku": product_mapping.get("sku"), "jasmin_sales_item": product_mapping.get("jasmin_sales_item"), "product_name": line.get("product_name"), "description": line.get("description") or line.get("product_name"), "quantity": qty, "unit_price": unit_price, "total_price": total_price, "status": status, "source_line_id": line_id, "metadata": _json({ "source_system": "odoo", "source_document": sale_name, "source_line_id": line_id, "qty_delivered": line.get("qty_delivered"), "qty_invoiced": line.get("qty_invoiced"), "product_id": line.get("product_id"), "resolved_sku": product_mapping.get("sku"), "resolved_jasmin_sales_item": product_mapping.get("jasmin_sales_item"), "product_mapping_status": product_mapping.get("mapping_status"), "catalog_name": product_mapping.get("catalog_name"), }), }) # v4.9.26 — Jasmin document reconstruction # ------------------------------------------------------------ # When a reconciliation candidate is created from a Jasmin quotation/pro-forma/ # invoice, the opportunity must not become an empty reminder. These helpers # import the external document itself, copy its commercial lines into # commercial_document_lines/opportunity_items and update the opportunity value. def _first_value(record: Dict[str, Any], *keys: str) -> Any: for key in keys: if key in record and record.get(key) not in (None, ""): return record.get(key) return None def _date_or_none_value(value: Any) -> Optional[str]: if value is None: return None if isinstance(value, datetime): return value.date().isoformat() text_value = _clean(value) if len(text_value) >= 10 and text_value[4:5] == "-" and text_value[7:8] == "-": return text_value[:10] return None def _jasmin_money_value(value: Any) -> Any: """Return a numeric-like value from Jasmin money objects. Jasmin documents commonly encode money as either a scalar field (for example ``grossValueAmount``) or as an object like ``{"amount": 199.0, "symbol": "€", ...}``. The previous importer passed those objects directly to Decimal, which produced null/0 values even though the payload had prices. """ 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_string(value: Any, *, default: Optional[str] = None, places: str = "0.01") -> Optional[str]: value = _jasmin_money_value(value) raw = _clean(value).replace("€", "").replace(" ", "").replace(",", ".") if raw == "": return default try: return str(Decimal(raw).quantize(Decimal(places))) except (InvalidOperation, ValueError): return default def _jasmin_document_kind_for_item(item: Dict[str, Any]) -> str: external_type = _clean(item.get("external_type")) return { "jasmin_quotation": "quotation", "jasmin_proforma": "proforma", "jasmin_invoice": "invoice", }.get(external_type, "document") def _jasmin_document_status_for_item(item: Dict[str, Any]) -> str: record = _payload_record(item) status = _clean(_first_value(record, "status", "documentStatus", "state", "statusDescription")) return status or "imported" def _jasmin_first_money(record: Dict[str, Any], *keys: str) -> Any: for key in keys: value = _first_value(record, key) if value not in (None, ""): return _jasmin_money_value(value) return None def _jasmin_document_totals(item: Dict[str, Any]) -> Dict[str, Optional[str]]: record = _payload_record(item) amount = _decimal_string(_jasmin_first_money( record, "taxExclusiveAmountAmount", "grossValueAmount", "netAmount", "taxExclusiveAmount", "goodsAmount", "grossValue", "amount", )) tax_amount = _decimal_string(_jasmin_first_money( record, "taxTotalAmount", "taxAmountAmount", "taxAmount", "taxTotal", "vatAmount", )) total_amount = _decimal_string(_jasmin_first_money( record, "payableAmountAmount", "lineExtensionAmountAmount", "totalAmountAmount", "payableAmount", "totalAmount", "total", "grossAmount", "lineExtensionAmount", "amount", ) or item.get("amount")) if not amount and total_amount: amount = total_amount return {"amount": amount, "tax_amount": tax_amount, "total_amount": total_amount} def _jasmin_line_lists(record: Dict[str, Any]) -> List[Dict[str, Any]]: result: List[Dict[str, Any]] = [] for key in ( "lines", "documentLines", "document_lines", "documentLine", "salesLines", "orderLines", "quotationLines", "invoiceLines", "items", ): value = record.get(key) if isinstance(value, list): result.extend([line for line in value if isinstance(line, dict)]) return result def _jasmin_sales_item_from_line(line: Dict[str, Any]) -> str: value = _first_value( line, "salesItem", "salesItemId", "salesItemKey", "itemKey", "itemCode", "item", "article", "productCode", "sku", ) if isinstance(value, dict): value = _first_value(value, "key", "id", "code", "name") return _clean(value) def _jasmin_description_from_line(line: Dict[str, Any]) -> str: value = _first_value( line, "description", "itemDescription", "salesItemDescription", "productName", "name", "articleDescription", ) if isinstance(value, dict): value = _first_value(value, "description", "name", "key") description = _clean(value) return description or _jasmin_sales_item_from_line(line) or "Linha Jasmin" def _jasmin_document_lines_from_item(item: Dict[str, Any]) -> List[Dict[str, Any]]: record = _payload_record(item) lines = [] for idx, line in enumerate(_jasmin_line_lists(record)): description = _jasmin_description_from_line(line) jasmin_sales_item = _jasmin_sales_item_from_line(line) quantity = _decimal_string(_first_value(line, "quantity", "qty", "orderedQuantity", "invoicedQuantity"), default="1.000", places="0.001") or "1.000" unit = _clean(_first_value(line, "unit", "unitKey", "unitOfMeasure", "unitOfMeasureCode")) or "UN" unit_price = _decimal_string(_jasmin_first_money( line, "unitPriceAmount", "unitPrice", "priceAmount", "price", "unitAmount", ), default="0.00") or "0.00" total_amount = _decimal_string(_jasmin_first_money( line, "lineExtensionAmountAmount", "totalAmountAmount", "grossValueAmount", "taxExclusiveAmountAmount", "totalAmount", "lineExtensionAmount", "grossValue", "netAmount", "amount", "grossAmount", )) if total_amount is None: try: total_amount = str((Decimal(quantity) * Decimal(unit_price)).quantize(Decimal("0.01"))) except (InvalidOperation, ValueError): total_amount = "0.00" line_id = _clean(_first_value(line, "id", "lineId", "lineNumber", "naturalKey", "key")) or f"{idx}:{jasmin_sales_item or description}" tax_schema = _clean(_first_value(line, "taxSchema", "itemTaxSchema", "taxSchemaKey", "vatCode")) or "NORMAL" lines.append({ "source_line_id": line_id, "line_index": idx, "jasmin_sales_item": jasmin_sales_item, "description": description, "quantity": quantity, "unit": unit, "unit_price": unit_price, "tax_schema": tax_schema, "total_amount": total_amount, "payload": line, }) return lines def _resolve_product_mapping_for_jasmin_line(conn: Any, line: Dict[str, Any]) -> Dict[str, Any]: jasmin_sales_item = _clean(line.get("jasmin_sales_item")) row = None if jasmin_sales_item: row = conn.execute(text(""" SELECT id::text, sku, jasmin_sales_item, name FROM products WHERE jasmin_sales_item = :jasmin_sales_item OR sku = :jasmin_sales_item LIMIT 1 """), {"jasmin_sales_item": jasmin_sales_item}).mappings().first() if row is None: wanted = _normalize_product_name(line.get("description")) if wanted: for candidate in conn.execute(text(""" SELECT id::text, sku, jasmin_sales_item, name FROM products WHERE active IS TRUE LIMIT 500 """)).mappings().all(): if _normalize_product_name(candidate.get("name")) == wanted: row = candidate break if row is None: return {"product_id": None, "sku": None, "jasmin_sales_item": jasmin_sales_item or None, "catalog_name": None, "mapping_status": "missing"} return { "product_id": row.get("id"), "sku": row.get("sku"), "jasmin_sales_item": row.get("jasmin_sales_item") or jasmin_sales_item, "catalog_name": row.get("name"), "mapping_status": "mapped" if row.get("id") else "missing", } def _upsert_jasmin_document_from_item(conn: Any, item: Dict[str, Any], opportunity_id: str) -> Optional[str]: if _clean(item.get("source_system")) != "jasmin" or not _clean(item.get("external_type")).startswith("jasmin_"): return None try: from app.commercial_service import ensure_commercial_schema ensure_commercial_schema() except Exception as exc: # pragma: no cover - safety guard logger.warning("failed to ensure commercial schema for Jasmin import: %s", exc) return None record = _payload_record(item) document_kind = _jasmin_document_kind_for_item(item) totals = _jasmin_document_totals(item) 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 FROM commercial_documents WHERE system = 'jasmin' AND ( (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)) ) 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() payload = { "source": "reconciliation_jasmin_import", "reconciliation_item_id": item.get("id"), "external_type": item.get("external_type"), "record": record, } params = { "id": existing or str(uuid.uuid4()), "customer_id": customer_id, "opportunity_id": opportunity_id, "document_kind": document_kind, "external_id": external_id or None, "company": _clean(_first_value(record, "company", "companyKey")) or None, "document_type": _clean(_first_value(record, "documentType", "documentTypeKey")) or None, "serie": _clean(_first_value(record, "serie", "serieKey", "series")) or None, "series_number": _clean(_first_value(record, "seriesNumber", "sequenceNumber")) or None, "document_number": document_number or None, "customer_party_key": _clean(_first_value(record, "buyerCustomerParty", "customerParty", "customerPartyKey", "partyKey")) or None, "status": _jasmin_document_status_for_item(item), "amount": totals.get("amount"), "tax_amount": totals.get("tax_amount"), "total_amount": totals.get("total_amount"), "currency": item.get("currency") or _clean(_first_value(record, "currency", "currencyKey", "currencyCode")) or "EUR", "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, } 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 SET customer_id = COALESCE(CAST(:customer_id AS UUID), customer_id), opportunity_id = CAST(:opportunity_id AS UUID), document_kind = :document_kind, external_id = COALESCE(:external_id, external_id), company = COALESCE(:company, company), document_type = COALESCE(:document_type, document_type), serie = COALESCE(:serie, serie), series_number = CASE WHEN CAST(:series_number AS TEXT) ~ '^[0-9]+$' THEN CAST(:series_number AS INTEGER) ELSE series_number END, document_number = COALESCE(:document_number, document_number), customer_party_key = COALESCE(:customer_party_key, customer_party_key), status = COALESCE(:status, status), amount = COALESCE(CAST(:amount AS NUMERIC), amount), tax_amount = COALESCE(CAST(:tax_amount AS NUMERIC), tax_amount), total_amount = COALESCE(CAST(:total_amount AS NUMERIC), total_amount), 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) """), params) document_id = existing conn.execute(text("DELETE FROM commercial_document_lines WHERE document_id = CAST(:document_id AS UUID)"), {"document_id": document_id}) else: conn.execute(text(""" INSERT INTO commercial_documents ( id, customer_id, opportunity_id, system, document_kind, external_id, company, document_type, serie, series_number, document_number, customer_party_key, status, amount, tax_amount, total_amount, currency, role, is_primary, document_date, due_date, payload, updated_at ) VALUES ( CAST(:id AS UUID), CAST(:customer_id AS UUID), CAST(:opportunity_id AS UUID), 'jasmin', :document_kind, :external_id, :company, :document_type, :serie, CASE WHEN CAST(:series_number AS TEXT) ~ '^[0-9]+$' THEN CAST(:series_number AS INTEGER) ELSE NULL END, :document_number, :customer_party_key, :status, CAST(:amount AS NUMERIC), CAST(:tax_amount AS NUMERIC), CAST(:total_amount AS NUMERIC), :currency, :role, :is_primary, CAST(:document_date AS DATE), CAST(:due_date AS DATE), CAST(:payload AS JSONB), now() ) """), params) document_id = params["id"] 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) ) """), { "document_id": document_id, "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"), "description": line.get("description"), "quantity": line.get("quantity"), "unit": line.get("unit"), "unit_price": line.get("unit_price"), "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")}), }) return document_id def _upsert_opportunity_items_from_jasmin_item(conn: Any, item: Dict[str, Any], opportunity_id: str) -> int: 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")) upserted = 0 for line in _jasmin_document_lines_from_item(item): mapping = _resolve_product_mapping_for_jasmin_line(conn, line) source_line_id = _clean(line.get("source_line_id")) params = { "id": str(uuid.uuid4()), "opportunity_id": opportunity_id, "product_id": mapping.get("product_id"), "sku": mapping.get("sku"), "jasmin_sales_item": mapping.get("jasmin_sales_item") or line.get("jasmin_sales_item"), "product_name": mapping.get("catalog_name") or line.get("description"), "description": line.get("description"), "quantity": line.get("quantity"), "unit_price": line.get("unit_price"), "total_price": line.get("total_amount"), "status": "JASMIN_IMPORTED", "source_document": document_ref, "source_line_id": source_line_id, "metadata": _json({ "source_system": "jasmin", "source_document": document_ref, "source_line_id": source_line_id, "source_external_id": item.get("external_id"), "source_external_type": item.get("external_type"), "resolved_sku": mapping.get("sku"), "resolved_jasmin_sales_item": mapping.get("jasmin_sales_item"), "product_mapping_status": mapping.get("mapping_status"), "catalog_name": mapping.get("catalog_name"), "price_source": "jasmin_document_line", }), } updated = conn.execute(text(""" UPDATE opportunity_items SET product_id = COALESCE(CAST(:product_id AS UUID), product_id), sku = COALESCE(:sku, sku), jasmin_sales_item = COALESCE(:jasmin_sales_item, jasmin_sales_item), product_name = COALESCE(:product_name, product_name), description = COALESCE(:description, description), quantity = CAST(:quantity AS NUMERIC), unit_price = CAST(:unit_price AS NUMERIC), total_price = CAST(:total_price AS NUMERIC), status = CASE WHEN status IN ('REJECTED', 'CANCELLED') THEN status ELSE :status END, metadata = COALESCE(metadata, '{}'::jsonb) || CAST(:metadata AS JSONB), updated_at = now() WHERE opportunity_id = CAST(:opportunity_id AS UUID) AND metadata->>'source_system' = 'jasmin' AND metadata->>'source_document' = :source_document AND metadata->>'source_line_id' = :source_line_id """), params).rowcount or 0 if updated: upserted += updated continue inserted = conn.execute(text(""" INSERT INTO opportunity_items ( id, opportunity_id, product_id, sku, jasmin_sales_item, product_name, description, quantity, unit_price, discount_amount, total_price, status, metadata ) SELECT CAST(:id AS UUID), CAST(:opportunity_id AS UUID), CAST(:product_id AS UUID), :sku, :jasmin_sales_item, :product_name, :description, CAST(:quantity AS NUMERIC), CAST(:unit_price AS NUMERIC), 0, CAST(:total_price AS NUMERIC), :status, CAST(:metadata AS JSONB) WHERE NOT EXISTS ( SELECT 1 FROM opportunity_items WHERE opportunity_id = CAST(:opportunity_id AS UUID) AND metadata->>'source_system' = 'jasmin' AND metadata->>'source_document' = :source_document AND metadata->>'source_line_id' = :source_line_id ) """), params).rowcount or 0 upserted += inserted return upserted def _apply_jasmin_documents_to_opportunity(conn: Any, items: List[Dict[str, Any]], opportunity_id: str, *, actor: str) -> Dict[str, Any]: jasmin_items = [item for item in items if _clean(item.get("source_system")) == "jasmin" and _clean(item.get("external_type")).startswith("jasmin_")] if not jasmin_items: return {"documents": 0, "lines": 0} document_count = 0 line_count = 0 product_names: List[str] = [] best_amount = None for item in jasmin_items: if _upsert_jasmin_document_from_item(conn, item, opportunity_id): document_count += 1 line_count += _upsert_opportunity_items_from_jasmin_item(conn, item, opportunity_id) for line in _jasmin_document_lines_from_item(item): name = _clean(line.get("description")) if name and name not in product_names: product_names.append(name) totals = _jasmin_document_totals(item) best_amount = best_amount or totals.get("total_amount") or item.get("amount") product_interest = ", ".join(product_names[:3]) if len(product_names) > 3: product_interest += f" +{len(product_names)-3} linha(s)" conn.execute(text(""" UPDATE opportunities SET value_amount = CASE WHEN CAST(:amount AS NUMERIC) IS NOT NULL AND (value_amount IS NULL OR value_amount = 0) THEN CAST(:amount AS NUMERIC) ELSE value_amount END, product_interest = CASE WHEN :product_interest <> '' AND (product_interest IS NULL OR product_interest = '' OR product_interest ILIKE 'Processo importado%%') THEN :product_interest ELSE product_interest END, metadata = COALESCE(metadata, '{}'::jsonb) || CAST(:metadata AS JSONB), updated_at = now() WHERE id = CAST(:opportunity_id AS UUID) """), { "opportunity_id": opportunity_id, "amount": _money_or_none(best_amount), "product_interest": product_interest, "metadata": _json({ "jasmin_reconciliation_imported": True, "jasmin_documents_imported": document_count, "jasmin_lines_imported": line_count, "jasmin_import_actor": actor, }), }) conn.execute(text(""" UPDATE opportunities SET value_amount = COALESCE(( SELECT SUM(total_price) FROM opportunity_items WHERE opportunity_id = CAST(:opportunity_id AS UUID) AND status NOT IN ('REJECTED', 'CANCELLED') ), value_amount, 0), updated_at = now() WHERE id = CAST(:opportunity_id AS UUID) AND (value_amount IS NULL OR value_amount = 0) AND EXISTS (SELECT 1 FROM opportunity_items WHERE opportunity_id = CAST(:opportunity_id AS UUID)) """), {"opportunity_id": opportunity_id}) return {"documents": document_count, "lines": line_count, "product_interest": product_interest} def _ensure_pending_task_for_reconstruction(conn: Any, opportunity_id: str, action_code: str, *, note: str, actor: str) -> None: action_code = _clean(action_code).upper() or "REVIEW_MANUALLY" exists = conn.execute(text(""" SELECT id::text FROM tasks WHERE opportunity_id = CAST(:opportunity_id AS UUID) AND status = 'pending' AND action_code = :action_code LIMIT 1 """), {"opportunity_id": opportunity_id, "action_code": action_code}).scalar() if exists: return try: config = get_action_config(action_code) conn.execute(text(""" INSERT INTO tasks ( id, opportunity_id, action_code, route, action, note, action_required, safe_to_post, status, source_system, source_event_id, idempotency_key, metadata, created_at, updated_at ) VALUES ( CAST(:id AS UUID), CAST(:opportunity_id AS UUID), :action_code, :route, :action, :note, TRUE, FALSE, 'pending', 'reconciliation_process', :source_event_id, :idempotency_key, CAST(:metadata AS JSONB), now(), now() ) ON CONFLICT (idempotency_key) DO NOTHING """), { "id": str(uuid.uuid4()), "opportunity_id": opportunity_id, "action_code": action_code, "route": config.get("route") or "financeiro", "action": config.get("action") or action_code, "note": note, "source_event_id": f"apply:{opportunity_id}:{action_code}", "idempotency_key": f"task:reconciliation_apply:{opportunity_id}:{action_code}", "metadata": _json({"source": "reconciliation_apply", "actor": actor}), }) except Exception as exc: # pragma: no cover - production safety guard logger.warning("failed to create reconstruction task for %s: %s", opportunity_id, exc) def _apply_reconstructed_process_to_opportunity(conn: Any, items: List[Dict[str, Any]], opportunity_id: str, *, actor: str) -> Dict[str, Any]: """Apply reconstructed evidence to the opportunity summary/pipeline. Linking is no longer just a loose reference: Odoo/Jasmin evidence updates operation_links, imports Odoo lines into Produtos, writes timeline events and moves the visual stage to the most advanced reconstructed step. """ state = infer_reconciliation_process_state(items) stage = _clean(state.get("stage")) or "REVIEW" action_code = _clean(state.get("action_code")) or "REVIEW_MANUALLY" amount = next((item.get("amount") for item in items if item.get("amount") is not None), None) amount_value = _money_or_none(amount) odoo_items = [item for item in items if str(item.get("external_type") or "") == "odoo_sale_order"] for item in items: _upsert_external_customer_mapping_from_process(conn, item, opportunity_id, actor=actor) _upsert_odoo_operation_links_from_item(conn, item, opportunity_id) _upsert_opportunity_items_from_odoo_item(conn, item, opportunity_id) jasmin_import = _apply_jasmin_documents_to_opportunity(conn, items, opportunity_id, actor=actor) current = conn.execute(text(""" SELECT stage, last_action_code, COALESCE(metadata, '{}'::jsonb) AS metadata FROM opportunities WHERE id = CAST(:opportunity_id AS UUID) FOR UPDATE """), {"opportunity_id": opportunity_id}).mappings().first() or {} try: from app.opportunity_service import OPPORTUNITY_STAGE_RANK current_rank = int(OPPORTUNITY_STAGE_RANK.get(_clean(current.get("stage")), 0)) suggested_rank = int(OPPORTUNITY_STAGE_RANK.get(stage, 0)) except Exception: current_rank = suggested_rank = 0 preserve_current = bool(_clean(current.get("stage"))) and current_rank >= suggested_rank effective_stage = _clean(current.get("stage")) if preserve_current else stage effective_action_code = _clean(current.get("last_action_code")) if preserve_current else action_code effective_action_code = effective_action_code or action_code current_review_status = reconstructed_review_status(current.get("metadata")) requires_review = ( canonical_action_code(action_code) in {canonical_action_code(code) for code in RECONSTRUCTED_SENSITIVE_ACTIONS} and current_review_status not in {"validated", "waived"} ) review_patch = ( reconstructed_review_metadata_patch( "required", actor=actor, reason="Processo reconstruído com ação sensível pendente.", blocked_action_code=action_code, ) if requires_review else {} ) metadata_payload = { "reconstruction_applied": True, "actor": actor, "suggested_stage": stage, "suggested_action": action_code, "effective_stage": effective_stage, "effective_action": effective_action_code, "stage_regression_prevented": preserve_current and effective_stage != stage, "item_ids": [str(item.get("id")) for item in items if item.get("id")], "jasmin_import": jasmin_import, "clientflow_record_mode": "reconstructed_invoice_review", **review_patch, } conn.execute(text(""" UPDATE opportunities SET stage = :stage, last_action_code = :action_code, value_amount = CASE WHEN CAST(:amount AS NUMERIC) IS NOT NULL AND (value_amount IS NULL OR value_amount = 0) THEN CAST(:amount AS NUMERIC) ELSE value_amount END, metadata = COALESCE(metadata, '{}'::jsonb) || CAST(:metadata AS JSONB), updated_at = now() WHERE id = CAST(:opportunity_id AS UUID) """), { "opportunity_id": opportunity_id, "stage": effective_stage, "action_code": effective_action_code, "amount": amount_value, "metadata": _json(metadata_payload), }) # If Odoo lines were imported and the opportunity value was still zero, # recalculate from opportunity_items. This preserves an existing manual # value when one has already been set. conn.execute(text(""" UPDATE opportunities SET value_amount = COALESCE(( SELECT SUM(total_price) FROM opportunity_items WHERE opportunity_id = CAST(:opportunity_id AS UUID) AND status NOT IN ('REJECTED', 'CANCELLED') ), value_amount, 0), updated_at = now() WHERE id = CAST(:opportunity_id AS UUID) AND (value_amount IS NULL OR value_amount = 0) 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()) if odoo_items and effective_action_code == "SEND_INVOICE" and not has_invoice: _ensure_pending_task_for_reconstruction( conn, opportunity_id, "REVIEW_RECONSTRUCTED_PROCESS" if requires_review else effective_action_code, note=( "Validar cliente, documentos e valor do processo reconstruído antes de enviar fatura." if requires_review else "Processo reconstruído: validar se falta emitir/enviar fatura antes de avançar." ), actor=actor, ) return { "stage": effective_stage, "action_code": effective_action_code, "amount": amount_value, "stage_regression_prevented": preserve_current and effective_stage != stage, } PROCESS_STEP_BY_EXTERNAL_TYPE = { "manual_request": (5, "Pedido externo registado", "QUOTE_REQUESTED", "SEND_QUOTE"), "jasmin_quotation": (20, "Orçamento encontrado no Jasmin", "QUOTE_SENT", "SEND_PROFORMA"), "jasmin_proforma": (30, "Orçamento para pagamento encontrado no Jasmin", "WAITING_PAYMENT", "CONFIRM_PAYMENT"), "payment_proof": (40, "Comprovativo de pagamento recebido", "WAITING_PAYMENT", "CONFIRM_PAYMENT"), "jasmin_invoice": (50, "Fatura encontrada no Jasmin", "INVOICE_SENT", "CONFIRM_PAYMENT"), "odoo_sale_order": (60, "Venda/encomenda encontrada no Odoo", "ODOO_ORDER_CREATED", "SEND_INVOICE"), "packlink_shipment": (80, "Envio encontrado na Packlink", "SHIPMENT_CREATED", "REVIEW_MANUALLY"), } def _process_identity_keys(item: Dict[str, Any]) -> List[Tuple[str, str]]: """Return all usable identities for grouping external evidence. The fiscal customer is still the strongest identity, but real-world data is uneven: Jasmin usually has NIF while Odoo may only have the partner/company name. v4.9.17 therefore keeps all identities for the same item and lets the grouping layer merge evidence by any shared key. Example: a Jasmin quote with NIF + name and an Odoo sale with only the same normalized name become one process candidate instead of a quote candidate with the Odoo sale shown merely as a suggestion. """ keys: List[Tuple[str, str]] = [] tax_id = _normalize_tax_id(item.get("customer_tax_id") or "") if tax_id: keys.append(("nif", tax_id)) email = _norm_match_value(item.get("customer_email")) if email: keys.append(("email", email)) name = _normalize_company_name(item.get("customer_name") or item.get("linked_customer_name")) if name and len(name) >= 5: keys.append(("name", name)) return keys def _process_identity_key(item: Dict[str, Any]) -> Optional[Tuple[str, str]]: """Backward-compatible strongest identity for older callers/tests.""" keys = _process_identity_keys(item) return keys[0] if keys else None def _best_process_group_key(items: List[Dict[str, Any]]) -> Optional[Tuple[str, str]]: """Pick the strongest display key for a merged process group.""" priority = {"nif": 0, "email": 1, "name": 2} keys: List[Tuple[str, str]] = [] for item in items: keys.extend(_process_identity_keys(item)) if not keys: return None # Deduplicate while preserving deterministic ordering by strength/value. unique = sorted(set(keys), key=lambda x: (priority.get(x[0], 99), x[1])) return unique[0] def _base_process_step(item: Dict[str, Any]) -> Dict[str, Any]: external_type = str(item.get("external_type") or "external_record") rank, label, stage, action_code = PROCESS_STEP_BY_EXTERNAL_TYPE.get( external_type, (10, "Evidência externa encontrada", STAGE_BY_EXTERNAL_TYPE.get(external_type, "REVIEW"), NEXT_ACTION_BY_EXTERNAL_TYPE.get(external_type, "REVIEW_MANUALLY")), ) return { "rank": rank, "label": label, "stage": stage, "action_code": item.get("suggested_action") or action_code, "item_id": item.get("id"), "source_system": item.get("source_system"), "external_type": external_type, "document_number": item.get("document_number") or item.get("external_id"), "document_date": item.get("document_date"), "amount": item.get("amount"), "currency": item.get("currency") or "EUR", "title": item.get("title"), } def _process_steps_for_item(item: Dict[str, Any]) -> List[Dict[str, Any]]: base = _base_process_step(item) if str(item.get("external_type") or "") != "odoo_sale_order": return [base] steps = [base] fulfilment = _odoo_fulfilment_from_item(item) lines = fulfilment.get("lines") if isinstance(fulfilment.get("lines"), list) else [] if lines: steps.append({**base, "rank": 62, "label": "Linhas/produtos importados do Odoo", "stage": "ODOO_ORDER_CREATED", "action_code": "SEND_INVOICE", "external_type": "odoo_sale_lines", "products": [line.get("product_name") or line.get("description") for line in lines if line.get("product_name") or line.get("description")]}) outgoing = fulfilment.get("outgoing_pickings") if isinstance(fulfilment.get("outgoing_pickings"), list) else [] done_pickings = [p for p in outgoing if _clean(p.get("state")) == "done"] ready_pickings = [p for p in outgoing if _clean(p.get("state")) == "assigned"] if done_pickings: for picking in done_pickings: steps.append({**base, "rank": 75, "label": "Entrega Odoo concluída", "stage": "SHIPPED", "action_code": "SEND_INVOICE", "external_type": "odoo_delivery", "document_number": picking.get("name"), "document_date": _clean(picking.get("date_done"))[:10] or base.get("document_date"), "picking_state": picking.get("state")}) elif ready_pickings: for picking in ready_pickings: steps.append({**base, "rank": 70, "label": "Picking Odoo reservado — validação física pendente", "stage": "ORDER_PREPARATION", "action_code": "VALIDATE_PHYSICAL_ORDER", "external_type": "odoo_delivery", "document_number": picking.get("name"), "document_date": _clean(picking.get("scheduled_date"))[:10] or base.get("document_date"), "picking_state": picking.get("state")}) if fulfilment.get("invoice_pending"): steps.append({**base, "rank": 85, "label": "Fatura por emitir", "stage": "SHIPMENT_CREATED" if done_pickings else "ODOO_ORDER_CREATED", "action_code": "SEND_INVOICE", "external_type": "odoo_invoice_pending", "document_number": base.get("document_number")}) return steps def _process_step(item: Dict[str, Any]) -> Dict[str, Any]: return _process_steps_for_item(item)[0] def _operation_document_ref(item: Dict[str, Any]) -> str: """Stable document/order reference used to avoid merging two orders. Customer identity tells us who the company is; this reference tells us which commercial operation the evidence belongs to. It is deliberately based on external document/order numbers rather than the customer name. """ record = _payload_record(item) return _clean( record.get("name") or item.get("document_number") or record.get("id") or item.get("external_id") ) def _operation_primary_anchor(item: Dict[str, Any]) -> str: """Return a strong operation anchor for a specific purchase/process. Fiscal identity answers "who is the customer". This anchor answers "which purchase/process is this evidence about". Odoo sales are always anchors, and Jasmin commercial documents are anchors too so two quotations for the same fiscal customer do not collapse into one giant opportunity. """ external_type = str(item.get("external_type") or "") ref = _operation_document_ref(item) anchor_types = { "odoo_sale_order", "jasmin_quotation", "jasmin_proforma", "jasmin_invoice", } if external_type in anchor_types and ref: return f"{external_type}:{ref}" return "" def _date_prefix(value: Any) -> str: return _clean(value)[:10] def _date_distance_days(a: Any, b: Any) -> Optional[int]: a_value = _date_prefix(a) b_value = _date_prefix(b) if not a_value or not b_value: return None try: return abs((datetime.fromisoformat(a_value).date() - datetime.fromisoformat(b_value).date()).days) except Exception: return None def _operation_match_score(item: Dict[str, Any], anchor: Dict[str, Any]) -> int: """Score whether an evidence item belongs to a specific purchase anchor.""" if item is anchor or item.get("id") == anchor.get("id"): return 1000 score = 0 item_ref = _operation_document_ref(item) anchor_ref = _operation_document_ref(anchor) if item_ref and anchor_ref and item_ref == anchor_ref: score += 100 item_amount = _money_or_none(item.get("amount")) anchor_amount = _money_or_none(anchor.get("amount")) if item_amount is not None and anchor_amount is not None: try: item_dec = Decimal(item_amount) anchor_dec = Decimal(anchor_amount) if item_dec == anchor_dec: score += 70 elif anchor_dec and item_dec >= (anchor_dec * Decimal("0.90")) and item_dec <= (anchor_dec * Decimal("1.10")): score += 35 except (InvalidOperation, ValueError, TypeError): pass distance = _date_distance_days(item.get("document_date"), anchor.get("document_date")) if distance is not None: if distance == 0: score += 25 elif distance <= 3: score += 20 elif distance <= 10: score += 12 elif distance <= 30: score += 5 # Odoo fulfilment lines are often the best differentiator when the same # company places two orders close together. Only count explicit overlap. item_products = { _normalize_product_name(line.get("product_name") or line.get("description")) for line in _odoo_importable_lines(item) if _normalize_product_name(line.get("product_name") or line.get("description")) } anchor_products = { _normalize_product_name(line.get("product_name") or line.get("description")) for line in _odoo_importable_lines(anchor) if _normalize_product_name(line.get("product_name") or line.get("description")) } if item_products and anchor_products and item_products & anchor_products: score += 25 return score def _operation_anchors_can_merge(left: Dict[str, Any], right: Dict[str, Any]) -> bool: """Return true when two purchase anchors are clearly the same process. Conservative rules: - Same external document/order reference is a hard match. - Two Odoo sale orders with different refs are always different purchases. - Two anchors of the same document class with different refs remain separate. - Different document classes may merge only with strong amount/date/product evidence, e.g. quote ORC2026.154 + sale S00279 for the same amount. """ left_ref = _operation_document_ref(left) right_ref = _operation_document_ref(right) if left_ref and right_ref and left_ref == right_ref: return True left_type = str(left.get("external_type") or "") right_type = str(right.get("external_type") or "") if left_type == "odoo_sale_order" and right_type == "odoo_sale_order": return False if left_type == right_type: return False score = _operation_match_score(left, right) if score >= 90: return True # Backward-compatible fiscal-name bridge: when Jasmin gives the NIF/name # and Odoo only gives the name, a single Odoo sale close to the Jasmin # commercial document should still reconstruct one process even when the # Jasmin amount was not imported. Multiple Odoo orders remain separated by # their own anchors and loose items must still match unambiguously. if {left_type, right_type} & {"odoo_sale_order"}: distance = _date_distance_days(left.get("document_date"), right.get("document_date")) left_amount = _money_or_none(left.get("amount")) right_amount = _money_or_none(right.get("amount")) if distance is not None and distance <= 3 and (left_amount is None or right_amount is None): return True return False def _split_items_by_operation_identity(items: List[Dict[str, Any]]) -> List[List[Dict[str, Any]]]: """Split one fiscal customer group into separate commercial purchases. The first pass intentionally merges evidence by fiscal identity (NIF/email/fiscal name). This second pass prevents "one customer = one process" mistakes. Every Odoo sale and every Jasmin commercial document is a purchase anchor; anchors are merged only when there is strong evidence they are the same purchase. Payments/shipments/manual evidence are assigned only when the best amount/date/reference/product match is unambiguous. """ anchors = [item for item in items if _operation_primary_anchor(item)] if len(anchors) <= 1: return [items] parent = list(range(len(anchors))) def find(idx: int) -> int: while parent[idx] != idx: parent[idx] = parent[parent[idx]] idx = parent[idx] return idx def union(a: int, b: int) -> None: ra, rb = find(a), find(b) if ra != rb: parent[rb] = ra for i, left in enumerate(anchors): for j, right in enumerate(anchors[i + 1:], start=i + 1): if _operation_anchors_can_merge(left, right): union(i, j) grouped_anchor_indexes: Dict[int, List[int]] = {} for idx in range(len(anchors)): grouped_anchor_indexes.setdefault(find(idx), []).append(idx) buckets: List[Dict[str, Any]] = [] for indexes in grouped_anchor_indexes.values(): anchor_items = [anchors[idx] for idx in indexes] # Prefer Odoo sale order as the visible purchase key, then quotation, # proforma and invoice. This keeps UI labels stable for real orders. anchor_items.sort(key=lambda item: { "odoo_sale_order": 0, "jasmin_quotation": 1, "jasmin_proforma": 2, "jasmin_invoice": 3, }.get(str(item.get("external_type") or ""), 9)) buckets.append({"anchor": anchor_items[0], "items": anchor_items, "key": _operation_primary_anchor(anchor_items[0])}) anchor_ids = {str(anchor.get("id") or id(anchor)) for anchor in anchors} standalone: List[List[Dict[str, Any]]] = [] for item in items: if str(item.get("id") or id(item)) in anchor_ids: continue scored = sorted( ((_operation_match_score(item, bucket["anchor"]), idx) for idx, bucket in enumerate(buckets)), key=lambda pair: pair[0], reverse=True, ) best_score, best_idx = scored[0] second_score = scored[1][0] if len(scored) > 1 else -1 if best_score >= 70 and best_score > second_score: buckets[best_idx]["items"].append(item) else: standalone.append([item]) return [bucket["items"] for bucket in buckets] + standalone def _operation_group_suffix(items: List[Dict[str, Any]]) -> str: preferred_types = ["odoo_sale_order", "jasmin_quotation", "jasmin_proforma", "jasmin_invoice"] for preferred_type in preferred_types: for item in items: if str(item.get("external_type") or "") != preferred_type: continue anchor = _operation_primary_anchor(item) if anchor: return anchor for item in items: ref = _operation_document_ref(item) if ref: return f"document:{ref}" return "" def infer_reconciliation_process_state(items: List[Dict[str, Any]]) -> Dict[str, Any]: """Infer a conservative process stage/next action from grouped evidence.""" steps = sorted([step for item in items for step in _process_steps_for_item(item)], key=lambda x: (x.get("rank") or 0, str(x.get("document_date") or ""))) if not steps: return {"stage": "REVIEW", "action_code": "REVIEW_MANUALLY", "label": "Processo por rever"} strongest = max(steps, key=lambda x: int(x.get("rank") or 0)) stage = strongest.get("stage") or "REVIEW" action_code = strongest.get("action_code") or "REVIEW_MANUALLY" # If a sale/order already exists, the important next check is usually # invoice/payment status, not creating another commercial document. external_types = {str(item.get("external_type") or "") for item in items} if "odoo_sale_order" in external_types and "jasmin_invoice" not in external_types: action_code = "SEND_INVOICE" if any(step.get("external_type") == "odoo_delivery" for step in steps): stage = "SHIPPED" if "payment_proof" in external_types: action_code = "CONFIRM_PAYMENT" if "packlink_shipment" in external_types and "jasmin_invoice" not in external_types: action_code = "SEND_INVOICE" return { "stage": stage, "action_code": action_code, "label": strongest.get("label") or "Processo reconstruído", "steps": steps, } def _process_amount_values(items: List[Dict[str, Any]]) -> List[Decimal]: values: List[Decimal] = [] for item in items: value = _money_or_none(item.get("amount")) if value is None: continue try: values.append(Decimal(value)) except (InvalidOperation, ValueError, TypeError): continue return values def _candidate_reasons_and_risks(group: Dict[str, Any], items: List[Dict[str, Any]], *, identity_reason: str, confidence_label: str) -> Dict[str, Any]: """Explain why a process candidate was proposed and what needs review. The operator should never have to trust an opaque confidence label. This helper produces short, UI-ready reasons and risks separated from the final state/action inference. It is deliberately conservative: customer identity can be high-confidence while the purchase grouping still needs review. """ reasons: List[str] = [] risks: List[str] = [] external_types = {str(item.get("external_type") or "") for item in items} if identity_reason: reasons.append(f"cliente fiscal por {identity_reason}") if group.get("operation_key"): reasons.append("compra/processo separado por referência documental") if group.get("split_from_multi_purchase"): reasons.append("cliente com várias compras detectadas; este cartão foi separado") risks.append("mesmo cliente fiscal tem outros processos na janela") if len(items) > 1: reasons.append(f"{len(items)} evidências agrupadas") if group.get("suggestions"): reasons.append("existe sugestão de oportunidade aberta") amounts = _process_amount_values(items) amount_conflict = False amount_conflict_values: List[str] = [] if len(amounts) >= 2: unique_amounts = sorted(set(amounts)) amount_conflict_values = [str(value.quantize(Decimal("0.01"))) for value in unique_amounts] min_amount, max_amount = min(unique_amounts), max(unique_amounts) if len(unique_amounts) == 1: reasons.append("valor igual entre documentos") elif min_amount and max_amount <= (min_amount * Decimal("1.10")): reasons.append("valor aproximado entre documentos") else: amount_conflict = True risks.append("conflito financeiro: valores divergentes entre documentos") risks.append("bloquear auto-associação financeira até revisão") dated_items = [item for item in items if item.get("document_date")] if len(dated_items) >= 2: distances = [ _date_distance_days(left.get("document_date"), right.get("document_date")) for pos, left in enumerate(dated_items) for right in dated_items[pos + 1:] ] distances = [d for d in distances if d is not None] if distances: if min(distances) <= 3: reasons.append("datas próximas") if max(distances) > 30: risks.append("datas afastadas; pode ser histórico ou compra diferente") if "odoo_sale_order" in external_types and "jasmin_invoice" not in external_types: risks.append("venda Odoo sem fatura Jasmin associada") if "jasmin_invoice" in external_types and not ({"odoo_sale_order", "jasmin_quotation", "jasmin_proforma"} & external_types): risks.append("fatura solta; confirmar se é histórico") if "payment_proof" in external_types and "jasmin_invoice" not in external_types: risks.append("comprovativo sem fatura associada") if not any(_normalize_tax_id(item.get("customer_tax_id") or "") for item in items): risks.append("cliente sem NIF; validar nome fiscal") if confidence_label != "alta": risks.append("confiança de cliente/processo não é alta") # Deterministic de-duplication with stable order. reasons = list(dict.fromkeys([r for r in reasons if r])) risks = list(dict.fromkeys([r for r in risks if r])) if amount_conflict or (risks and any("valores diferentes" in risk or "valor divergente" in risk or "compra diferente" in risk for risk in risks)): review_status = "conflict" elif risks: review_status = "needs_review" else: review_status = "ready" return { "reasons": reasons, "risks": risks, "review_status": review_status, "financial_conflict": amount_conflict, "amount_conflict_values": amount_conflict_values if amount_conflict else [], } def _record_reconciliation_decision( conn: Any, *, decision_type: str, item_ids: Optional[List[str]] = None, status: Optional[str] = None, opportunity_id: Optional[str] = None, customer_id: Optional[str] = None, note: str = "", actor: str = "operator", payload: Optional[Dict[str, Any]] = None, ) -> None: """Persist an operator/system decision without failing core actions. Long-lived installations may receive this table through the additive schema guard. If a local database is inconsistent, the original link/create/ignore action should still complete, so failures are logged only. """ try: conn.execute(text(""" INSERT INTO reconciliation_decisions ( decision_type, status, item_ids, opportunity_id, customer_id, note, actor, payload ) VALUES ( :decision_type, :status, CAST(:item_ids AS TEXT[]), CAST(:opportunity_id AS UUID), CAST(:customer_id AS UUID), :note, :actor, CAST(:payload AS JSONB) ) """), { "decision_type": _clean(decision_type) or "unknown", "status": _clean(status) or None, "item_ids": [str(x) for x in (item_ids or []) if str(x)], "opportunity_id": _uuid_or_none(opportunity_id), "customer_id": _uuid_or_none(customer_id), "note": _clean(note) or None, "actor": _clean(actor) or "operator", "payload": _json(payload or {}), }) except Exception as exc: # pragma: no cover - production safety guard logger.warning("failed to record reconciliation decision %s: %s", decision_type, exc) def list_reconciliation_process_candidates(*, status: str = "open", days: int = 3, limit: int = 20) -> List[Dict[str, Any]]: """Return grouped process candidates built from recent open items. A candidate is shown when at least two pieces of evidence share the same NIF/email/name, when one item already has a strong open-operation suggestion, or when one important external item is actionable by itself (for example an Odoo sale order without ClientFlow). """ try: items = list_reconciliation_items(status=status, limit=300, days=days) except Exception as exc: # pragma: no cover - production safety guard logger.warning("failed to list reconciliation items for process candidates: %s", exc) return [] # Build connected components from every usable identity key. NIF is absolute # and normalized company fiscal name is also a strong business key in this # project. This avoids splitting one real process into separate cards when # Jasmin has NIF + name and Odoo has only the company name. indexed_items: List[Dict[str, Any]] = [] parent: List[int] = [] key_owner: Dict[str, int] = {} def find(idx: int) -> int: while parent[idx] != idx: parent[idx] = parent[parent[idx]] idx = parent[idx] return idx def union(a: int, b: int) -> None: ra, rb = find(a), find(b) if ra != rb: parent[rb] = ra for item in items: keys = _process_identity_keys(item) if not keys: continue idx = len(indexed_items) indexed_items.append(item) parent.append(idx) for key_name, key_value in keys: key = f"{key_name}:{key_value}" if key in key_owner: union(idx, key_owner[key]) else: key_owner[key] = idx # Fiscal-name bridge for cross-source reconstruction. Jasmin often carries # the fiscal NIF + canonical company name, while Odoo may only carry a # partner/display name with extra location text and no NIF. Exact identity # keys above do not merge those records when the names are not identical. # If at least one side has a NIF and the normalized names are a strong # substring/overlap match, merge them into the same process. The merged # group will still use the NIF as display key through _best_process_group_key. name_candidates: List[Tuple[int, str, bool, str]] = [] for idx, item in enumerate(indexed_items): name_key = _normalize_company_name(item.get("customer_name") or item.get("linked_customer_name")) if len(name_key) < 5: continue has_tax = bool(_normalize_tax_id(item.get("customer_tax_id") or "")) source = str(item.get("source_system") or "") name_candidates.append((idx, name_key, has_tax, source)) for pos, (idx_a, name_a, has_tax_a, source_a) in enumerate(name_candidates): for idx_b, name_b, has_tax_b, source_b in name_candidates[pos + 1:]: if find(idx_a) == find(idx_b): continue if not (has_tax_a or has_tax_b): continue # Prefer bridging different sources; same-source fuzzy merges are # riskier and can accidentally collapse unrelated customer records. if source_a and source_b and source_a == source_b: continue score = _name_match_score(name_a, name_b) if score >= 35: union(idx_a, idx_b) grouped_items: Dict[int, List[Dict[str, Any]]] = {} for idx, item in enumerate(indexed_items): grouped_items.setdefault(find(idx), []).append(item) groups: Dict[str, Dict[str, Any]] = {} for identity_items in grouped_items.values(): split_groups = _split_items_by_operation_identity(identity_items) multiple_operation_groups = len(split_groups) > 1 for raw_items in split_groups: best_key = _best_process_group_key(raw_items) if not best_key: continue key_name, key_value = best_key identity_group_key = f"{key_name}:{key_value}" operation_suffix = _operation_group_suffix(raw_items) if multiple_operation_groups else "" group_key = f"{identity_group_key}|{operation_suffix}" if operation_suffix else identity_group_key group = { "process_key": group_key, "match_key": key_name, "match_value": key_value, "operation_key": operation_suffix, "split_from_multi_purchase": multiple_operation_groups, "items": [], "suggestions": [], } for item in raw_items: group["items"].append(item) for suggestion in item.get("operation_suggestions") or []: if suggestion.get("opportunity_id") and all(existing.get("opportunity_id") != suggestion.get("opportunity_id") for existing in group["suggestions"]): group["suggestions"].append(suggestion) groups[group_key] = group candidates: List[Dict[str, Any]] = [] for group in groups.values(): group_items = sorted( group["items"], key=lambda x: ( str(x.get("document_date") or x.get("updated_at") or ""), min((int(step.get("rank") or 0) for step in _process_steps_for_item(x)), default=0), ), ) external_types = {str(item.get("external_type") or "") for item in group_items} actionable_singleton = bool(external_types & { "odoo_sale_order", "jasmin_invoice", "jasmin_proforma", "payment_proof", "packlink_shipment", }) split_purchase_singleton = bool(group.get("split_from_multi_purchase")) and bool(external_types & { "jasmin_quotation", "manual_request", "external_record", }) if len(group_items) < 2 and not group.get("suggestions") and not actionable_singleton and not split_purchase_singleton: continue state = infer_reconciliation_process_state(group_items) first = group_items[0] customer_name = next((item.get("customer_name") for item in group_items if item.get("customer_name")), None) or first.get("linked_customer_name") or "Cliente externo" customer_email = next((item.get("customer_email") for item in group_items if item.get("customer_email")), None) or "" customer_tax_id = next((item.get("customer_tax_id") for item in group_items if item.get("customer_tax_id")), None) or "" total_amount = next((item.get("amount") for item in group_items if item.get("amount") is not None), None) confidence_label, identity_reason = _process_group_identity_label(str(group.get("match_key") or ""), group_items) explanation = _candidate_reasons_and_risks(group, group_items, identity_reason=identity_reason, confidence_label=confidence_label) candidates.append({ "process_key": group["process_key"], "match_key": group["match_key"], "match_value": group["match_value"], "operation_key": group.get("operation_key") or "", "customer_name": customer_name, "customer_email": customer_email, "customer_tax_id": customer_tax_id, "amount": total_amount, "currency": first.get("currency") or "EUR", "item_ids": [str(item.get("id")) for item in group_items if item.get("id")], "items": group_items, "timeline": state.get("steps") or [], "suggested_stage": state.get("stage"), "suggested_action": state.get("action_code"), "suggested_label": state.get("label"), "suggestions": group.get("suggestions") or [], "confidence": confidence_label, "identity_reason": identity_reason, "reasons": explanation.get("reasons") or [], "risks": explanation.get("risks") or [], "review_status": explanation.get("review_status") or "needs_review", "financial_conflict": bool(explanation.get("financial_conflict")), "amount_conflict_values": explanation.get("amount_conflict_values") or [], }) candidates.sort(key=lambda x: (0 if x.get("confidence") == "alta" else 1, -len(x.get("items") or []), str(x.get("customer_name") or ""))) return candidates[: max(int(limit or 20), 1)] def _get_reconciliation_items_by_ids(item_ids: List[str]) -> List[Dict[str, Any]]: ensure_reconciliation_schema() cleaned = [str(x).strip() for x in item_ids if str(x).strip()] if not cleaned: return [] 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 id = ANY(CAST(:ids AS UUID[])) AND status IN ('open','needs_review','conflict') ORDER BY document_date NULLS FIRST, created_at """), {"ids": cleaned}).mappings().all() return [dict(row) for row in rows] def _materialize_current_opportunity_action(opportunity_id: str, *, actor: str) -> Dict[str, Any]: """Materialize only the central, post-commit decision for the opportunity.""" try: from app.opportunity_next_action_service import get_opportunity_next_action from app.opportunity_action_task_materializer import ensure_pending_task_for_next_action next_action = get_opportunity_next_action(opportunity_id) result = ensure_pending_task_for_next_action( opportunity_id, next_action, source="reconciliation_v129", actor=actor, ) return {"next_action": next_action, "materialization": result} except Exception as exc: # pragma: no cover - production safety guard logger.warning("failed to materialize post-reconciliation action for %s: %s", opportunity_id, exc) return {"error": str(exc)} def link_reconciliation_process_to_opportunity(item_ids: List[str], opportunity_id: str, *, actor: str = "operator") -> int: """Link several reconciliation items to the same opportunity as one process.""" items = _get_reconciliation_items_by_ids(item_ids) if not items or not _uuid_or_none(opportunity_id): return 0 state = infer_reconciliation_process_state(items) with engine.begin() as conn: for item in items: conn.execute(text(""" UPDATE reconciliation_items SET opportunity_id = CAST(:opportunity_id AS UUID), status = 'linked', resolved_at = now(), updated_at = now(), payload = COALESCE(payload, '{}'::jsonb) || CAST(:payload AS JSONB) WHERE id = CAST(:id AS UUID) """), { "id": item.get("id"), "opportunity_id": opportunity_id, "payload": _json({"process_reconstruction": {"linked_by": actor, "suggested_stage": state.get("stage"), "suggested_action": state.get("action_code")}}), }) _upsert_external_customer_mapping_from_process(conn, item, opportunity_id, actor=actor) _upsert_odoo_operation_links_from_item(conn, item, opportunity_id) _upsert_opportunity_items_from_odoo_item(conn, item, opportunity_id) _apply_jasmin_documents_to_opportunity(conn, [item], opportunity_id, actor=actor) for step in _process_steps_for_item(item): conn.execute(text(""" INSERT INTO opportunity_events ( id, opportunity_id, event_type, action_code, to_stage, note, payload, created_by ) VALUES ( CAST(:event_id AS UUID), CAST(:opportunity_id AS UUID), 'reconciliation_evidence_imported', :action_code, :to_stage, :note, CAST(:payload AS JSONB), :created_by ) """), { "event_id": str(uuid.uuid4()), "opportunity_id": opportunity_id, "action_code": step.get("action_code") or state.get("action_code"), "to_stage": step.get("stage") or state.get("stage"), "note": f"{step.get('label') or 'Evidência externa ligada'}: {step.get('document_number') or item.get('document_number') or item.get('external_id')}", "payload": _json({"reconciliation_item_id": item.get("id"), "source_system": item.get("source_system"), "external_type": step.get("external_type") or item.get("external_type"), "external_id": item.get("external_id"), "document_number": step.get("document_number") or item.get("document_number"), "document_date": step.get("document_date") or item.get("document_date"), "amount": item.get("amount"), "step": step}), "created_by": actor, }) _apply_reconstructed_process_to_opportunity(conn, items, opportunity_id, actor=actor) _record_reconciliation_decision( conn, decision_type="link_process", item_ids=[str(item.get("id")) for item in items if item.get("id")], status="linked", opportunity_id=opportunity_id, actor=actor, payload={"suggested_stage": state.get("stage"), "suggested_action": state.get("action_code")}, ) _materialize_current_opportunity_action(opportunity_id, actor=actor) return len(items) def create_opportunity_from_reconciliation_process(item_ids: List[str], *, actor: str = "operator") -> Optional[str]: """Create one reconstructed opportunity from several external items.""" items = _get_reconciliation_items_by_ids(item_ids) if not items: return None state = infer_reconciliation_process_state(items) first = items[0] customer_name = next((item.get("customer_name") for item in items if item.get("customer_name")), None) or "Processo reconstruído" customer_email = next((item.get("customer_email") for item in items if item.get("customer_email")), None) or None customer_id = next((item.get("customer_id") for item in items if item.get("customer_id")), None) amount_value = next((item.get("amount") for item in items if item.get("amount") is not None), None) currency = next((item.get("currency") for item in items if item.get("currency")), None) or "EUR" action_code = state.get("action_code") or "REVIEW_MANUALLY" stage = state.get("stage") or "REVIEW" evidence = [{ "reconciliation_item_id": item.get("id"), "source_system": item.get("source_system"), "external_type": item.get("external_type"), "external_id": item.get("external_id"), "document_number": item.get("document_number"), "document_date": item.get("document_date"), "amount": item.get("amount"), } for item in items] opportunity_id = str(uuid.uuid4()) review_patch = reconstructed_review_metadata_patch( "required", actor=actor, reason="Oportunidade reconstruída a partir de evidências externas.", blocked_action_code=action_code, ) metadata = { "created_from_reconciliation_process": True, "source_system": "reconciliation", "evidence": evidence, "suggested_stage": stage, "suggested_action": action_code, "clientflow_record_mode": "reconstructed_invoice_review", **review_patch, } title = f"Processo reconstruído · {customer_name}" with engine.begin() as conn: conn.execute(text(""" INSERT INTO opportunities ( id, title, stage, status, local_customer_id, customer_name, customer_email, product_interest, value_amount, currency, source_system, source_event_id, last_action_code, metadata ) VALUES ( CAST(:id AS UUID), :title, :stage, 'open', CAST(:customer_id AS UUID), :customer_name, :customer_email, :product_interest, :value_amount, :currency, 'reconciliation', :source_event_id, :last_action_code, CAST(:metadata AS JSONB) ) """), { "id": opportunity_id, "title": title, "stage": stage, "customer_id": _uuid_or_none(customer_id), "customer_name": customer_name, "customer_email": customer_email, "product_interest": "Processo importado/reconstruído", "value_amount": _money_or_none(amount_value), "currency": currency, "source_event_id": first.get("external_id") or first.get("id"), "last_action_code": action_code, "metadata": _json(metadata), }) conn.execute(text(""" INSERT INTO opportunity_events ( id, opportunity_id, event_type, action_code, to_stage, note, payload, created_by ) VALUES ( CAST(:event_id AS UUID), CAST(:opportunity_id AS UUID), 'opportunity_reconstructed_from_reconciliation', :action_code, :to_stage, :note, CAST(:payload AS JSONB), :created_by ) """), { "event_id": str(uuid.uuid4()), "opportunity_id": opportunity_id, "action_code": action_code, "to_stage": stage, "note": "Oportunidade reconstruída a partir de múltiplas evidências externas.", "payload": _json(metadata), "created_by": actor, }) for item in items: conn.execute(text(""" UPDATE reconciliation_items SET opportunity_id = CAST(:opportunity_id AS UUID), status = 'linked', resolved_at = now(), updated_at = now(), resolution_note = 'Ligado por reconstrução de processo', payload = COALESCE(payload, '{}'::jsonb) || CAST(:payload AS JSONB) WHERE id = CAST(:id AS UUID) """), {"opportunity_id": opportunity_id, "id": item.get("id"), "payload": _json({"process_reconstruction": {"opportunity_id": opportunity_id, "actor": actor}})}) _upsert_external_customer_mapping_from_process(conn, item, opportunity_id, actor=actor) _upsert_odoo_operation_links_from_item(conn, item, opportunity_id) _upsert_opportunity_items_from_odoo_item(conn, item, opportunity_id) _apply_jasmin_documents_to_opportunity(conn, [item], opportunity_id, actor=actor) for step in _process_steps_for_item(item): conn.execute(text(""" INSERT INTO opportunity_events ( id, opportunity_id, event_type, action_code, to_stage, note, payload, created_by ) VALUES ( CAST(:event_id AS UUID), CAST(:opportunity_id AS UUID), 'reconciliation_evidence_imported', :action_code, :to_stage, :note, CAST(:payload AS JSONB), :created_by ) """), { "event_id": str(uuid.uuid4()), "opportunity_id": opportunity_id, "action_code": step.get("action_code") or action_code, "to_stage": step.get("stage") or stage, "note": f"{step.get('label') or 'Evidência externa importada'}: {step.get('document_number') or item.get('document_number') or item.get('external_id')}", "payload": _json({"reconciliation_item_id": item.get("id"), "source_system": item.get("source_system"), "external_type": step.get("external_type") or item.get("external_type"), "external_id": item.get("external_id"), "document_number": step.get("document_number") or item.get("document_number"), "document_date": step.get("document_date") or item.get("document_date"), "amount": item.get("amount"), "step": step}), "created_by": actor, }) _record_reconciliation_decision( conn, decision_type="create_process_opportunity", item_ids=[str(item.get("id")) for item in items if item.get("id")], status="linked", opportunity_id=opportunity_id, customer_id=_uuid_or_none(customer_id), note="Oportunidade reconstruída a partir de reconciliação", actor=actor, payload={"suggested_stage": stage, "suggested_action": action_code, "evidence_count": len(evidence)}, ) task_id = _create_task_for_opportunity( opportunity_id=opportunity_id, action_code="REVIEW_RECONSTRUCTED_PROCESS", note="Confirmar cliente, documento principal, valor e evidências antes de executar a ação sensível sugerida.", source_system="reconciliation_process", source_event_id=opportunity_id, metadata={**metadata, "blocked_action_code": canonical_action_code(action_code), "review_type": "reconstructed_process"}, ) if task_id: with engine.begin() as conn: conn.execute(text(""" UPDATE opportunities SET last_task_id = CAST(:task_id AS UUID), updated_at = now() WHERE id = CAST(:opportunity_id AS UUID) """), {"task_id": task_id, "opportunity_id": opportunity_id}) return opportunity_id def set_reconciliation_status(item_id: str, *, status: str, note: str = "", actor: str = "operator") -> None: ensure_reconciliation_schema() with engine.begin() as conn: conn.execute(text(""" UPDATE reconciliation_items SET status = :status, resolution_note = COALESCE(NULLIF(:note, ''), resolution_note), resolved_at = CASE WHEN :status IN ('resolved','ignored','linked','historical') THEN now() ELSE resolved_at END, updated_at = now(), payload = COALESCE(payload, '{}'::jsonb) || CAST(:payload AS JSONB) WHERE id = CAST(:id AS UUID) """), { "id": item_id, "status": status, "note": note, "payload": _json({"last_action": {"actor": actor, "status": status}}), }) _record_reconciliation_decision( conn, decision_type="status", item_ids=[item_id], status=status, note=note, actor=actor, payload={"status": status}, ) def link_reconciliation_to_opportunity(item_id: str, opportunity_id: str, *, actor: str = "operator") -> None: # Treat a single item link as a one-item process link. This ensures Odoo # sales/fulfilment evidence updates the opportunity pipeline instead of # creating only a loose reference. if link_reconciliation_process_to_opportunity([item_id], opportunity_id, actor=actor): return ensure_reconciliation_schema() with engine.begin() as conn: conn.execute(text(""" UPDATE reconciliation_items SET opportunity_id = CAST(:opportunity_id AS UUID), status = 'linked', resolved_at = now(), updated_at = now(), payload = COALESCE(payload, '{}'::jsonb) || CAST(:payload AS JSONB) WHERE id = CAST(:id AS UUID) """), { "id": item_id, "opportunity_id": opportunity_id, "payload": _json({"linked_by": actor}), }) conn.execute(text(""" INSERT INTO opportunity_events ( id, opportunity_id, event_type, note, payload, created_by ) VALUES ( CAST(:event_id AS UUID), CAST(:opportunity_id AS UUID), 'reconciliation_item_linked', :note, CAST(:payload AS JSONB), :created_by ) """), { "event_id": str(uuid.uuid4()), "opportunity_id": opportunity_id, "note": "Item de reconciliação ligado à oportunidade.", "payload": _json({"reconciliation_item_id": item_id}), "created_by": actor, }) _record_reconciliation_decision( conn, decision_type="link_item", item_ids=[item_id], status="linked", opportunity_id=opportunity_id, actor=actor, ) def _create_task_for_opportunity( *, opportunity_id: str, action_code: str, note: str, source_system: str, source_event_id: Optional[str], metadata: Optional[Dict[str, Any]] = None, ) -> Optional[str]: config = get_action_config(action_code) route = config.get("route") or "rever" action = config.get("action") or action_code action_required = bool(config.get("action_required", True)) safe_to_post = bool(config.get("safe_to_post", False)) status = "pending" if action_required else "skipped" idempotency_key = f"task:{source_system}:opportunity:{opportunity_id}:{source_event_id or action_code}:{action_code}" with engine.begin() as conn: row = conn.execute(text(""" INSERT INTO tasks ( opportunity_id, action_code, route, action, note, action_required, safe_to_post, status, source_system, source_event_id, idempotency_key, metadata, priority ) VALUES ( CAST(:opportunity_id AS UUID), :action_code, :route, :action, :note, :action_required, :safe_to_post, :status, :source_system, :source_event_id, :idempotency_key, CAST(:metadata AS JSONB), :priority ) ON CONFLICT (idempotency_key) DO NOTHING RETURNING id::text """), { "opportunity_id": opportunity_id, "action_code": action_code, "route": route, "action": action, "note": note, "action_required": action_required, "safe_to_post": safe_to_post, "status": status, "source_system": source_system, "source_event_id": source_event_id, "idempotency_key": idempotency_key, "metadata": _json(metadata or {}), "priority": _priority_for_action(action_code), }).fetchone() return row[0] if row else None def create_opportunity_from_reconciliation(item_id: str, *, actor: str = "operator") -> Optional[str]: """Promote a reconciliation candidate into a ClientFlow opportunity. This is a deliberate operator action. It does not auto-confirm payment or close the opportunity; it creates the process container and a next task. """ ensure_reconciliation_schema() item = get_reconciliation_item(item_id) if not item: return None external_type = str(item.get("external_type") or "external_record") stage = STAGE_BY_EXTERNAL_TYPE.get(external_type, "REVIEW") action_code = item.get("suggested_action") or NEXT_ACTION_BY_EXTERNAL_TYPE.get(external_type, "REVIEW_MANUALLY") title = item.get("title") or item.get("customer_name") or "Oportunidade importada" customer_name = item.get("customer_name") or title metadata = { "created_from_reconciliation_item_id": item_id, "source_system": item.get("source_system"), "external_type": external_type, "external_id": item.get("external_id"), "document_number": item.get("document_number"), } opportunity_id = str(uuid.uuid4()) with engine.begin() as conn: conn.execute(text(""" INSERT INTO opportunities ( id, title, stage, status, local_customer_id, customer_name, customer_email, product_interest, value_amount, currency, source_system, source_event_id, last_action_code, metadata ) VALUES ( CAST(:id AS UUID), :title, :stage, 'open', CAST(:customer_id AS UUID), :customer_name, :customer_email, :product_interest, :value_amount, :currency, :source_system, :source_event_id, :last_action_code, CAST(:metadata AS JSONB) ) """), { "id": opportunity_id, "title": title, "stage": stage, "customer_id": _uuid_or_none(item.get("customer_id")), "customer_name": customer_name, "customer_email": item.get("customer_email"), "product_interest": (item.get("payload") or {}).get("product_interest") if isinstance(item.get("payload"), dict) else None, "value_amount": _money_or_none(item.get("amount")), "currency": item.get("currency") or "EUR", "source_system": item.get("source_system") or "reconciliation", "source_event_id": item.get("external_id"), "last_action_code": action_code, "metadata": _json(metadata), }) conn.execute(text(""" INSERT INTO opportunity_events ( id, opportunity_id, event_type, action_code, to_stage, note, payload, created_by ) VALUES ( CAST(:event_id AS UUID), CAST(:opportunity_id AS UUID), 'opportunity_created_from_reconciliation', :action_code, :to_stage, :note, CAST(:payload AS JSONB), :created_by ) """), { "event_id": str(uuid.uuid4()), "opportunity_id": opportunity_id, "action_code": action_code, "to_stage": stage, "note": "Oportunidade criada a partir de informação externa reconciliada.", "payload": _json(metadata), "created_by": actor, }) conn.execute(text(""" UPDATE reconciliation_items SET opportunity_id = CAST(:opportunity_id AS UUID), status = 'linked', resolved_at = now(), updated_at = now(), resolution_note = :note WHERE id = CAST(:item_id AS UUID) """), { "opportunity_id": opportunity_id, "item_id": item_id, "note": "Criada oportunidade a partir deste item.", }) _upsert_external_customer_mapping_from_process(conn, item, opportunity_id, actor=actor) _upsert_odoo_operation_links_from_item(conn, item, opportunity_id) _upsert_opportunity_items_from_odoo_item(conn, item, opportunity_id) _apply_jasmin_documents_to_opportunity(conn, [item], opportunity_id, actor=actor) _record_reconciliation_decision( conn, decision_type="create_item_opportunity", item_ids=[item_id], status="linked", opportunity_id=opportunity_id, customer_id=_uuid_or_none(item.get("customer_id")), note="Criada oportunidade a partir deste item.", actor=actor, payload=metadata, ) task_id = _create_task_for_opportunity( opportunity_id=opportunity_id, action_code=action_code, note=item.get("description") or f"Continuar processo importado de {item.get('source_system') or 'fonte externa'}.", source_system="reconciliation", source_event_id=item_id, metadata=metadata, ) if task_id: with engine.begin() as conn: conn.execute(text(""" UPDATE opportunities SET last_task_id = CAST(:task_id AS UUID), updated_at = now() WHERE id = CAST(:opportunity_id AS UUID) """), {"task_id": task_id, "opportunity_id": opportunity_id}) return opportunity_id def create_external_request( *, source_channel: str, customer_name: str, customer_email: str = "", customer_phone: str = "", product_interest: str = "", request_text: str = "", action_code: str = "SEND_QUOTE", actor: str = "operator", ) -> Dict[str, Any]: """Create an opportunity and task for WhatsApp/phone/direct-email intake.""" ensure_reconciliation_schema() action_code = str(action_code or "SEND_QUOTE").upper() config = get_action_config(action_code) opportunity_id = str(uuid.uuid4()) title_parts = [p for p in [_clean(customer_name), _clean(product_interest)] if p] title = " · ".join(title_parts) or "Pedido externo" stage = STAGE_BY_EXTERNAL_TYPE.get("manual_request", "QUOTE_REQUESTED") if action_code == "SEND_INFO": stage = "INFO_REQUESTED" elif action_code == "SEND_INVOICE": stage = "INVOICE_REQUESTED" elif action_code == "SEND_PROFORMA": stage = "PROFORMA_REQUESTED" metadata = { "manual_intake": True, "source_channel": source_channel, "request_text": request_text, "created_by": actor, } with engine.begin() as conn: conn.execute(text(""" INSERT INTO opportunities ( id, title, stage, status, customer_name, customer_email, customer_phone, product_interest, source_system, last_action_code, metadata ) VALUES ( CAST(:id AS UUID), :title, :stage, 'open', :customer_name, :customer_email, :customer_phone, :product_interest, :source_system, :action_code, CAST(:metadata AS JSONB) ) """), { "id": opportunity_id, "title": title, "stage": stage, "customer_name": _clean(customer_name) or "Contacto externo", "customer_email": _clean(customer_email), "customer_phone": _clean(customer_phone), "product_interest": _clean(product_interest), "source_system": _clean(source_channel) or "manual", "action_code": action_code, "metadata": _json(metadata), }) conn.execute(text(""" INSERT INTO opportunity_events ( id, opportunity_id, event_type, action_code, to_stage, note, payload, created_by ) VALUES ( CAST(:event_id AS UUID), CAST(:opportunity_id AS UUID), 'manual_external_intake', :action_code, :stage, :note, CAST(:payload AS JSONB), :created_by ) """), { "event_id": str(uuid.uuid4()), "opportunity_id": opportunity_id, "action_code": action_code, "stage": stage, "note": f"Pedido registado manualmente via {_clean(source_channel) or 'canal externo'}.", "payload": _json(metadata), "created_by": actor, }) task_id = _create_task_for_opportunity( opportunity_id=opportunity_id, action_code=action_code, note=_clean(request_text) or config.get("action") or "Continuar pedido externo.", source_system=_clean(source_channel) or "manual", source_event_id=f"manual:{opportunity_id}", metadata=metadata, ) if task_id: with engine.begin() as conn: conn.execute(text(""" UPDATE opportunities SET last_task_id = CAST(:task_id AS UUID), updated_at = now() WHERE id = CAST(:opportunity_id AS UUID) """), {"task_id": task_id, "opportunity_id": opportunity_id}) return {"opportunity_id": opportunity_id, "task_id": task_id} def create_payment_proof( *, opportunity_id: Optional[str] = None, customer_id: Optional[str] = None, source_system: str = "manual", source_ref: str = "", filename: str = "", file_url: str = "", amount: Any = None, currency: str = "EUR", proof_date: str = "", note: str = "", payload: Optional[Dict[str, Any]] = None, actor: str = "operator", ) -> Dict[str, Any]: """Store proof as evidence and create a CONFIRM_PAYMENT task if linked. A payment proof is never a payment confirmation. It only opens or supports a finance validation task. """ ensure_reconciliation_schema() amount_value = _money_or_none(amount) with engine.begin() as conn: row = conn.execute(text(""" INSERT INTO payment_proofs ( opportunity_id, customer_id, source_system, source_ref, filename, file_url, amount, currency, proof_date, status, note, payload ) VALUES ( CAST(:opportunity_id AS UUID), CAST(:customer_id AS UUID), :source_system, :source_ref, :filename, :file_url, :amount, :currency, CAST(:proof_date AS DATE), 'pending_validation', :note, CAST(:payload AS JSONB) ) RETURNING id::text, opportunity_id::text, customer_id::text, source_system, source_ref, filename, file_url, amount, currency, proof_date, status, note, payload, created_at, updated_at """), { "opportunity_id": _uuid_or_none(opportunity_id), "customer_id": _uuid_or_none(customer_id), "source_system": _clean(source_system) or "manual", "source_ref": _clean(source_ref), "filename": _clean(filename), "file_url": _clean(file_url), "amount": amount_value, "currency": _clean(currency) or "EUR", "proof_date": _clean(proof_date) or None, "note": _clean(note), "payload": _json(payload or {"created_by": actor}), }).mappings().first() proof = dict(row or {}) proof_id = proof.get("id") if opportunity_id: task_id = _create_task_for_opportunity( opportunity_id=opportunity_id, action_code="CONFIRM_PAYMENT", note=f"Comprovativo de pagamento recebido. Validar no banco/Jasmin antes de confirmar. {note}".strip(), source_system="payment_proof", source_event_id=proof_id, metadata={"payment_proof_id": proof_id, "amount": amount_value, "source_system": source_system}, ) proof["task_id"] = task_id else: item = upsert_reconciliation_item( source_system=_clean(source_system) or "manual", external_type="payment_proof", external_id=proof_id, title="Comprovativo de pagamento por associar", description="Comprovativo recebido, mas ainda sem oportunidade associada. Não confirma pagamento.", priority="alta", suggested_action="CONFIRM_PAYMENT", amount=amount_value, currency=currency, payload={"payment_proof_id": proof_id, "filename": filename, "note": note}, ) proof["reconciliation_item_id"] = item.get("id") return proof def sync_local_documents_without_opportunity(limit: int = 200) -> Dict[str, int]: """Create reconciliation candidates for local Jasmin documents not linked to opportunities.""" ensure_reconciliation_schema() with engine.begin() as conn: rows = conn.execute(text(""" SELECT cd.id::text, cd.system, cd.document_kind, cd.external_id, cd.document_number, cd.customer_id::text, c.name AS customer_name, c.email AS customer_email, c.tax_id AS customer_tax_id, cd.status, cd.total_amount, cd.amount, cd.currency, cd.document_date, cd.payload FROM commercial_documents cd LEFT JOIN customers c ON c.id = cd.customer_id WHERE cd.opportunity_id IS NULL AND COALESCE(cd.system, 'jasmin') = 'jasmin' AND COALESCE(cd.status, '') NOT IN ('ignored','cancelled','superseded') ORDER BY cd.updated_at DESC, cd.created_at DESC LIMIT :limit """), {"limit": int(limit)}).mappings().all() created = 0 for row in rows: data = dict(row) kind = str(data.get("document_kind") or "document") external_type = "jasmin_invoice" if "invoice" in kind else "jasmin_quotation" document_number = data.get("document_number") or data.get("external_id") or data.get("id") title = f"Documento Jasmin sem oportunidade · {document_number}" item = upsert_reconciliation_item( source_system="jasmin", external_type=external_type, external_id=data.get("external_id") or data.get("id"), title=title, description="Documento Jasmin existente sem ligação a oportunidade ClientFlow.", priority="normal" if external_type == "jasmin_quotation" else "alta", suggested_action=NEXT_ACTION_BY_EXTERNAL_TYPE.get(external_type), customer_id=data.get("customer_id"), customer_name=data.get("customer_name"), customer_email=data.get("customer_email"), customer_tax_id=data.get("customer_tax_id"), document_number=document_number, document_date=data.get("document_date"), amount=data.get("total_amount") or data.get("amount"), currency=data.get("currency") or "EUR", payload={"commercial_document_id": data.get("id"), "document_kind": kind, "status": data.get("status")}, idempotency_key=f"reconcile:commercial_document:{data.get('id')}", ) if item: created += 1 return {"seen": len(rows), "created_or_updated": created}