Import ClientFlow production v4928.1.5.132.4
This commit is contained in:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user