Files
clientflow_backend/app/commercial_service.py
2026-06-09 22:55:58 +01:00

886 lines
44 KiB
Python

"""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 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_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)"))
# Hotfix v4927.1: em bases já usadas podem existir vários documentos
# Jasmin/Odoo ligados à mesma oportunidade e ao mesmo tipo. Ao adicionar
# role/is_primary com DEFAULT current/TRUE, todos passariam a ser
# “atuais”, fazendo o índice único falhar no arranque e deixando o
# serviço indisponível atrás do nginx. Antes de criar o índice, mantemos
# só o documento mais recente como primário e movemos os restantes para
# histórico.
conn.execute(text("""
WITH ranked AS (
SELECT
id,
ROW_NUMBER() OVER (
PARTITION BY opportunity_id, system, document_kind, COALESCE(role, 'current')
ORDER BY COALESCE(updated_at, created_at) DESC, created_at DESC, id DESC
) AS rn
FROM commercial_documents
WHERE opportunity_id IS NOT NULL
AND COALESCE(is_primary, TRUE) = TRUE
AND COALESCE(role, 'current') IN ('current', 'accepted')
)
UPDATE commercial_documents AS d
SET role = CASE
WHEN COALESCE(d.role, 'current') IN ('current', 'accepted') THEN 'historical'
ELSE COALESCE(d.role, 'historical')
END,
is_primary = FALSE,
is_active = FALSE,
updated_at = now()
FROM ranked AS r
WHERE d.id = r.id
AND r.rn > 1
"""))
# v4927.2: não criar índice UNIQUE durante o arranque.
# Em produção podem existir duplicados históricos que ainda não foram
# reconciliados; se o índice único falhar, toda a app fica indisponível
# e o nginx devolve 502. A regra de “um principal por fase” fica
# aplicada pela normalização acima e pelos serviços que promovem um
# documento a atual. O índice de apoio é não único e seguro em bases
# reais com dados legados.
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')"))
# v4928.1: migração leve de registos antigos/reconstruídos.
# Não apaga documentos nem altera fases. Apenas evita que orçamentos e
# pró-formas legados continuem a aparecer como documento principal quando
# já existe fatura Jasmin atual/aceite para a mesma oportunidade.
conn.execute(text("""
WITH primary_invoice AS (
SELECT DISTINCT ON (opportunity_id, system)
id, opportunity_id, system, COALESCE(document_date, created_at::date) AS invoice_date, created_at
FROM commercial_documents
WHERE opportunity_id IS NOT NULL
AND system = 'jasmin'
AND document_kind = 'invoice'
AND COALESCE(is_primary, TRUE) = TRUE
AND COALESCE(role, 'current') IN ('current', 'accepted')
ORDER BY opportunity_id, system, COALESCE(document_date, created_at::date) DESC, created_at DESC
)
UPDATE commercial_documents AS d
SET role = 'historical',
is_primary = FALSE,
is_active = FALSE,
payload = COALESCE(d.payload, '{}'::jsonb) || jsonb_build_object('legacy_reason', 'superseded_by_invoice_v4928_1'),
updated_at = now()
FROM primary_invoice AS inv
WHERE d.opportunity_id = inv.opportunity_id
AND d.system = inv.system
AND d.document_kind IN ('quotation', 'proforma')
AND COALESCE(d.role, 'current') IN ('current', 'accepted')
AND COALESCE(d.is_primary, TRUE) = TRUE
AND COALESCE(d.document_date, d.created_at::date) <= inv.invoice_date
"""))
# Marca oportunidades reconstruídas com fatura e sem task pendente para a UI
# poder explicar que são processos antigos/auditoria, não um funil limpo.
conn.execute(text("""
UPDATE opportunities AS o
SET metadata = COALESCE(o.metadata, '{}'::jsonb) || jsonb_build_object(
'clientflow_record_mode', COALESCE(o.metadata->>'clientflow_record_mode', 'reconstructed_invoice_review'),
'clientflow_legacy_migrated_by', 'v4928_1'
),
updated_at = now()
WHERE EXISTS (
SELECT 1 FROM commercial_documents d
WHERE d.opportunity_id = o.id
AND d.system = 'jasmin'
AND d.document_kind = 'invoice'
AND COALESCE(d.role, 'current') IN ('current', 'accepted')
AND COALESCE(d.is_primary, TRUE) = TRUE
)
AND NOT EXISTS (
SELECT 1 FROM tasks t
WHERE t.opportunity_id = o.id
AND t.status = 'pending'
)
AND COALESCE(o.metadata->>'clientflow_record_mode', '') = ''
"""))
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()
)
"""))
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": _clean(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:
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:
version_number = _next_document_version(conn, opportunity_id, document_kind)
if document_kind == "quotation" and opportunity_id:
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()
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:
for idx, line in enumerate(lines):
conn.execute(text("""
INSERT INTO commercial_document_lines (
document_id, opportunity_item_id, line_index, local_product_id, jasmin_sales_item,
description, quantity, unit, unit_price, tax_schema, total_amount, payload
) VALUES (
CAST(:document_id AS UUID), CAST(: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()
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,
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
CASE COALESCE(cd.role, 'current') WHEN 'current' THEN 0 WHEN 'accepted' THEN 1 WHEN 'related' THEN 2 WHEN 'historical' THEN 3 ELSE 4 END,
COALESCE(cd.is_primary, FALSE) 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()
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()
with engine.begin() as conn:
row = conn.execute(text("""
SELECT 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, total_amount, currency,
parent_document_id::text, version_number, is_active, payload, created_at, updated_at
FROM commercial_documents
WHERE opportunity_id = CAST(:opportunity_id AS UUID)
AND document_kind = 'quotation'
AND status NOT IN ('cancelled','failed')
ORDER BY is_active DESC, created_at DESC
LIMIT 1
"""), {"opportunity_id": opportunity_id}).mappings().first()
return dict(row) if row else None
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": _clean(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]