639 lines
26 KiB
Python
639 lines
26 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.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 _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 _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 _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": _compact(merged.get("country") or settings.jasmin_default_country or "PT", 2).upper(),
|
|
"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")
|
|
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": _compact(customer.get("country") or settings.jasmin_default_country or "PT", 2).upper(),
|
|
"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()
|
|
result = await client.get_customer_by_tax_id(tax_id)
|
|
# Quando não existe, Jasmin devolve lista com {message: ...}; quando existe devolve objeto.
|
|
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.
|
|
confirm = await client.get_customer_by_tax_id(tax_id)
|
|
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}},
|
|
})
|
|
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')}")
|
|
|
|
invoice_id = await JasminClient().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 JasminClient().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
|
|
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) -> tuple[Dict[str, Any], bytes, str]:
|
|
"""Obtém PDF de orçamento/fatura Jasmin para download via ClientFlow."""
|
|
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)
|
|
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)
|