bugfix: Invoice details

This commit is contained in:
2026-08-24 16:39:08 -03:00
parent 4b3e4d44e8
commit d7f270f52a
102 changed files with 183 additions and 10 deletions

View File

@@ -19,6 +19,10 @@ class InvoiceContext:
complete_invoices: dict[str, Any] | None = None complete_invoices: dict[str, Any] | None = None
billing_analysis: dict[str, Any] | None = None billing_analysis: dict[str, Any] | None = None
invoice_detail: Any = None invoice_detail: Any = None
invoice_amount: str = ""
invoice_amount_open: str = ""
invoice_period: str = ""
invoice_emissao: str = ""
customer_id: str = "" customer_id: str = ""
error: str | None = None error: str | None = None
cache_hit: bool = False cache_hit: bool = False
@@ -33,6 +37,10 @@ class InvoiceContext:
"complete_invoices_payload": self.complete_invoices, "complete_invoices_payload": self.complete_invoices,
"billing_analysis": self.billing_analysis, "billing_analysis": self.billing_analysis,
"invoice_detail": self.invoice_detail, "invoice_detail": self.invoice_detail,
"invoice_amount": self.invoice_amount,
"invoice_amount_open": self.invoice_amount_open,
"invoice_period": self.invoice_period,
"invoice_emissao": self.invoice_emissao,
"customer_id": self.customer_id, "customer_id": self.customer_id,
"invoice_context_error": self.error, "invoice_context_error": self.error,
"invoice_context_cache_hit": self.cache_hit, "invoice_context_cache_hit": self.cache_hit,
@@ -42,6 +50,55 @@ class InvoiceContext:
} }
def extract_invoice_summary_context(detail: Any) -> dict[str, str]:
"""Extrai o resumo semântico da fatura detalhada.
Mantém a paridade com o prefetch do Contas original: ``bill_pdf`` devolve
um envelope com ``parsed_content`` e o parser coloca o total em
``total_geral``. Workflows e agentes não precisam conhecer a estrutura do
PDF para consumir ``invoice_amount``/``invoice_amount_open``.
"""
parsed = detail
if isinstance(detail, dict) and isinstance(detail.get("parsed_content"), dict):
parsed = detail["parsed_content"]
if not isinstance(parsed, dict):
return {}
resumo = parsed.get("Fatura Resumo")
if not isinstance(resumo, list):
resumo = []
invoice_period = ""
invoice_emissao = ""
invoice_amount = ""
parsed_total = parsed.get("total_geral")
if parsed_total is not None and str(parsed_total).strip():
invoice_amount = str(parsed_total).strip()
for item in resumo:
if not isinstance(item, dict):
continue
desc = str(item.get("desc", "") or "").strip().casefold()
if desc in {"período", "periodo"}:
invoice_period = str(item.get("period", "") or "").strip()
elif desc in {"emissão", "emissao"}:
invoice_emissao = str(item.get("emissao", "") or "").strip()
if not invoice_amount and bool(item.get("is_total")) and str(item.get("value", "")).strip():
invoice_amount = str(item.get("value", "")).strip()
if not invoice_amount and "total" in desc and str(item.get("value", "")).strip():
invoice_amount = str(item.get("value", "")).strip()
context: dict[str, str] = {}
if invoice_period:
context["invoice_period"] = invoice_period
if invoice_emissao:
context["invoice_emissao"] = invoice_emissao
if invoice_amount:
context["invoice_amount"] = invoice_amount
context["invoice_amount_open"] = invoice_amount
return context
class InvoiceContextService: class InvoiceContextService:
"""Session-scoped invoice prefetch backed by the framework cache. """Session-scoped invoice prefetch backed by the framework cache.
@@ -155,6 +212,7 @@ class InvoiceContextService:
detail = None detail = None
detail_ms = 0.0 detail_ms = 0.0
summary: dict[str, str] = {}
if include_detail and resolved_invoice_id: if include_detail and resolved_invoice_id:
detail, detail_error, detail_ms = await self._timed_to_thread( detail, detail_error, detail_ms = await self._timed_to_thread(
"invoice_detail", self.client.bill_pdf, msisdn, resolved_invoice_id, customer_id, "invoice_detail", self.client.bill_pdf, msisdn, resolved_invoice_id, customer_id,
@@ -163,6 +221,8 @@ class InvoiceContextService:
if detail_error: if detail_error:
errors["invoice_detail"] = detail_error errors["invoice_detail"] = detail_error
error_parts.append(f"invoice_detail: {detail_error}") error_parts.append(f"invoice_detail: {detail_error}")
else:
summary = extract_invoice_summary_context(detail)
metadata = { metadata = {
"cache_hit": False, "cache_hit": False,
@@ -177,7 +237,12 @@ class InvoiceContextService:
msisdn=msisdn, invoice_id=resolved_invoice_id, msisdn=msisdn, invoice_id=resolved_invoice_id,
complete_invoices=complete if isinstance(complete, dict) else None, complete_invoices=complete if isinstance(complete, dict) else None,
billing_analysis=billing if isinstance(billing, dict) else None, billing_analysis=billing if isinstance(billing, dict) else None,
invoice_detail=detail, customer_id=customer_id, invoice_detail=detail,
invoice_amount=summary.get("invoice_amount", ""),
invoice_amount_open=summary.get("invoice_amount_open", ""),
invoice_period=summary.get("invoice_period", ""),
invoice_emissao=summary.get("invoice_emissao", ""),
customer_id=customer_id,
error="; ".join(error_parts) or None, cache_hit=False, business_events=events, errors=errors, metadata=metadata, error="; ".join(error_parts) or None, cache_hit=False, business_events=events, errors=errors, metadata=metadata,
) )
@@ -219,7 +284,10 @@ class InvoiceContextService:
await self.cache.set(key, { await self.cache.set(key, {
"msisdn": ctx.msisdn, "invoice_id": ctx.invoice_id, "msisdn": ctx.msisdn, "invoice_id": ctx.invoice_id,
"complete_invoices": ctx.complete_invoices, "billing_analysis": ctx.billing_analysis, "complete_invoices": ctx.complete_invoices, "billing_analysis": ctx.billing_analysis,
"invoice_detail": ctx.invoice_detail, "customer_id": ctx.customer_id, "invoice_detail": ctx.invoice_detail,
"invoice_amount": ctx.invoice_amount, "invoice_amount_open": ctx.invoice_amount_open,
"invoice_period": ctx.invoice_period, "invoice_emissao": ctx.invoice_emissao,
"customer_id": ctx.customer_id,
"error": ctx.error, "cache_hit": False, "error": ctx.error, "cache_hit": False,
"errors": dict(ctx.errors or {}), "metadata": dict(ctx.metadata or {}), "errors": dict(ctx.errors or {}), "metadata": dict(ctx.metadata or {}),
"_fetched_at": time.time(), "_fetched_at": time.time(),

View File

@@ -36,8 +36,29 @@ class ContasDomainService:
partial = [p for p in products if needle in str(p.get("name") or p.get("description") or "").lower() or str(p.get("name") or p.get("description") or "").lower() in needle] partial = [p for p in products if needle in str(p.get("name") or p.get("description") or "").lower() or str(p.get("name") or p.get("description") or "").lower() in needle]
return partial[0] if partial else None return partial[0] if partial else None
def consultar_faturas(self, *, msisdn: str, **_: Any) -> Any: def consultar_faturas(self, *, msisdn: str, **args: Any) -> Any:
return self.client.consultar_faturas(msisdn) """Consulta faturas e expõe o resumo semântico quando pré-carregado.
A fonte de ``invoice_amount`` continua sendo a fatura detalhada (Bill PDF),
como no Contas original. O MCP apenas apresenta esse dado junto do retorno
de Complete Invoices; não altera o contrato do backend TIM nem inventa valor.
"""
complete = args.get("complete_invoices_payload")
if not isinstance(complete, dict):
complete = self.client.consultar_faturas(msisdn)
if not isinstance(complete, dict):
return complete
result = dict(complete)
for key in ("invoice_amount", "invoice_amount_open", "invoice_period", "invoice_emissao"):
value = args.get(key)
if value not in (None, ""):
result[key] = value
if args.get("invoice_id") not in (None, ""):
result.setdefault("invoice_id", args.get("invoice_id"))
if args.get("customer_id") not in (None, ""):
result.setdefault("customer_id", args.get("customer_id"))
return result
def consultar_plano(self, *, msisdn: str, **args: Any) -> dict[str, Any]: def consultar_plano(self, *, msisdn: str, **args: Any) -> dict[str, Any]:
"""Retorna somente os planos presentes na evidência de billing analysis. """Retorna somente os planos presentes na evidência de billing analysis.

Binary file not shown.

Binary file not shown.

View File

@@ -71,7 +71,7 @@ class ToolCall(BaseModel):
TOOLS: dict[str, dict[str, Any]] = { TOOLS: dict[str, dict[str, Any]] = {
"consultar_faturas": {"description": "Consulta faturas do cliente.", "input_schema": {"msisdn": "string"}}, "consultar_faturas": {"description": "Consulta faturas do cliente, incluindo valor total quando disponível na fatura detalhada.", "input_schema": {"msisdn": "string"}},
"consultar_plano": {"description": "Consulta exclusivamente o plano ou planos contratados presentes na fatura.", "input_schema": {"msisdn": "string"}}, "consultar_plano": {"description": "Consulta exclusivamente o plano ou planos contratados presentes na fatura.", "input_schema": {"msisdn": "string"}},
"invoice_explanation": {"description": "Executa o workflow de explicação de fatura com pause/resume pelo WorkflowRuntime do framework.", "input_schema": {"msisdn": "string"}}, "invoice_explanation": {"description": "Executa o workflow de explicação de fatura com pause/resume pelo WorkflowRuntime do framework.", "input_schema": {"msisdn": "string"}},
"consultar_vas": {"description": "Consulta VAS ativos.", "input_schema": {"msisdn": "string"}}, "consultar_vas": {"description": "Consulta VAS ativos.", "input_schema": {"msisdn": "string"}},
@@ -307,17 +307,22 @@ def _preflight_subject(name: str, args: dict[str, Any]) -> dict[str, Any] | None
return None return None
async def _enrich_invoice_context(name: str, args: dict[str, Any]) -> None: async def _enrich_invoice_context(name: str, args: dict[str, Any]) -> None:
if name not in {"consultar_plano", "invoice_explanation", "cancelar_vas_avulso", "tratar_vas_estrategico", "contestar_cobranca"}: if name not in {"consultar_faturas", "consultar_plano", "invoice_explanation", "cancelar_vas_avulso", "tratar_vas_estrategico", "contestar_cobranca"}:
return return
msisdn = str(args.get("msisdn") or "").strip() msisdn = str(args.get("msisdn") or "").strip()
if not msisdn: if not msisdn:
return return
# If all required evidence is already present, preserve it exactly. # If all required evidence is already present, preserve it exactly. For
if isinstance(args.get("complete_invoices_payload"), dict) and isinstance(args.get("billing_analysis"), dict): # consultar_faturas/invoice_explanation the semantic amount also matters, so
# a cached pair without detail must still be enriched.
has_base = isinstance(args.get("complete_invoices_payload"), dict) and isinstance(args.get("billing_analysis"), dict)
needs_detail = name in {"consultar_faturas", "invoice_explanation", "cancelar_vas_avulso", "contestar_cobranca"}
has_detail = isinstance(args.get("invoice_detail"), dict) or args.get("invoice_amount") not in (None, "")
if has_base and (not needs_detail or has_detail):
return return
session_id = str(args.get("session_id") or args.get("original_session_id") or args.get("conversation_key") or "").strip() session_id = str(args.get("session_id") or args.get("original_session_id") or args.get("conversation_key") or "").strip()
invoice_id = str(args.get("invoice_id") or args.get("current_invoice_number") or "").strip() invoice_id = str(args.get("invoice_id") or args.get("current_invoice_number") or "").strip()
include_detail = name in {"cancelar_vas_avulso", "contestar_cobranca"} and not isinstance(args.get("invoice_detail"), dict) include_detail = name in {"consultar_faturas", "invoice_explanation", "cancelar_vas_avulso", "contestar_cobranca"} and not isinstance(args.get("invoice_detail"), dict)
try: try:
ctx = await get_invoice_context_service().get( ctx = await get_invoice_context_service().get(
session_id=session_id, session_id=session_id,

Some files were not shown because too many files have changed in this diff Show More