Files
clientflow_backend/app/jasmin_service.py

779 lines
32 KiB
Python

"""Serviço Jasmin para ClientFlow.
Camada de negócio validada com testes reais:
- NIF pesquisado sem prefixo PT;
- cliente novo criado sem enviar campos opcionais vazios;
- orçamento ORC/ORC2026 criado via POST /sales/quotations;
- fatura criada via POST /billing/invoices/fromQuotation/{id} com body {}.
"""
from __future__ import annotations
import json
import os
import re
import uuid
from datetime import date
from decimal import Decimal
from typing import Any, Dict, List, Optional, Tuple
from sqlalchemy import text
from app.commercial_service import (
add_document_lines,
create_commercial_document,
find_invoice_for_parent,
get_customer_by_tax_id as get_local_customer_by_tax_id,
get_customer_for_opportunity,
link_customer_to_opportunity,
get_commercial_document,
get_latest_active_quotation,
list_commercial_documents,
mark_document_status,
update_commercial_document_details,
normalize_tax_id,
upsert_customer,
)
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
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()
def _setting(name: str, default: str = "") -> str:
return str(getattr(settings, name, "") or default).strip()
def _compact(value: Any, limit: int = 180) -> str:
value = re.sub(r"\s+", " ", str(value or "")).strip()
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):
return value
try:
return json.loads(value)
except Exception:
return {}
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() == "":
return Decimal(default)
return Decimal(str(value).replace(",", ".").strip())
except Exception:
return Decimal(default)
def _money_amount(value: Any) -> Optional[str]:
if value is None:
return None
if isinstance(value, dict):
for key in ("amount", "value", "baseAmount", "reportingAmount"):
if value.get(key) is not None:
return str(_as_decimal(value.get(key)))
return None
return str(_as_decimal(value))
def _first_present(data: Dict[str, Any], *keys: str) -> Any:
for key in keys:
if data.get(key) not in (None, ""):
return data.get(key)
return None
def _safe_int(value: Any) -> Optional[int]:
try:
if value is None or str(value).strip() == "":
return None
return int(value)
except Exception:
return None
def _normalize_doc_details(raw: Dict[str, Any]) -> Dict[str, Any]:
"""Extrai campos estáveis de uma resposta de documento Jasmin."""
if not isinstance(raw, dict):
return {"payload": {"jasmin_details": raw}}
document_type = _first_present(raw, "documentType", "documentTypeKey")
serie = _first_present(raw, "serie", "serieKey")
series_number = _safe_int(_first_present(raw, "seriesNumber", "number"))
document_number = _first_present(raw, "documentNumber", "naturalKey", "documentNo")
if not document_number and document_type and serie and series_number is not None:
document_number = f"{document_type} {serie}/{series_number}"
total_amount = (
_money_amount(raw.get("payableAmount"))
or _money_amount(raw.get("totalAmount"))
or _money_amount(raw.get("payableAmountAmount"))
or _money_amount(raw.get("amount"))
)
tax_amount = _money_amount(raw.get("taxExclusiveAmount")) or _money_amount(raw.get("taxAmount"))
currency = _first_present(raw, "currency", "currencyKey")
return {
"company": _first_present(raw, "company", "companyKey"),
"document_type": document_type,
"serie": serie,
"series_number": series_number,
"document_number": document_number,
"amount": total_amount,
"tax_amount": tax_amount,
"total_amount": total_amount,
"currency": currency,
"document_date": str(_first_present(raw, "documentDate", "postingDate") or "")[:10] or None,
"due_date": str(_first_present(raw, "dueDate") or "")[:10] or None,
"payload": {"jasmin_details": raw},
}
def _find_first(metadata: Dict[str, Any], keys: List[str]) -> str:
for key in keys:
value = metadata.get(key)
if isinstance(value, dict):
# permitir metadata.customer.tax_id, metadata.billing.address, etc.
for nested in keys:
if nested in value and value[nested]:
return _compact(value[nested])
elif value:
return _compact(value)
return ""
def split_pt_address(address: str) -> Tuple[str, str, str]:
clean = re.sub(r"\s+", " ", str(address or "").replace("\n", " ")).strip()
if not clean:
return "", "", ""
match = re.search(r"\b(\d{4}-\d{3}|\d{4})\b\s*(.*)$", clean)
if not match:
return clean, "", ""
return clean[: match.start()].strip(" ,-"), match.group(1), _compact(match.group(2), 80)
def extract_customer_data_from_opportunity(opportunity: Dict[str, Any]) -> Dict[str, Any]:
metadata = _metadata_dict(opportunity)
customer_meta = metadata.get("customer") if isinstance(metadata.get("customer"), dict) else {}
billing_meta = metadata.get("billing") if isinstance(metadata.get("billing"), dict) else {}
merged = {**metadata, **customer_meta, **billing_meta}
name = _compact(
merged.get("name")
or merged.get("customer_name")
or merged.get("company_name")
or opportunity.get("customer_name")
)
tax_id = normalize_tax_id(
merged.get("tax_id")
or merged.get("nif")
or merged.get("vat")
or merged.get("companyTaxID")
or merged.get("customer_tax_id")
)
street = _compact(
merged.get("street_name")
or merged.get("streetName")
or merged.get("address")
or merged.get("customer_address")
or merged.get("billing_address")
)
parsed_street, parsed_zip, parsed_city = split_pt_address(street)
postal_zone = _compact(merged.get("postal_zone") or merged.get("postalZone") or merged.get("zip") or merged.get("postal_code") or parsed_zip)
city_name = _compact(merged.get("city_name") or merged.get("cityName") or merged.get("city") or parsed_city)
if parsed_street:
street = parsed_street
return _without_empty({
"name": name,
"tax_id": tax_id,
"email": _compact(merged.get("email") or merged.get("electronicMail") or opportunity.get("customer_email")),
"phone": _compact(merged.get("phone") or merged.get("telephone") or opportunity.get("customer_phone")),
"street_name": street,
"postal_zone": postal_zone,
"city_name": city_name,
"country": normalize_jasmin_country_key(merged.get("country") or settings.jasmin_default_country or "PT"),
"metadata": {"source": "opportunity", "opportunity_id": str(opportunity.get("id"))},
})
def build_jasmin_customer_payload(customer: Dict[str, Any]) -> Dict[str, Any]:
tax_id = normalize_tax_id(customer.get("tax_id"))
if not tax_id:
raise JasminPayloadError("NIF em falta para criar cliente Jasmin")
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,
"companyTaxID": tax_id,
"electronicMail": _compact(customer.get("email"), 120),
"telephone": _compact(customer.get("phone"), 40),
"streetName": _compact(customer.get("street_name"), 160),
"postalZone": _compact(customer.get("postal_zone"), 20),
"cityName": _compact(customer.get("city_name"), 80),
"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,
"paymentTerm": settings.jasmin_default_payment_term,
"partyTaxSchema": settings.jasmin_default_party_tax_schema,
"deliveryTerm": settings.jasmin_default_delivery_term,
"currency": settings.jasmin_default_currency,
"endCustomer": True,
"oneTimeCustomer": False,
"isPerson": False,
}
# Jasmin rejeitou ElectronicMail/Telephone vazios nos testes. Omitir campos opcionais vazios.
return _without_empty(payload)
async def find_or_create_customer_for_opportunity(opportunity_id: str) -> Dict[str, Any]:
opportunity = get_opportunity(opportunity_id)
if not opportunity:
raise JasminPayloadError(f"Oportunidade não encontrada: {opportunity_id}")
linked_customer = get_customer_for_opportunity(opportunity_id)
if linked_customer:
data = {**linked_customer, **{"metadata": {"source": "linked_customer", "opportunity_id": opportunity_id}}}
else:
data = extract_customer_data_from_opportunity(opportunity)
tax_id = normalize_tax_id(data.get("tax_id"))
if not tax_id:
raise JasminPayloadError("NIF do cliente em falta. Associe um cliente à oportunidade ou preencha metadata.customer_tax_id/metadata.nif.")
local = get_local_customer_by_tax_id(tax_id)
if local and local.get("jasmin_customer_party_key"):
try:
link_customer_to_opportunity(local["id"], opportunity_id)
except Exception:
pass
return local
client = JasminClient()
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,
"name": result.get("customerName") or data.get("name"),
"tax_id": tax_id,
"jasmin_customer_party_key": result.get("customerPartyKey"),
"jasmin_customer_id": result.get("id"),
"metadata": {"jasmin_lookup": result},
})
try:
link_customer_to_opportunity(customer["id"], opportunity_id)
except Exception:
pass
return customer
if not data.get("name"):
raise JasminPayloadError("Cliente não existe no Jasmin e falta nome para criar.")
payload = build_jasmin_customer_payload(data)
jasmin_id = await client.create_customer(payload)
# 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):
party_key = confirm.get("customerPartyKey") or party_key
customer_name = confirm.get("customerName") or customer_name
customer = upsert_customer({
**data,
"name": customer_name,
"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, "confirm_warning": confirm_warning}},
})
try:
link_customer_to_opportunity(customer["id"], opportunity_id)
except Exception:
pass
return customer
def _line_metadata(item: Dict[str, Any]) -> Dict[str, Any]:
value = item.get("metadata") or {}
if isinstance(value, dict):
return value
try:
return json.loads(value)
except Exception:
return {}
def build_quotation_lines(opportunity_id: str) -> Tuple[List[Dict[str, Any]], List[Dict[str, Any]]]:
items = list_opportunity_items(opportunity_id)
active = [item for item in items if str(item.get("status") or "").upper() not in {"REJECTED", "CANCELLED"}]
if not active:
raise JasminPayloadError("A oportunidade não tem linhas de produto ativas.")
document_lines: List[Dict[str, Any]] = []
local_lines: List[Dict[str, Any]] = []
today = date.today().isoformat()
for item in active:
meta = _line_metadata(item)
jasmin_item = _compact(
meta.get("jasmin_sales_item")
or meta.get("jasmin_item_key")
or meta.get("salesItem")
or item.get("jasmin_sales_item")
or settings.jasmin_default_sales_item
or item.get("sku")
)
if not jasmin_item:
raise JasminPayloadError(f"Linha sem salesItem Jasmin: {item.get('product_name')}")
unit = _compact(meta.get("unit") or meta.get("jasmin_unit") or settings.jasmin_default_unit or "UN", 12)
tax_schema = _compact(meta.get("itemTaxSchema") or meta.get("jasmin_item_tax_schema") or settings.jasmin_default_item_tax_schema or "NORMAL", 40)
quantity = _as_decimal(item.get("quantity"), "1")
unit_price = _as_decimal(item.get("unit_price"), "0")
description = _compact(item.get("description") or item.get("product_name") or jasmin_item, 180)
document_lines.append({
"description": description,
"quantity": float(quantity),
"unit": unit,
"unitPrice": {"amount": float(unit_price)},
"itemTaxSchema": tax_schema,
"salesItem": jasmin_item,
"documentLineStatus": 1,
"deliveryDate": today,
})
local_lines.append({
"opportunity_item_id": item.get("id"),
"local_product_id": item.get("product_id"),
"jasmin_sales_item": jasmin_item,
"description": description,
"quantity": str(quantity),
"unit": unit,
"unit_price": str(unit_price),
"tax_schema": tax_schema,
"total_amount": str(_as_decimal(item.get("total_price"), str(quantity * unit_price))),
"payload": {"opportunity_item": item, "jasmin_line": document_lines[-1]},
})
return document_lines, local_lines
def validate_opportunity_for_quotation(opportunity_id: str) -> List[str]:
"""Valida dados antes de criar outbox/orçamento Jasmin.
Esta validação evita a sensação de "cliquei e depois falhou no worker" para
problemas previsíveis: cliente sem NIF, sem ficha local, ou linhas sem artigo
Jasmin. O worker continua a validar novamente ao montar o payload.
"""
errors: List[str] = []
customer = get_customer_for_opportunity(opportunity_id)
if not customer:
errors.append("Associar uma ficha de cliente à oportunidade.")
else:
if not _compact(customer.get("name")):
errors.append("Cliente sem nome fiscal.")
if not normalize_tax_id(customer.get("tax_id")):
errors.append("Cliente sem NIF válido.")
# Se o cliente ainda não existe no Jasmin, a criação precisa de morada.
if not _compact(customer.get("jasmin_customer_party_key")):
if not _compact(customer.get("street_name")):
errors.append("Cliente sem morada fiscal.")
if not _compact(customer.get("postal_zone")):
errors.append("Cliente sem código postal.")
if not _compact(customer.get("city_name")):
errors.append("Cliente sem cidade.")
try:
items = list_opportunity_items(opportunity_id)
except Exception as exc:
return [f"Não foi possível ler produtos da oportunidade: {exc}"]
active = [item for item in items if str(item.get("status") or "").upper() not in {"REJECTED", "CANCELLED"}]
if not active:
errors.append("Adicionar pelo menos um produto ativo à oportunidade.")
for item in active:
name = _compact(item.get("product_name") or item.get("sku") or "Produto")
jasmin_item = _compact(item.get("jasmin_sales_item"))
if not jasmin_item:
errors.append(f"Produto '{name}' sem Artigo Jasmin.")
if _as_decimal(item.get("unit_price"), "0") <= 0:
errors.append(f"Produto '{name}' com preço unitário zero ou inválido.")
return errors
def assert_opportunity_ready_for_quotation(opportunity_id: str) -> None:
errors = validate_opportunity_for_quotation(opportunity_id)
if errors:
raise JasminPayloadError("Não é possível criar orçamento:\n- " + "\n- ".join(errors))
def build_quotation_payload(opportunity_id: str, customer: Dict[str, Any]) -> Tuple[Dict[str, Any], List[Dict[str, Any]]]:
document_lines, local_lines = build_quotation_lines(opportunity_id)
today = date.today().isoformat()
customer_party_key = customer.get("jasmin_customer_party_key")
if not customer_party_key:
raise JasminPayloadError("Cliente local sem jasmin_customer_party_key")
payload = {
"documentType": settings.jasmin_quotation_type,
"serie": settings.jasmin_quotation_serie,
"seriesNumber": 0,
"company": settings.jasmin_company_key,
"documentDate": today,
"postingDate": today,
"buyerCustomerParty": customer_party_key,
"buyerCustomerPartyName": customer.get("name"),
"exchangeRate": 1,
"discount": 0,
"paymentMethod": settings.jasmin_default_payment_method,
"paymentTerm": settings.jasmin_default_payment_term,
"currency": settings.jasmin_default_currency,
"deliveryTerm": settings.jasmin_default_delivery_term,
"priceList": settings.jasmin_default_price_list,
"remarks": f"Orçamento criado via API ClientFlow para oportunidade {opportunity_id}.",
"documentLines": document_lines,
}
missing = [name for name in ["documentType", "serie", "company"] if not payload.get(name)]
if missing:
raise JasminPayloadError("Configuração Jasmin em falta: " + ", ".join(missing))
return payload, local_lines
async def create_quotation_for_opportunity(opportunity_id: str) -> Dict[str, Any]:
customer = await find_or_create_customer_for_opportunity(opportunity_id)
payload, local_lines = build_quotation_payload(opportunity_id, customer)
quotation_id = await JasminClient().create_quotation(payload)
amount = sum(_as_decimal(line.get("total_amount"), "0") for line in local_lines)
doc = create_commercial_document(
document_kind="quotation",
customer_id=customer.get("id"),
opportunity_id=opportunity_id,
system="jasmin",
external_id=quotation_id,
company=payload.get("company"),
document_type=payload.get("documentType"),
serie=payload.get("serie"),
customer_party_key=customer.get("jasmin_customer_party_key"),
status="created",
amount=str(amount),
total_amount=str(amount),
currency=payload.get("currency") or "EUR",
payload={"jasmin_payload": payload, "jasmin_id": quotation_id},
document_date=payload.get("documentDate"),
)
try:
details = await JasminClient().get_quotation(quotation_id)
doc = update_commercial_document_details(doc["id"], _normalize_doc_details(details)) or doc
except Exception as exc:
doc = update_commercial_document_details(doc["id"], {"payload": {"jasmin_detail_warning": str(exc)}}) or doc
add_document_lines(doc["id"], local_lines)
try:
register_operation_action(
opportunity_id,
"jasmin_quotation",
external_id=quotation_id,
external_name=f"{payload.get('documentType')} {payload.get('serie')}",
payload={"commercial_document_id": doc.get("id"), "customer_party_key": customer.get("jasmin_customer_party_key")},
created_by="jasmin_service",
)
except Exception:
# operation_links é compatibilidade visual; não deve falhar o fluxo principal.
pass
set_opportunity_stage(opportunity_id, "QUOTE_SENT", note="Orçamento Jasmin criado via ClientFlow.", created_by="jasmin_service")
return {"customer": customer, "quotation": doc, "quotation_id": quotation_id, "payload": payload}
async def convert_latest_quotation_to_invoice(opportunity_id: str) -> Dict[str, Any]:
quotation = get_latest_active_quotation(opportunity_id)
if not quotation or not quotation.get("external_id"):
raise JasminPayloadError("Não existe orçamento Jasmin ativo para converter.")
existing_invoice = find_invoice_for_parent(quotation["id"])
if existing_invoice:
raise JasminPayloadError(f"Este orçamento já tem fatura associada: {existing_invoice.get('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"),
opportunity_id=opportunity_id,
system="jasmin",
external_id=invoice_id,
company=quotation.get("company"),
customer_party_key=quotation.get("customer_party_key"),
status="issued",
amount=quotation.get("amount"),
total_amount=quotation.get("total_amount") or quotation.get("amount"),
currency=quotation.get("currency") or "EUR",
parent_document_id=quotation.get("id"),
payload={"from_quotation": quotation, "jasmin_invoice_id": invoice_id},
document_date=date.today().isoformat(),
)
try:
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:
register_operation_action(
opportunity_id,
"jasmin_invoice",
external_id=invoice_id,
external_name="Fatura Jasmin",
payload={"commercial_document_id": invoice_doc.get("id"), "parent_quotation_id": quotation.get("external_id")},
created_by="jasmin_service",
)
except Exception:
pass
set_opportunity_stage(opportunity_id, "INVOICED", note="Orçamento Jasmin convertido em fatura.", created_by="jasmin_service")
return {"quotation": quotation, "invoice": invoice_doc, "invoice_id": invoice_id}
async def refresh_commercial_document_from_jasmin(document_id: str) -> Dict[str, Any]:
"""Atualiza número/série/valor de um documento já criado no Jasmin."""
doc = get_commercial_document(document_id)
if not doc:
raise JasminPayloadError("Documento comercial não encontrado.")
if str(doc.get("system") or "jasmin") != "jasmin":
raise JasminPayloadError("Documento não pertence ao sistema Jasmin.")
external_id = str(doc.get("external_id") or "").strip()
if not external_id:
raise JasminPayloadError("Documento sem external_id Jasmin.")
client = JasminClient()
kind = str(doc.get("document_kind") or "")
if kind == "quotation":
details = await client.get_quotation(external_id)
elif kind == "invoice":
details = await client.get_invoice(external_id)
else:
raise JasminPayloadError(f"Tipo de documento Jasmin não suportado: {kind}")
updated = update_commercial_document_details(document_id, _normalize_doc_details(details))
return updated or doc
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.")
external_id = str(doc.get("external_id") or "").strip()
if not external_id:
raise JasminPayloadError("Documento sem external_id Jasmin.")
client = JasminClient()
kind = str(doc.get("document_kind") or "")
if kind == "quotation":
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
def enqueue_create_quotation(opportunity_id: str, *, created_by: str = "operator") -> str:
assert_opportunity_ready_for_quotation(opportunity_id)
outbox_id = create_outbox_item(
business_event_id=str(uuid.uuid4()),
target_system="jasmin",
action_type="create_quotation",
payload={"opportunity_id": opportunity_id, "created_by": created_by},
idempotency_key=f"jasmin:quotation:{opportunity_id}",
)
if not outbox_id:
raise JasminPayloadError("Já existe pedido de orçamento Jasmin com a mesma chave de idempotência.")
return outbox_id
def enqueue_convert_latest_to_invoice(opportunity_id: str, *, created_by: str = "operator") -> str:
quotation = get_latest_active_quotation(opportunity_id)
quotation_external_id = str((quotation or {}).get("external_id") or "")
if not quotation_external_id:
raise JasminPayloadError("Não existe orçamento Jasmin ativo para faturar.")
outbox_id = create_outbox_item(
business_event_id=str(uuid.uuid4()),
target_system="jasmin",
action_type="convert_quotation_to_invoice",
payload={"opportunity_id": opportunity_id, "quotation_external_id": quotation_external_id, "created_by": created_by},
idempotency_key=f"jasmin:invoice_from_quotation:{quotation_external_id}",
)
if not outbox_id:
raise JasminPayloadError("Já existe pedido de conversão desta proposta em fatura.")
return outbox_id
async def process_create_quotation_outbox(payload: Dict[str, Any]) -> Dict[str, Any]:
opportunity_id = str(payload.get("opportunity_id") or "").strip()
if not opportunity_id:
raise JasminPayloadError("opportunity_id em falta")
return await create_quotation_for_opportunity(opportunity_id)
async def process_convert_invoice_outbox(payload: Dict[str, Any]) -> Dict[str, Any]:
opportunity_id = str(payload.get("opportunity_id") or "").strip()
if not opportunity_id:
raise JasminPayloadError("opportunity_id em falta")
return await convert_latest_quotation_to_invoice(opportunity_id)