"""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)