Release v4928.1.4.2 stable
This commit is contained in:
638
app/jasmin_service.py
Normal file
638
app/jasmin_service.py
Normal file
@@ -0,0 +1,638 @@
|
||||
"""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)
|
||||
Reference in New Issue
Block a user