Import ClientFlow production v4928.1.5.132.4

This commit is contained in:
plx
2026-07-29 13:11:01 +00:00
parent 6445044ac6
commit 261d342057
405 changed files with 48373 additions and 1401 deletions

View File

@@ -38,6 +38,12 @@ from app.config import settings
from app.db import engine
from app.integration_outbox_service import create_outbox_item
from app.jasmin_client import JasminClient, JasminError
from app.pdf_format_guard import (
analyze_pdf_format,
first_box_label,
invoice_pdf_block_message,
is_customer_send_blocking_pdf_warning,
)
from app.operation_service import register_operation_action
from app.opportunity_service import get_opportunity, set_opportunity_stage
from app.product_service import list_opportunity_items
@@ -47,6 +53,28 @@ class JasminPayloadError(ValueError):
pass
def _bool_env(name: str, default: bool = False) -> bool:
raw = os.getenv(name)
if raw is None:
return bool(default)
return str(raw).strip().lower() in {"1", "true", "yes", "on"}
def _record_pdf_format_check(document_id: str, info: Dict[str, Any], *, blocked: bool, error: str = "") -> None:
payload: Dict[str, Any] = {
"clientflow_pdf_format": info or {},
"clientflow_pdf_format_warning": str((info or {}).get("format_warning") or ""),
"clientflow_pdf_first_box": first_box_label(info or {}),
"clientflow_pdf_send_blocked": bool(blocked),
}
if error:
payload["clientflow_pdf_format_error"] = str(error)[:500]
try:
update_commercial_document_details(document_id, {"payload": payload})
except Exception as exc:
print(f"ClientFlow PDF format check persistence failed document_id={document_id}: {exc}", flush=True)
def _env(name: str, default: str = "") -> str:
return os.getenv(name, default).strip()
@@ -60,6 +88,45 @@ def _compact(value: Any, limit: int = 180) -> str:
return value[:limit].strip()
def normalize_jasmin_country_key(value: Any) -> str:
"""Normaliza país para CountryNaturalKey aceite pelo Jasmin.
O ClientFlow pode guardar país como "PT", "Portugal" ou até "PO"
quando dados externos foram truncados a 2 caracteres. O Jasmin espera a
natural key ISO usada no tenant; para Portugal é "PT".
"""
raw = re.sub(r"\s+", " ", str(value or "").strip())
if not raw:
raw = str(settings.jasmin_default_country or "PT").strip()
upper = raw.upper().strip()
normalized = re.sub(r"[^A-Z]", "", upper)
if not normalized:
return "PT"
aliases = {
"PT": "PT",
"P": "PT",
"PO": "PT", # erro histórico: "Portugal" truncado com limit=2
"POR": "PT",
"PRT": "PT",
"PORTUGAL": "PT",
"PORTUGUESA": "PT",
"REPUBLICAPORTUGUESA": "PT",
}
if normalized in aliases:
return aliases[normalized]
if normalized.startswith("PORTUG"):
return "PT"
if len(normalized) == 2:
return normalized
# fallback conservador: usar default do tenant se um nome longo desconhecido
# chegar aqui. Evita enviar natural keys inválidas como "ES" truncado de
# "Espanha" sem mapeamento explícito.
default_key = re.sub(r"[^A-Z]", "", str(settings.jasmin_default_country or "PT").upper())
return aliases.get(default_key, default_key[:2] if len(default_key) >= 2 else "PT")
def _metadata_dict(row: Dict[str, Any]) -> Dict[str, Any]:
value = row.get("metadata") or {}
if isinstance(value, dict):
@@ -74,6 +141,27 @@ def _without_empty(payload: Dict[str, Any]) -> Dict[str, Any]:
return {k: v for k, v in payload.items() if v not in (None, "", [], {})}
def _is_jasmin_customer_not_found(exc: BaseException) -> bool:
"""Reconhece a resposta Jasmin quando o NIF ainda não existe como Customer.
O endpoint `getCustomerByCompanyTaxId/{nif}` devolve HTTP 400 com a
mensagem "does not correspond to an existing Customer" quando o cliente
não existe. Esse caso é esperado no fluxo v4928.1.4.6: o ClientFlow deve
criar o Customer antes de criar o orçamento. Outros erros Jasmin continuam
a falhar normalmente.
"""
message = str(exc or "").lower()
return (
"getcustomerbycompanytaxid" in message
and (
"does not correspond to an existing customer" in message
or "não corresponde a um cliente" in message
or "nao corresponde a um cliente" in message
or "cliente" in message and "exist" in message
)
)
def _as_decimal(value: Any, default: str = "0") -> Decimal:
try:
if value is None or str(value).strip() == "":
@@ -207,7 +295,7 @@ def extract_customer_data_from_opportunity(opportunity: Dict[str, Any]) -> Dict[
"street_name": street,
"postal_zone": postal_zone,
"city_name": city_name,
"country": _compact(merged.get("country") or settings.jasmin_default_country or "PT", 2).upper(),
"country": normalize_jasmin_country_key(merged.get("country") or settings.jasmin_default_country or "PT"),
"metadata": {"source": "opportunity", "opportunity_id": str(opportunity.get("id"))},
})
@@ -219,6 +307,15 @@ def build_jasmin_customer_payload(customer: Dict[str, Any]) -> Dict[str, Any]:
name = _compact(customer.get("name"), 120)
if not name:
raise JasminPayloadError("Nome do cliente em falta")
missing_address = []
if not _compact(customer.get("street_name")):
missing_address.append("morada fiscal")
if not _compact(customer.get("postal_zone")):
missing_address.append("código postal")
if not _compact(customer.get("city_name")):
missing_address.append("cidade")
if missing_address:
raise JasminPayloadError("Cliente não existe no Jasmin e faltam dados para criar Customer: " + ", ".join(missing_address))
payload = {
"partyKey": customer.get("jasmin_customer_party_key") or f"CF{tax_id}",
"name": name,
@@ -228,7 +325,7 @@ def build_jasmin_customer_payload(customer: Dict[str, Any]) -> Dict[str, Any]:
"streetName": _compact(customer.get("street_name"), 160),
"postalZone": _compact(customer.get("postal_zone"), 20),
"cityName": _compact(customer.get("city_name"), 80),
"country": _compact(customer.get("country") or settings.jasmin_default_country or "PT", 2).upper(),
"country": normalize_jasmin_country_key(customer.get("country") or settings.jasmin_default_country or "PT"),
"customerGroup": settings.jasmin_default_customer_group,
"priceList": settings.jasmin_default_price_list,
"paymentMethod": settings.jasmin_default_payment_method,
@@ -268,8 +365,15 @@ async def find_or_create_customer_for_opportunity(opportunity_id: str) -> Dict[s
return local
client = JasminClient()
result = await client.get_customer_by_tax_id(tax_id)
# Quando não existe, Jasmin devolve lista com {message: ...}; quando existe devolve objeto.
try:
result = await client.get_customer_by_tax_id(tax_id)
except JasminError as exc:
if not _is_jasmin_customer_not_found(exc):
raise
result = None
# Quando existe devolve objeto. Quando não existe, seguimos para criação
# automática do Customer Jasmin com os dados fiscais validados no ClientFlow.
if isinstance(result, dict) and result.get("customerPartyKey"):
customer = upsert_customer({
**data,
@@ -290,8 +394,15 @@ async def find_or_create_customer_for_opportunity(opportunity_id: str) -> Dict[s
payload = build_jasmin_customer_payload(data)
jasmin_id = await client.create_customer(payload)
# Confirmar por NIF para obter customerPartyKey final.
confirm = await client.get_customer_by_tax_id(tax_id)
# Confirmar por NIF para obter customerPartyKey final. Em alguns tenants a
# indexação por NIF pode não estar imediatamente disponível; nesse caso
# usamos a partyKey enviada no payload e mantemos o aviso em metadata.
confirm = None
confirm_warning = None
try:
confirm = await client.get_customer_by_tax_id(tax_id)
except JasminError as exc:
confirm_warning = str(exc)
party_key = payload.get("partyKey")
customer_name = data.get("name")
if isinstance(confirm, dict):
@@ -303,7 +414,7 @@ async def find_or_create_customer_for_opportunity(opportunity_id: str) -> Dict[s
"tax_id": tax_id,
"jasmin_customer_party_key": party_key,
"jasmin_customer_id": jasmin_id,
"metadata": {"jasmin_created": {"id": jasmin_id, "payload": payload, "confirm": confirm}},
"metadata": {"jasmin_created": {"id": jasmin_id, "payload": payload, "confirm": confirm, "confirm_warning": confirm_warning}},
})
try:
link_customer_to_opportunity(customer["id"], opportunity_id)
@@ -510,7 +621,8 @@ async def convert_latest_quotation_to_invoice(opportunity_id: str) -> Dict[str,
if existing_invoice:
raise JasminPayloadError(f"Este orçamento já tem fatura associada: {existing_invoice.get('external_id')}")
invoice_id = await JasminClient().create_invoice_from_quotation(str(quotation["external_id"]))
client = JasminClient()
invoice_id = await client.create_invoice_from_quotation(str(quotation["external_id"]))
invoice_doc = create_commercial_document(
document_kind="invoice",
customer_id=quotation.get("customer_id"),
@@ -528,10 +640,26 @@ async def convert_latest_quotation_to_invoice(opportunity_id: str) -> Dict[str,
document_date=date.today().isoformat(),
)
try:
details = await JasminClient().get_invoice(invoice_id)
details = await client.get_invoice(invoice_id)
invoice_doc = update_commercial_document_details(invoice_doc["id"], _normalize_doc_details(details)) or invoice_doc
except Exception as exc:
invoice_doc = update_commercial_document_details(invoice_doc["id"], {"payload": {"jasmin_detail_warning": str(exc)}}) or invoice_doc
try:
pdf_data, pdf_content_type = await client.print_invoice_pdf(invoice_id)
pdf_info = analyze_pdf_format(pdf_data, pdf_content_type)
_record_pdf_format_check(
str(invoice_doc.get("id") or ""),
pdf_info,
blocked=is_customer_send_blocking_pdf_warning(pdf_info),
)
except Exception as exc:
_record_pdf_format_check(
str(invoice_doc.get("id") or ""),
{"format_warning": "pdf_format_check_error"},
blocked=False,
error=str(exc),
)
mark_document_status(quotation["id"], "converted", {"invoice_document_id": invoice_doc.get("id"), "invoice_id": invoice_id})
try:
@@ -574,8 +702,14 @@ async def refresh_commercial_document_from_jasmin(document_id: str) -> Dict[str,
return updated or doc
async def get_commercial_document_pdf(document_id: str) -> tuple[Dict[str, Any], bytes, str]:
"""Obtém PDF de orçamento/fatura Jasmin para download via ClientFlow."""
async def get_commercial_document_pdf(document_id: str, *, validate_customer_send: bool = False) -> tuple[Dict[str, Any], bytes, str]:
"""Obtém PDF de orçamento/fatura Jasmin.
Quando validate_customer_send=True, bloqueia faturas Jasmin cujo PDF venha
num layout não-A4/inesperado para evitar envio ao cliente de documentos em
formato estreito/recibo. O download manual sem validação continua possível
para diagnóstico.
"""
doc = get_commercial_document(document_id)
if not doc:
raise JasminPayloadError("Documento comercial não encontrado.")
@@ -589,6 +723,12 @@ async def get_commercial_document_pdf(document_id: str) -> tuple[Dict[str, Any],
data, content_type = await client.print_quotation_pdf(external_id)
elif kind == "invoice":
data, content_type = await client.print_invoice_pdf(external_id)
pdf_info = analyze_pdf_format(data, content_type)
blocked = is_customer_send_blocking_pdf_warning(pdf_info)
_record_pdf_format_check(document_id, pdf_info, blocked=blocked)
if validate_customer_send and blocked and not _bool_env("CLIENTFLOW_ALLOW_NON_A4_JASMIN_INVOICE_PDF", False):
number = str(doc.get("document_number") or doc.get("external_id") or document_id)
raise JasminPayloadError(invoice_pdf_block_message(number, pdf_info))
else:
raise JasminPayloadError(f"Tipo de documento sem PDF Jasmin suportado: {kind}")
return doc, data, content_type