"""Domínio comercial ClientFlow. Guarda clientes locais, documentos Jasmin (orçamentos/faturas) e envios externos de forma normalizada. Esta camada evita transformar operation_links num armazenamento principal e permite vários orçamentos/faturas por cliente e por oportunidade. """ from __future__ import annotations import json import re import uuid from decimal import Decimal from typing import Any, Dict, Iterable, List, Optional from sqlalchemy import text from app.db import engine _SCHEMA_READY = False 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 normalize_tax_id(value: Any) -> str: """Normaliza NIF/VAT PT para uso local e Jasmin. Nos testes reais Jasmin, getCustomerByCompanyTaxId aceitou 504931946 e não encontrou PT504931946. Por isso guardamos sem prefixo PT. """ tax_id = _clean(value).upper().replace(" ", "").replace("-", "") if tax_id.startswith("PT"): tax_id = tax_id[2:] return tax_id def normalize_fiscal_name(value: Any) -> str: """Normaliza nome fiscal para evitar duplicados quando falta NIF. No domínio ClientFlow o nome fiscal de empresa é tratado como chave de identidade forte. Esta normalização é deliberadamente simples e estável: remove pontuação/ruído de espaçamento, preservando a designação legal. """ name = _clean(value).casefold() name = re.sub(r"[^0-9a-záàâãéêíóôõúç]+", " ", name, flags=re.IGNORECASE) return re.sub(r"\s+", " ", name).strip() def normalize_email(value: Any) -> str: """Normaliza email para identidade lógica do cliente. A criação de clientes deve tratar ``CLIENTE@EXEMPLO.COM`` e `` cliente@exemplo.com `` como a mesma ficha comercial. """ email = _clean(value).casefold() email = re.sub(r"\s+", "", email) return email def _validate_email_or_empty(value: Any) -> str: email = normalize_email(value) if not email: return "" # Validação deliberadamente simples: evita lixo óbvio sem bloquear emails válidos raros. if not re.match(r"^[^@\s]+@[^@\s]+\.[^@\s]+$", email): raise ValueError("Email inválido.") return email def ensure_commercial_schema() -> None: global _SCHEMA_READY if _SCHEMA_READY: return with engine.begin() as conn: conn.execute(text(""" CREATE TABLE IF NOT EXISTS customers ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), name TEXT NOT NULL, 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() ) """)) conn.execute(text(""" CREATE UNIQUE INDEX IF NOT EXISTS ux_customers_tax_id ON customers(tax_id) WHERE tax_id IS NOT NULL AND tax_id <> '' """)) conn.execute(text("CREATE INDEX IF NOT EXISTS idx_customers_name ON customers(name)")) conn.execute(text("CREATE INDEX IF NOT EXISTS idx_customers_email_normalized ON customers((lower(trim(email)))) WHERE email IS NOT NULL AND trim(email) <> ''")) conn.execute(text("CREATE INDEX IF NOT EXISTS idx_customers_jasmin_key ON customers(jasmin_customer_party_key)")) conn.execute(text("CREATE INDEX IF NOT EXISTS idx_customers_external_identity ON customers USING gin (metadata)")) conn.execute(text(""" CREATE TABLE IF NOT EXISTS commercial_documents ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), customer_id UUID REFERENCES customers(id) ON DELETE SET NULL, opportunity_id UUID REFERENCES opportunities(id) ON DELETE SET NULL, system TEXT NOT NULL DEFAULT 'jasmin', document_kind TEXT NOT NULL, external_id TEXT, external_url TEXT, company TEXT, document_type TEXT, serie TEXT, series_number INTEGER, document_number TEXT, customer_party_key TEXT, status TEXT NOT NULL DEFAULT 'draft', amount NUMERIC(12,2), tax_amount NUMERIC(12,2), total_amount NUMERIC(12,2), currency TEXT DEFAULT 'EUR', parent_document_id UUID REFERENCES commercial_documents(id) ON DELETE SET NULL, version_number INTEGER, role TEXT NOT NULL DEFAULT 'current', is_primary BOOLEAN NOT NULL DEFAULT TRUE, is_active BOOLEAN NOT NULL DEFAULT TRUE, document_date DATE, due_date DATE, payload JSONB NOT NULL DEFAULT '{}'::jsonb, created_at TIMESTAMPTZ NOT NULL DEFAULT now(), updated_at TIMESTAMPTZ NOT NULL DEFAULT now() ) """)) # Upgrades aditivos para bases que já tenham a tabela. for stmt in [ "ALTER TABLE commercial_documents ADD COLUMN IF NOT EXISTS external_url TEXT", "ALTER TABLE commercial_documents ADD COLUMN IF NOT EXISTS parent_document_id UUID REFERENCES commercial_documents(id) ON DELETE SET NULL", "ALTER TABLE commercial_documents ADD COLUMN IF NOT EXISTS version_number INTEGER", "ALTER TABLE commercial_documents ADD COLUMN IF NOT EXISTS role TEXT NOT NULL DEFAULT 'current'", "ALTER TABLE commercial_documents ADD COLUMN IF NOT EXISTS is_primary BOOLEAN NOT NULL DEFAULT TRUE", "ALTER TABLE commercial_documents ADD COLUMN IF NOT EXISTS is_active BOOLEAN NOT NULL DEFAULT TRUE", "ALTER TABLE commercial_documents ADD COLUMN IF NOT EXISTS tax_amount NUMERIC(12,2)", "ALTER TABLE commercial_documents ADD COLUMN IF NOT EXISTS total_amount NUMERIC(12,2)", ]: conn.execute(text(stmt)) conn.execute(text("CREATE INDEX IF NOT EXISTS idx_commercial_documents_customer ON commercial_documents(customer_id)")) conn.execute(text("CREATE INDEX IF NOT EXISTS idx_commercial_documents_opportunity ON commercial_documents(opportunity_id)")) conn.execute(text("CREATE INDEX IF NOT EXISTS idx_commercial_documents_external ON commercial_documents(system, external_id)")) conn.execute(text("CREATE INDEX IF NOT EXISTS idx_commercial_documents_kind ON commercial_documents(document_kind, status)")) conn.execute(text("CREATE INDEX IF NOT EXISTS idx_commercial_documents_role ON commercial_documents(opportunity_id, document_kind, role, is_primary)")) # v2: startup must never classify or rewrite document relationships. # The versioned migration creates the authoritative partial unique # constraint; legacy fields below are dual-write compatibility only. conn.execute(text("DROP INDEX IF EXISTS ux_commercial_documents_primary_role")) conn.execute(text("CREATE INDEX IF NOT EXISTS idx_commercial_documents_primary_role ON commercial_documents(opportunity_id, system, document_kind, role) WHERE opportunity_id IS NOT NULL AND COALESCE(is_primary, TRUE) = TRUE AND COALESCE(role, 'current') IN ('current', 'accepted')")) conn.execute(text(""" CREATE TABLE IF NOT EXISTS commercial_document_lines ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), document_id UUID NOT NULL REFERENCES commercial_documents(id) ON DELETE CASCADE, opportunity_item_id UUID REFERENCES opportunity_items(id) ON DELETE SET NULL, line_index INTEGER NOT NULL DEFAULT 0, local_product_id UUID, jasmin_sales_item TEXT, description TEXT NOT NULL, quantity NUMERIC(12,3) NOT NULL DEFAULT 1, unit TEXT DEFAULT 'UN', unit_price NUMERIC(12,2) NOT NULL DEFAULT 0, tax_schema TEXT DEFAULT 'NORMAL', total_amount NUMERIC(12,2), payload JSONB NOT NULL DEFAULT '{}'::jsonb, created_at TIMESTAMPTZ NOT NULL DEFAULT now() ) """)) # Reconciliation v2 columns belong exclusively to migration 007. # Startup must leave a pre-007 database unchanged so the migration can # add, backfill and constrain the canonical identifier atomically. conn.execute(text("CREATE INDEX IF NOT EXISTS idx_commercial_document_lines_document ON commercial_document_lines(document_id)")) conn.execute(text(""" CREATE TABLE IF NOT EXISTS shipments ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), customer_id UUID REFERENCES customers(id) ON DELETE SET NULL, opportunity_id UUID REFERENCES opportunities(id) ON DELETE SET NULL, system TEXT NOT NULL DEFAULT 'packlink', external_reference TEXT, carrier TEXT, service_id TEXT, service_name TEXT, status TEXT NOT NULL DEFAULT 'draft', tracking_code TEXT, tracking_url TEXT, label_url TEXT, price NUMERIC(12,2), currency TEXT DEFAULT 'EUR', payload JSONB NOT NULL DEFAULT '{}'::jsonb, created_at TIMESTAMPTZ NOT NULL DEFAULT now(), updated_at TIMESTAMPTZ NOT NULL DEFAULT now() ) """)) conn.execute(text("CREATE INDEX IF NOT EXISTS idx_shipments_opportunity ON shipments(opportunity_id)")) conn.execute(text("CREATE INDEX IF NOT EXISTS idx_shipments_external ON shipments(system, external_reference)")) # Relação explícita oportunidade -> cliente local. Mantém o campo antigo # opportunities.customer_id intacto para compatibilidade com Chatwoot/legado. conn.execute(text("ALTER TABLE opportunities ADD COLUMN IF NOT EXISTS local_customer_id UUID REFERENCES customers(id) ON DELETE SET NULL")) conn.execute(text("CREATE INDEX IF NOT EXISTS idx_opportunities_local_customer ON opportunities(local_customer_id)")) _SCHEMA_READY = True def get_customer_by_tax_id(tax_id: str) -> Optional[Dict[str, Any]]: ensure_commercial_schema() normalized = normalize_tax_id(tax_id) if not normalized: return None with engine.begin() as conn: row = conn.execute(text(""" SELECT id::text, name, tax_id, email, phone, street_name, postal_zone, city_name, country, jasmin_customer_party_key, jasmin_customer_id, metadata, created_at, updated_at FROM customers WHERE tax_id = :tax_id LIMIT 1 """), {"tax_id": normalized}).mappings().first() return dict(row) if row else None def get_customer(customer_id: str) -> Optional[Dict[str, Any]]: ensure_commercial_schema() with engine.begin() as conn: row = conn.execute(text(""" SELECT id::text, name, tax_id, email, phone, street_name, postal_zone, city_name, country, jasmin_customer_party_key, jasmin_customer_id, metadata, created_at, updated_at FROM customers WHERE id = CAST(:id AS UUID) LIMIT 1 """), {"id": customer_id}).mappings().first() return dict(row) if row else None def upsert_customer(data: Dict[str, Any]) -> Dict[str, Any]: ensure_commercial_schema() tax_id = normalize_tax_id(data.get("tax_id") or data.get("companyTaxID")) name = _clean(data.get("name") or data.get("customerName") or data.get("customer_name")) if not name: raise ValueError("Nome do cliente é obrigatório") params = { "name": name, "tax_id": tax_id or None, "email": _validate_email_or_empty(data.get("email") or data.get("electronicMail")) or None, "phone": _clean(data.get("phone") or data.get("telephone")) or None, "street_name": _clean(data.get("street_name") or data.get("streetName")) or None, "postal_zone": _clean(data.get("postal_zone") or data.get("postalZone")) or None, "city_name": _clean(data.get("city_name") or data.get("cityName")) or None, "country": _clean(data.get("country") or "PT") or "PT", "jasmin_customer_party_key": _clean(data.get("jasmin_customer_party_key") or data.get("customerPartyKey")) or None, "jasmin_customer_id": _clean(data.get("jasmin_customer_id") or data.get("jasmin_id") or data.get("id")) or None, "metadata": _json(data.get("metadata") or {}), } with engine.begin() as conn: # Identidade forte por email quando não há NIF. Evita fichas duplicadas # por maiúsculas/espaços no email. O NIF continua a ter prioridade. if not params["tax_id"] and params["email"]: existing_by_email = conn.execute(text(""" SELECT id::text, name, tax_id, email, phone, street_name, postal_zone, city_name, country, jasmin_customer_party_key, jasmin_customer_id, metadata, created_at, updated_at FROM customers WHERE lower(trim(email)) = :email ORDER BY updated_at DESC LIMIT 1 """), {"email": params["email"]}).mappings().first() if existing_by_email: row = conn.execute(text(""" UPDATE customers SET name = COALESCE(NULLIF(:name, ''), name), phone = COALESCE(:phone, phone), street_name = COALESCE(:street_name, street_name), postal_zone = COALESCE(:postal_zone, postal_zone), city_name = COALESCE(:city_name, city_name), country = COALESCE(:country, country), jasmin_customer_party_key = COALESCE(:jasmin_customer_party_key, jasmin_customer_party_key), jasmin_customer_id = COALESCE(:jasmin_customer_id, jasmin_customer_id), metadata = COALESCE(metadata, '{}'::jsonb) || CAST(:metadata AS JSONB), updated_at = now() WHERE id = CAST(:id AS UUID) RETURNING id::text, name, tax_id, email, phone, street_name, postal_zone, city_name, country, jasmin_customer_party_key, jasmin_customer_id, metadata, created_at, updated_at """), {**params, "id": existing_by_email["id"]}).mappings().first() return dict(row or {}) if params["tax_id"]: row = conn.execute(text(""" INSERT INTO customers ( name, tax_id, email, phone, street_name, postal_zone, city_name, country, jasmin_customer_party_key, jasmin_customer_id, metadata, updated_at ) VALUES ( :name, :tax_id, :email, :phone, :street_name, :postal_zone, :city_name, :country, :jasmin_customer_party_key, :jasmin_customer_id, CAST(:metadata AS JSONB), now() ) ON CONFLICT (tax_id) WHERE tax_id IS NOT NULL AND tax_id <> '' DO UPDATE SET name = EXCLUDED.name, email = COALESCE(EXCLUDED.email, customers.email), phone = COALESCE(EXCLUDED.phone, customers.phone), street_name = COALESCE(EXCLUDED.street_name, customers.street_name), postal_zone = COALESCE(EXCLUDED.postal_zone, customers.postal_zone), city_name = COALESCE(EXCLUDED.city_name, customers.city_name), country = COALESCE(EXCLUDED.country, customers.country), jasmin_customer_party_key = COALESCE(EXCLUDED.jasmin_customer_party_key, customers.jasmin_customer_party_key), jasmin_customer_id = COALESCE(EXCLUDED.jasmin_customer_id, customers.jasmin_customer_id), metadata = customers.metadata || EXCLUDED.metadata, updated_at = now() RETURNING id::text, name, tax_id, email, phone, street_name, postal_zone, city_name, country, jasmin_customer_party_key, jasmin_customer_id, metadata, created_at, updated_at """), params).mappings().first() else: normalized_name = normalize_fiscal_name(params["name"]) row = conn.execute(text(""" SELECT id::text, name, tax_id, email, phone, street_name, postal_zone, city_name, country, jasmin_customer_party_key, jasmin_customer_id, metadata, created_at, updated_at FROM customers WHERE COALESCE(tax_id, '') = '' AND trim(regexp_replace(regexp_replace(lower(name), '[^0-9a-záàâãéêíóôõúç]+', ' ', 'g'), '\\s+', ' ', 'g')) = :normalized_name LIMIT 1 """), {"normalized_name": normalized_name}).mappings().first() if row: row = conn.execute(text(""" UPDATE customers SET email = COALESCE(:email, email), phone = COALESCE(:phone, phone), street_name = COALESCE(:street_name, street_name), postal_zone = COALESCE(:postal_zone, postal_zone), city_name = COALESCE(:city_name, city_name), country = COALESCE(:country, country), jasmin_customer_party_key = COALESCE(:jasmin_customer_party_key, jasmin_customer_party_key), jasmin_customer_id = COALESCE(:jasmin_customer_id, jasmin_customer_id), metadata = COALESCE(metadata, '{}'::jsonb) || CAST(:metadata AS JSONB), updated_at = now() WHERE id = CAST(:id AS UUID) RETURNING id::text, name, tax_id, email, phone, street_name, postal_zone, city_name, country, jasmin_customer_party_key, jasmin_customer_id, metadata, created_at, updated_at """), {**params, "id": row["id"]}).mappings().first() else: row = conn.execute(text(""" INSERT INTO customers ( name, email, phone, street_name, postal_zone, city_name, country, jasmin_customer_party_key, jasmin_customer_id, metadata, updated_at ) VALUES ( :name, :email, :phone, :street_name, :postal_zone, :city_name, :country, :jasmin_customer_party_key, :jasmin_customer_id, CAST(:metadata AS JSONB), now() ) RETURNING id::text, name, tax_id, email, phone, street_name, postal_zone, city_name, country, jasmin_customer_party_key, jasmin_customer_id, metadata, created_at, updated_at """), params).mappings().first() return dict(row or {}) def _next_document_version(conn, opportunity_id: Optional[str], document_kind: str) -> int: if not opportunity_id: return 1 value = conn.execute(text(""" SELECT COALESCE(MAX(version_number), 0) + 1 FROM commercial_documents WHERE opportunity_id = CAST(:opportunity_id AS UUID) AND document_kind = :document_kind """), {"opportunity_id": opportunity_id, "document_kind": document_kind}).scalar() return int(value or 1) def create_commercial_document( *, document_kind: str, customer_id: Optional[str] = None, opportunity_id: Optional[str] = None, system: str = "jasmin", external_id: Optional[str] = None, external_url: Optional[str] = None, company: Optional[str] = None, document_type: Optional[str] = None, serie: Optional[str] = None, series_number: Optional[int] = None, document_number: Optional[str] = None, customer_party_key: Optional[str] = None, status: str = "created", amount: Any = None, tax_amount: Any = None, total_amount: Any = None, currency: str = "EUR", parent_document_id: Optional[str] = None, payload: Optional[Dict[str, Any]] = None, document_date: Optional[str] = None, due_date: Optional[str] = None, ) -> Dict[str, Any]: ensure_commercial_schema() document_kind = _clean(document_kind) if not document_kind: raise ValueError("document_kind é obrigatório") with engine.begin() as conn: from app.document_reconciliation_service import ( document_reconciliation_v2_available, register_created_document, ) v2_available = document_reconciliation_v2_available(conn) version_number = _next_document_version(conn, opportunity_id, document_kind) if document_kind == "quotation" and opportunity_id and not v2_available: conn.execute(text(""" UPDATE commercial_documents SET status = CASE WHEN status IN ('created','sent','draft') THEN 'superseded' ELSE status END, is_active = FALSE, updated_at = now() WHERE opportunity_id = CAST(:opportunity_id AS UUID) AND document_kind = 'quotation' AND is_active = TRUE """), {"opportunity_id": opportunity_id}) row = conn.execute(text(""" INSERT INTO commercial_documents ( customer_id, opportunity_id, system, document_kind, external_id, external_url, company, document_type, serie, series_number, document_number, customer_party_key, status, amount, tax_amount, total_amount, currency, parent_document_id, version_number, is_active, document_date, due_date, payload, updated_at ) VALUES ( CAST(:customer_id AS UUID), CAST(:opportunity_id AS UUID), :system, :document_kind, :external_id, :external_url, :company, :document_type, :serie, :series_number, :document_number, :customer_party_key, :status, :amount, :tax_amount, :total_amount, :currency, CAST(:parent_document_id AS UUID), :version_number, TRUE, CAST(:document_date AS DATE), CAST(:due_date AS DATE), CAST(:payload AS JSONB), now() ) RETURNING id::text, customer_id::text, opportunity_id::text, system, document_kind, external_id, external_url, company, document_type, serie, series_number, document_number, customer_party_key, status, amount, tax_amount, total_amount, currency, parent_document_id::text, version_number, is_active, document_date, due_date, payload, created_at, updated_at """), { "customer_id": customer_id, "opportunity_id": opportunity_id, "system": system, "document_kind": document_kind, "external_id": external_id, "external_url": external_url, "company": company, "document_type": document_type, "serie": serie, "series_number": series_number, "document_number": document_number, "customer_party_key": customer_party_key, "status": status, "amount": amount, "tax_amount": tax_amount, "total_amount": total_amount if total_amount is not None else amount, "currency": currency, "parent_document_id": parent_document_id, "version_number": version_number, "document_date": document_date, "due_date": due_date, "payload": _json(payload), }).mappings().first() if row and opportunity_id and v2_available: link = register_created_document( conn, opportunity_id, str(row["id"]), document_kind, status=status, actor="commercial_service.create_commercial_document", ) if link: row = dict(row) | { "document_id": str(row["id"]), "link_id": str(link["id"]), "relationship": link["relationship"], } return dict(row or {}) def add_document_lines(document_id: str, lines: Iterable[Dict[str, Any]]) -> None: ensure_commercial_schema() with engine.begin() as conn: # Migration 007 is additive. Select the statement once per batch so # the generic writer works both before and after the new NOT NULL # canonical column is introduced. has_canonical_id = bool(conn.execute(text(""" SELECT EXISTS ( SELECT 1 FROM pg_attribute WHERE attrelid = to_regclass('commercial_document_lines') AND attname = 'commercial_document_id' AND attnum > 0 AND NOT attisdropped ) """)).scalar()) for idx, line in enumerate(lines): columns = ("document_id, commercial_document_id, " if has_canonical_id else "document_id, ") values = ("CAST(:document_id AS UUID), CAST(:document_id AS UUID), " if has_canonical_id else "CAST(:document_id AS UUID), ") conn.execute(text(f""" INSERT INTO commercial_document_lines ( {columns}opportunity_item_id, line_index, local_product_id, jasmin_sales_item, description, quantity, unit, unit_price, tax_schema, total_amount, payload ) VALUES ( {values}CAST(:opportunity_item_id AS UUID), :line_index, CAST(:local_product_id AS UUID), :jasmin_sales_item, :description, :quantity, :unit, :unit_price, :tax_schema, :total_amount, CAST(:payload AS JSONB) ) """), { "document_id": document_id, "opportunity_item_id": line.get("opportunity_item_id"), "line_index": int(line.get("line_index") if line.get("line_index") is not None else idx), "local_product_id": line.get("local_product_id"), "jasmin_sales_item": line.get("jasmin_sales_item"), "description": line.get("description") or "Linha", "quantity": line.get("quantity") or 1, "unit": line.get("unit") or "UN", "unit_price": line.get("unit_price") or 0, "tax_schema": line.get("tax_schema") or "NORMAL", "total_amount": line.get("total_amount"), "payload": _json(line.get("payload") or {}), }) def list_commercial_documents(opportunity_id: Optional[str] = None, customer_id: Optional[str] = None, limit: int = 100) -> List[Dict[str, Any]]: ensure_commercial_schema() if opportunity_id: from app.document_reconciliation_service import resolve_document_links # Canonical resolver owns the only group-complete rollout fallback. rows = resolve_document_links(opportunity_id) if customer_id: rows = [row for row in rows if str(row.get("customer_id") or "") == str(customer_id)] return rows[:int(limit)] # Legitimate legacy inventory: without an opportunity scope this endpoint # lists document records for customer/history, not effective relationships. filters = [] params: Dict[str, Any] = {"limit": int(limit)} if opportunity_id: filters.append("cd.opportunity_id = CAST(:opportunity_id AS UUID)") params["opportunity_id"] = opportunity_id if customer_id: filters.append("cd.customer_id = CAST(:customer_id AS UUID)") params["customer_id"] = customer_id where = "WHERE " + " AND ".join(filters) if filters else "" with engine.begin() as conn: rows = conn.execute(text(f""" SELECT cd.id::text, cd.customer_id::text, cd.opportunity_id::text, cd.system, cd.document_kind, cd.external_id, cd.external_url, cd.company, cd.document_type, cd.serie, cd.series_number, cd.document_number, cd.customer_party_key, cd.status, cd.amount, cd.tax_amount, cd.total_amount, cd.currency, cd.parent_document_id::text, cd.version_number, cd.role, cd.is_primary, cd.is_active, NULL::text AS relationship, NULL::boolean AS is_manual, NULL::text AS decision_reason, NULL::text AS decided_by, NULL::timestamptz AS decided_at, cd.document_date, cd.due_date, cd.payload, cd.created_at, cd.updated_at, c.name AS customer_name, c.tax_id AS customer_tax_id FROM commercial_documents cd LEFT JOIN customers c ON c.id = cd.customer_id {where} ORDER BY cd.is_primary DESC, cd.created_at DESC LIMIT :limit """), params).mappings().all() return [dict(r) for r in rows] def get_commercial_document(document_id: str) -> Optional[Dict[str, Any]]: ensure_commercial_schema() from app.document_reconciliation_service import resolve_document_opportunity, resolve_document_links opportunity_id = resolve_document_opportunity(document_id) if opportunity_id: return next((row for row in resolve_document_links(opportunity_id, include_ended=True) if str(row.get("document_id")) == str(document_id)), None) # Legitimate orphan/history lookup: there is no relationship to resolve. with engine.begin() as conn: row = conn.execute(text(""" SELECT cd.id::text, cd.customer_id::text, cd.opportunity_id::text, cd.system, cd.document_kind, cd.external_id, cd.external_url, cd.company, cd.document_type, cd.serie, cd.series_number, cd.document_number, cd.customer_party_key, cd.status, cd.amount, cd.tax_amount, cd.total_amount, cd.currency, cd.parent_document_id::text, cd.version_number, cd.role, cd.is_primary, cd.is_active, cd.document_date, cd.due_date, cd.payload, cd.created_at, cd.updated_at, c.name AS customer_name, c.tax_id AS customer_tax_id FROM commercial_documents cd LEFT JOIN customers c ON c.id = cd.customer_id WHERE cd.id = CAST(:id AS UUID) LIMIT 1 """), {"id": document_id}).mappings().first() return dict(row) if row else None def get_latest_active_quotation(opportunity_id: str) -> Optional[Dict[str, Any]]: ensure_commercial_schema() from app.document_reconciliation_service import get_primary_document return get_primary_document(opportunity_id, "quotation") def find_invoice_for_parent(parent_document_id: str) -> Optional[Dict[str, Any]]: ensure_commercial_schema() with engine.begin() as conn: row = conn.execute(text(""" SELECT id::text, external_id, document_number, status, created_at FROM commercial_documents WHERE parent_document_id = CAST(:parent_document_id AS UUID) AND document_kind = 'invoice' ORDER BY created_at DESC LIMIT 1 """), {"parent_document_id": parent_document_id}).mappings().first() return dict(row) if row else None def mark_document_status(document_id: str, status: str, payload: Optional[Dict[str, Any]] = None) -> None: ensure_commercial_schema() with engine.begin() as conn: conn.execute(text(""" UPDATE commercial_documents SET status = :status, payload = payload || CAST(:payload AS JSONB), updated_at = now() WHERE id = CAST(:id AS UUID) """), {"id": document_id, "status": status, "payload": _json(payload or {})}) def update_commercial_document_details(document_id: str, details: Dict[str, Any]) -> Optional[Dict[str, Any]]: """Atualiza campos normalizados depois de consultar o documento no sistema externo. Usado sobretudo para Jasmin, porque o POST devolve apenas o UUID e o número/série precisam de um GET subsequente. Campos ausentes não apagam dados já existentes. """ ensure_commercial_schema() payload = details.get("payload") or {} with engine.begin() as conn: row = conn.execute(text(""" UPDATE commercial_documents SET company = COALESCE(:company, company), document_type = COALESCE(:document_type, document_type), serie = COALESCE(:serie, serie), series_number = COALESCE(:series_number, series_number), document_number = COALESCE(:document_number, document_number), amount = COALESCE(:amount, amount), tax_amount = COALESCE(:tax_amount, tax_amount), total_amount = COALESCE(:total_amount, 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), payload = payload || CAST(:payload AS JSONB), updated_at = now() WHERE id = CAST(:id AS UUID) RETURNING id::text, customer_id::text, opportunity_id::text, system, document_kind, external_id, external_url, company, document_type, serie, series_number, document_number, customer_party_key, status, amount, tax_amount, total_amount, currency, parent_document_id::text, version_number, is_active, document_date, due_date, payload, created_at, updated_at """), { "id": document_id, "company": details.get("company"), "document_type": details.get("document_type"), "serie": details.get("serie"), "series_number": details.get("series_number"), "document_number": details.get("document_number"), "amount": details.get("amount"), "tax_amount": details.get("tax_amount"), "total_amount": details.get("total_amount"), "currency": details.get("currency"), "document_date": details.get("document_date"), "due_date": details.get("due_date"), "payload": _json(payload), }).mappings().first() return dict(row) if row else None def upsert_shipment_record(data: Dict[str, Any]) -> Dict[str, Any]: ensure_commercial_schema() params = { "customer_id": data.get("customer_id"), "opportunity_id": data.get("opportunity_id"), "system": data.get("system") or "packlink", "external_reference": data.get("external_reference") or data.get("reference"), "carrier": data.get("carrier"), "service_id": data.get("service_id"), "service_name": data.get("service_name"), "status": data.get("status") or "created", "tracking_code": data.get("tracking_code"), "tracking_url": data.get("tracking_url"), "label_url": data.get("label_url"), "price": data.get("price"), "currency": data.get("currency") or "EUR", "payload": _json(data.get("payload") or {}), } with engine.begin() as conn: row = conn.execute(text(""" INSERT INTO shipments ( customer_id, opportunity_id, system, external_reference, carrier, service_id, service_name, status, tracking_code, tracking_url, label_url, price, currency, payload, updated_at ) VALUES ( CAST(:customer_id AS UUID), CAST(:opportunity_id AS UUID), :system, :external_reference, :carrier, :service_id, :service_name, :status, :tracking_code, :tracking_url, :label_url, :price, :currency, CAST(:payload AS JSONB), now() ) RETURNING id::text, customer_id::text, opportunity_id::text, system, external_reference, carrier, service_id, service_name, status, tracking_code, tracking_url, label_url, price, currency, payload, created_at, updated_at """), params).mappings().first() return dict(row or {}) def list_customers(q: Optional[str] = None, limit: int = 200) -> List[Dict[str, Any]]: """Lista clientes locais normalizados. A página Customers passa a ser a fonte principal dos dados fiscais. """ ensure_commercial_schema() params: Dict[str, Any] = {"limit": int(limit)} where = "" if q: params["q"] = f"%{str(q).strip()}%" where = """ WHERE name ILIKE :q OR COALESCE(tax_id, '') ILIKE :q OR COALESCE(email, '') ILIKE :q OR COALESCE(phone, '') ILIKE :q OR COALESCE(jasmin_customer_party_key, '') ILIKE :q """ with engine.begin() as conn: rows = conn.execute(text(f""" SELECT c.id::text, c.name, c.tax_id, c.email, c.phone, c.street_name, c.postal_zone, c.city_name, c.country, c.jasmin_customer_party_key, c.jasmin_customer_id, c.metadata, c.created_at, c.updated_at, COUNT(DISTINCT o.id) AS opportunity_count, COUNT(DISTINCT cd.id) FILTER (WHERE cd.document_kind = 'quotation') AS quotation_count, COUNT(DISTINCT cd.id) FILTER (WHERE cd.document_kind = 'invoice') AS invoice_count FROM customers c LEFT JOIN opportunities o ON COALESCE(o.local_customer_id::text, '') = c.id::text LEFT JOIN commercial_documents cd ON cd.customer_id = c.id {where} GROUP BY c.id ORDER BY c.updated_at DESC, c.created_at DESC LIMIT :limit """), params).mappings().all() return [dict(r) for r in rows] def update_customer(customer_id: str, data: Dict[str, Any]) -> Dict[str, Any]: """Atualiza uma ficha de cliente local. Não chama Jasmin diretamente; a sincronização é feita por jasmin_service/outbox. """ ensure_commercial_schema() params = { "id": customer_id, "name": _clean(data.get("name")), "tax_id": normalize_tax_id(data.get("tax_id")), "email": _validate_email_or_empty(data.get("email")) or None, "phone": _clean(data.get("phone")) or None, "street_name": _clean(data.get("street_name")) or None, "postal_zone": _clean(data.get("postal_zone")) or None, "city_name": _clean(data.get("city_name")) or None, "country": _clean(data.get("country") or "PT") or "PT", "jasmin_customer_party_key": _clean(data.get("jasmin_customer_party_key")) or None, "jasmin_customer_id": _clean(data.get("jasmin_customer_id")) or None, "metadata": _json(data.get("metadata") or {}), } if not params["name"]: raise ValueError("Nome do cliente é obrigatório") with engine.begin() as conn: if params["tax_id"]: duplicate = conn.execute(text(""" SELECT id::text, name, tax_id, email, phone, street_name, postal_zone, city_name, country, jasmin_customer_party_key, jasmin_customer_id, metadata, created_at, updated_at FROM customers WHERE tax_id = :tax_id AND id <> CAST(:id AS UUID) LIMIT 1 """), {"tax_id": params["tax_id"], "id": params["id"]}).mappings().first() if duplicate: raise DuplicateCustomerTaxIdError(params["tax_id"], dict(duplicate)) row = conn.execute(text(""" UPDATE customers SET name = :name, tax_id = NULLIF(:tax_id, ''), email = :email, phone = :phone, street_name = :street_name, postal_zone = :postal_zone, city_name = :city_name, country = :country, jasmin_customer_party_key = :jasmin_customer_party_key, jasmin_customer_id = :jasmin_customer_id, metadata = COALESCE(metadata, '{}'::jsonb) || CAST(:metadata AS JSONB), updated_at = now() WHERE id = CAST(:id AS UUID) RETURNING id::text, name, tax_id, email, phone, street_name, postal_zone, city_name, country, jasmin_customer_party_key, jasmin_customer_id, metadata, created_at, updated_at """), params).mappings().first() if not row: raise ValueError("Cliente não encontrado") return dict(row) def link_customer_to_opportunity(customer_id: str, opportunity_id: str) -> None: """Liga um cliente local a uma oportunidade sem duplicar dados fiscais.""" ensure_commercial_schema() with engine.begin() as conn: conn.execute(text(""" UPDATE opportunities SET local_customer_id = CAST(:customer_id AS UUID), updated_at = now() WHERE id = CAST(:opportunity_id AS UUID) """), {"customer_id": customer_id, "opportunity_id": opportunity_id}) def unlink_customer_from_opportunity(opportunity_id: str) -> None: ensure_commercial_schema() with engine.begin() as conn: conn.execute(text(""" UPDATE opportunities SET local_customer_id = NULL, updated_at = now() WHERE id = CAST(:opportunity_id AS UUID) """), {"opportunity_id": opportunity_id}) def get_customer_for_opportunity(opportunity_id: str) -> Optional[Dict[str, Any]]: ensure_commercial_schema() with engine.begin() as conn: row = conn.execute(text(""" SELECT c.id::text, c.name, c.tax_id, c.email, c.phone, c.street_name, c.postal_zone, c.city_name, c.country, c.jasmin_customer_party_key, c.jasmin_customer_id, c.metadata, c.created_at, c.updated_at FROM opportunities o 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() return dict(row) if row else None def list_opportunities_for_customer(customer_id: str, limit: int = 50) -> List[Dict[str, Any]]: ensure_commercial_schema() with engine.begin() as conn: rows = conn.execute(text(""" SELECT id::text, title, stage, status, customer_name, customer_email, product_interest, value_amount, currency, updated_at, created_at FROM opportunities WHERE local_customer_id = CAST(:customer_id AS UUID) ORDER BY updated_at DESC LIMIT :limit """), {"customer_id": customer_id, "limit": int(limit)}).mappings().all() return [dict(r) for r in rows] def list_shipments(opportunity_id: Optional[str] = None, customer_id: Optional[str] = None, limit: int = 100) -> List[Dict[str, Any]]: ensure_commercial_schema() filters = [] params: Dict[str, Any] = {"limit": int(limit)} if opportunity_id: filters.append("s.opportunity_id = CAST(:opportunity_id AS UUID)") params["opportunity_id"] = opportunity_id if customer_id: filters.append("s.customer_id = CAST(:customer_id AS UUID)") params["customer_id"] = customer_id where = "WHERE " + " AND ".join(filters) if filters else "" with engine.begin() as conn: rows = conn.execute(text(f""" SELECT s.id::text, s.customer_id::text, s.opportunity_id::text, s.system, s.external_reference, s.carrier, s.service_id, s.service_name, s.status, s.tracking_code, s.tracking_url, s.label_url, s.price, s.currency, s.payload, s.created_at, s.updated_at, c.name AS customer_name FROM shipments s LEFT JOIN customers c ON c.id = s.customer_id {where} ORDER BY s.created_at DESC LIMIT :limit """), params).mappings().all() return [dict(r) for r in rows]