ajuste no contas
This commit is contained in:
@@ -244,6 +244,96 @@ def _money(value: Any) -> str:
|
||||
return f"{d:.2f}".replace(".", ",")
|
||||
|
||||
|
||||
def _invoice_focus_normalize(value: Any) -> str:
|
||||
"""Normalize customer/item wording only for conservative invoice focus matching.
|
||||
|
||||
This does not infer a charge. It is used only to narrow an already fetched
|
||||
current-invoice payload to an item whose meaningful name tokens are all present
|
||||
in the customer's utterance. The backend data remains the authority.
|
||||
"""
|
||||
import unicodedata
|
||||
raw = unicodedata.normalize("NFKD", str(value or "").lower().replace("+", " mais "))
|
||||
raw = "".join(ch for ch in raw if not unicodedata.combining(ch))
|
||||
return " ".join(re.sub(r"[^a-z0-9]+", " ", raw).split())
|
||||
|
||||
|
||||
def _focused_invoice_explanation(user_text: str, billing_analysis: Any) -> str:
|
||||
"""Return a factual explanation focused on invoice items named by the user.
|
||||
|
||||
The legacy Billing Analysis ``invoiceExplanation`` is a global variation summary
|
||||
and can omit a specifically questioned item even when that item exists in
|
||||
``currentInvoice``. In that case the generic summary is the wrong customer
|
||||
answer and can trigger AOFERTA for mentioning unrelated services.
|
||||
|
||||
We narrow only when the user's utterance contains every meaningful token of an
|
||||
authoritative current-invoice item name (e.g. ``VOD + Canais abertos``).
|
||||
Otherwise the existing global explanation remains untouched.
|
||||
"""
|
||||
if not isinstance(billing_analysis, dict):
|
||||
return ""
|
||||
user_norm = _invoice_focus_normalize(user_text)
|
||||
if not user_norm:
|
||||
return ""
|
||||
user_tokens = set(user_norm.split())
|
||||
stop = {"de", "do", "da", "dos", "das", "e", "mais", "a", "o", "um", "uma"}
|
||||
matches: list[dict[str, Any]] = []
|
||||
for section in billing_analysis.get("currentInvoice") or []:
|
||||
if not isinstance(section, dict):
|
||||
continue
|
||||
for item in section.get("items") or []:
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
desc = str(item.get("desc") or "").strip()
|
||||
if not desc:
|
||||
continue
|
||||
desc_tokens = [t for t in _invoice_focus_normalize(desc).split() if t not in stop and len(t) > 1]
|
||||
# Require at least two meaningful tokens. This avoids narrowing on
|
||||
# generic one-word coincidences such as "plano" or "servico".
|
||||
if len(desc_tokens) < 2 or not all(t in user_tokens for t in desc_tokens):
|
||||
continue
|
||||
matches.append(item)
|
||||
if not matches:
|
||||
return ""
|
||||
|
||||
# Keep a single canonical item family. If the utterance happens to fully match
|
||||
# more than one distinct name, do not guess which one the customer meant.
|
||||
names = {}
|
||||
for item in matches:
|
||||
key = _invoice_focus_normalize(item.get("desc"))
|
||||
names.setdefault(key, []).append(item)
|
||||
if len(names) != 1:
|
||||
return ""
|
||||
items = next(iter(names.values()))
|
||||
canonical = str(items[0].get("desc") or "cobrança").strip()
|
||||
|
||||
def fmt_date(value: Any) -> str:
|
||||
raw = str(value or "").strip()
|
||||
m = re.match(r"^(\d{4})-(\d{2})-(\d{2})", raw)
|
||||
if m:
|
||||
return f"{m.group(3)}/{m.group(2)}/{m.group(1)}"
|
||||
return raw
|
||||
|
||||
details = []
|
||||
for item in items:
|
||||
value = _money(item.get("value"))
|
||||
date = fmt_date(item.get("date"))
|
||||
details.append(f"R$ {value}" + (f" em {date}" if date else ""))
|
||||
count = len(details)
|
||||
if count == 1:
|
||||
prefix = f"Na fatura atual, identifiquei uma cobrança de {canonical}: "
|
||||
else:
|
||||
prefix = f"Na fatura atual, identifiquei {count} cobranças de {canonical}: "
|
||||
if count == 1:
|
||||
detail_text = details[0]
|
||||
else:
|
||||
detail_text = ", ".join(details[:-1]) + " e " + details[-1]
|
||||
return (
|
||||
prefix + detail_text + ". "
|
||||
"Os dados consultados comprovam esses lançamentos na fatura, mas não informam "
|
||||
"a origem ou contratação além do item faturado."
|
||||
)
|
||||
|
||||
|
||||
def _normalize_contestation_name(value: Any) -> str:
|
||||
import unicodedata
|
||||
text = unicodedata.normalize("NFKD", str(value or "").strip())
|
||||
@@ -641,7 +731,9 @@ def build_contas_workflow_actions(service: ContasDomainService, *, idempotency_s
|
||||
call_args = dict(params)
|
||||
call_args.pop("msisdn", None)
|
||||
evidence = service.invoice_explanation(msisdn=msisdn, **call_args)
|
||||
text = _extract_invoice_text(evidence.get("billing_analysis"))
|
||||
billing_analysis = evidence.get("billing_analysis") if isinstance(evidence, dict) else None
|
||||
user_text = str(_first(params, state, "message", "text", "customer_message", "user_text") or "").strip()
|
||||
text = _focused_invoice_explanation(user_text, billing_analysis) or _extract_invoice_text(billing_analysis)
|
||||
except TimApiError as exc:
|
||||
transport = {"_transport": {"rct_operation": "base_conhecimento", "attempts": list(exc.attempts or [])}}
|
||||
rct_events = _transport_rct_events(transport)
|
||||
@@ -1104,7 +1196,7 @@ def build_contas_workflow_actions(service: ContasDomainService, *, idempotency_s
|
||||
concurrency = max(1, int(os.getenv("TIM_CANCELAMENTO_BATCH_CONCURRENCY", "5")))
|
||||
semaphore = asyncio.Semaphore(concurrency)
|
||||
|
||||
async def _process_item(item: dict[str, Any]) -> dict[str, Any]:
|
||||
async def _process_item_impl(item: dict[str, Any]) -> dict[str, Any]:
|
||||
async with semaphore:
|
||||
msisdn = str(item.get("msisdn") or _first(params, state, "msisdn"))
|
||||
subject = str(item.get("name") or item.get("desc") or item.get("subject") or "")
|
||||
@@ -1165,7 +1257,37 @@ def build_contas_workflow_actions(service: ContasDomainService, *, idempotency_s
|
||||
await idempotency_store.set(key, item_result)
|
||||
return item_result
|
||||
except Exception as exc:
|
||||
return {"success": False, "msisdn": msisdn, "subject": subject, "error": str(exc), "protocol": protocol}
|
||||
return {
|
||||
"success": False,
|
||||
"msisdn": msisdn,
|
||||
"subject": subject,
|
||||
"reason": "cancel_vas_failed",
|
||||
"error": str(exc),
|
||||
"protocol": protocol,
|
||||
"recoverable": True,
|
||||
}
|
||||
|
||||
async def _process_item(item: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Boundary de robustez: nenhuma falha técnica de um item derruba o batch/request."""
|
||||
try:
|
||||
return await _process_item_impl(item)
|
||||
except Exception as exc:
|
||||
msisdn = str(item.get("msisdn") or _first(params, state, "msisdn"))
|
||||
subject = str(item.get("name") or item.get("desc") or item.get("subject") or "")
|
||||
logger.exception(
|
||||
"Falha não tratada no cancelamento VAS; convertendo em resultado recuperável msisdn=%s subject=%s",
|
||||
msisdn,
|
||||
subject,
|
||||
)
|
||||
return {
|
||||
"success": False,
|
||||
"msisdn": msisdn,
|
||||
"subject": subject,
|
||||
"reason": "internal_processing_failed",
|
||||
"error": str(exc),
|
||||
"recoverable": True,
|
||||
"fallback_applied": True,
|
||||
}
|
||||
|
||||
valid_items = [dict(x) for x in items if isinstance(x, dict)]
|
||||
results = list(await asyncio.gather(*[_process_item(item) for item in valid_items])) if valid_items else []
|
||||
|
||||
Reference in New Issue
Block a user