"""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-<id>``.  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-<odoo product_id>``
    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}
