Projeto do Agent Contas ORACLE
This commit is contained in:
4
app/domain/contas/__init__.py
Normal file
4
app/domain/contas/__init__.py
Normal file
@@ -0,0 +1,4 @@
|
||||
"""TIM Contas business domain for the framework-native application."""
|
||||
from .service import ContasDomainService
|
||||
|
||||
__all__ = ["ContasDomainService"]
|
||||
566
app/domain/contas/client.py
Normal file
566
app/domain/contas/client.py
Normal file
@@ -0,0 +1,566 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import time
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
from urllib.parse import quote
|
||||
|
||||
import requests
|
||||
|
||||
from .integrations.secure_pdf_crypto import encrypt_secure_pdf_value
|
||||
from .parsers import parse_tim_bill_pdf
|
||||
|
||||
|
||||
class TimApiError(RuntimeError):
|
||||
def __init__(self, message: str, *, status_code: int | None = None, body: Any = None, attempts: list[dict[str, Any]] | None = None):
|
||||
super().__init__(message)
|
||||
self.status_code = status_code
|
||||
self.body = body
|
||||
self.attempts = list(attempts or [])
|
||||
|
||||
|
||||
class TimApiClient:
|
||||
"""Thin TIM integration adapter.
|
||||
|
||||
This class deliberately contains no agent, LangGraph, memory, routing or LLM logic.
|
||||
Those responsibilities belong to agent_framework_oci.
|
||||
"""
|
||||
|
||||
FIXTURES = Path(__file__).with_name("fixtures")
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.mock = os.getenv("TIM_USE_MOCK_GATEWAY", "true").lower() in {"1", "true", "yes", "on"} or os.getenv("TIM_GATEWAY_MODE", "mock").lower() == "mock"
|
||||
self.timeout = int(os.getenv("TIM_GATEWAY_DEFAULT_TIMEOUT", "30"))
|
||||
self.max_retries = int(os.getenv("TIM_GATEWAY_RETRY_MAX_RETRIES", "3"))
|
||||
self.backoff = float(os.getenv("TIM_GATEWAY_RETRY_BACKOFF_FACTOR", "0.5"))
|
||||
|
||||
def fixture(self, name: str) -> Any:
|
||||
path = self.FIXTURES / f"{name}.json"
|
||||
return json.loads(path.read_text(encoding="utf-8"))
|
||||
|
||||
def _headers(self, *, client_id: str | None = None, auth: str | None = None, extra: dict[str, str] | None = None) -> dict[str, str]:
|
||||
h = {"Content-Type": "application/json", "Accept": "application/json"}
|
||||
if client_id:
|
||||
h["client_id"] = client_id
|
||||
if auth:
|
||||
h["Authorization"] = auth
|
||||
if extra:
|
||||
h.update({k: v for k, v in extra.items() if v not in (None, "")})
|
||||
return h
|
||||
|
||||
def request(self, method: str, url: str, *, payload: dict[str, Any] | None = None, params: dict[str, Any] | None = None, headers: dict[str, str] | None = None, timeout: int | None = None, attempt_log: list[dict[str, Any]] | None = None) -> Any:
|
||||
if not url:
|
||||
raise TimApiError("Endpoint TIM não configurado")
|
||||
last: Exception | None = None
|
||||
attempts = max(1, self.max_retries)
|
||||
for attempt in range(attempts):
|
||||
started = time.monotonic()
|
||||
try:
|
||||
resp = requests.request(method, url, json=payload, params=params, headers=headers or {}, timeout=timeout or self.timeout)
|
||||
if resp.status_code in {204, 205}:
|
||||
if attempt_log is not None:
|
||||
attempt_log.append({"attempt": attempt + 1, "success": True, "status_code": resp.status_code, "latency_ms": int((time.monotonic()-started)*1000), "api_url": url})
|
||||
return {"status": resp.status_code, "message": "OK"}
|
||||
resp.raise_for_status()
|
||||
if attempt_log is not None:
|
||||
attempt_log.append({"attempt": attempt + 1, "success": True, "status_code": resp.status_code, "latency_ms": int((time.monotonic()-started)*1000), "api_url": url})
|
||||
if not resp.content:
|
||||
return {"status": resp.status_code, "message": "OK"}
|
||||
ctype = resp.headers.get("content-type", "").lower()
|
||||
if "json" in ctype:
|
||||
return resp.json()
|
||||
return {"status": resp.status_code, "raw_content": resp.content, "content_type": ctype}
|
||||
except requests.RequestException as exc:
|
||||
last = exc
|
||||
if attempt_log is not None:
|
||||
response = getattr(exc, "response", None)
|
||||
attempt_log.append({"attempt": attempt + 1, "success": False, "status_code": getattr(response, "status_code", None), "latency_ms": int((time.monotonic()-started)*1000), "error": str(exc), "api_url": url})
|
||||
if attempt >= attempts - 1:
|
||||
break
|
||||
time.sleep(self.backoff * (2 ** attempt))
|
||||
response = getattr(last, "response", None)
|
||||
status_code = getattr(response, "status_code", None)
|
||||
body = None
|
||||
if response is not None:
|
||||
try:
|
||||
body = response.json()
|
||||
except Exception:
|
||||
body = getattr(response, "text", None)
|
||||
raise TimApiError(str(last), status_code=status_code, body=body, attempts=list(attempt_log or []))
|
||||
|
||||
@staticmethod
|
||||
def _attach_transport(result: Any, *, operation: str, attempts: list[dict[str, Any]]) -> Any:
|
||||
metadata = {
|
||||
"rct_operation": operation,
|
||||
"attempts": list(attempts),
|
||||
"api_response_payload": result if isinstance(result, (dict, list, str, int, float, bool)) else str(result),
|
||||
}
|
||||
if isinstance(result, dict):
|
||||
out = dict(result)
|
||||
out["_transport"] = metadata
|
||||
return out
|
||||
return {"data": result, "_transport": metadata}
|
||||
|
||||
def consultar_faturas(self, msisdn: str) -> Any:
|
||||
if self.mock:
|
||||
return self.fixture("complete_invoices")
|
||||
url = os.getenv("TIM_COMPLETE_INVOICES_URL", "")
|
||||
return self.request("POST", url, payload={"msisdn": msisdn}, headers=self._headers(client_id=None, auth=os.getenv("TIM_COMPLETE_INVOICES_AUTH", ""), extra={"ClientID": os.getenv("TIM_COMPLETE_INVOICES_CLIENT_ID", "AIAGENTCR")}))
|
||||
|
||||
def billing_analysis(self, msisdn: str, **context: Any) -> Any:
|
||||
if self.mock:
|
||||
return self.fixture("divergencia")
|
||||
base = os.getenv("TIM_DIVERGENCIA_URL", "").rstrip("/")
|
||||
url = f"{base}/{quote(msisdn)}"
|
||||
auth = os.getenv("TIM_DIVERGENCIA_AUTH", "") or os.getenv("TIM_SECURE_PDF_AUTH", "")
|
||||
headers = self._headers(client_id=None, auth=auth, extra={
|
||||
"clientID": os.getenv("TIM_DIVERGENCIA_CLIENT_ID", "AIAGENTCR"),
|
||||
})
|
||||
params = {"channel": context.get("channel") or "AIAGENTCR"}
|
||||
attempts: list[dict[str, Any]] = []
|
||||
try:
|
||||
result = self.request("GET", url, params=params, headers=headers, timeout=int(os.getenv("TIM_DIVERGENCIA_TIMEOUT", "120")), attempt_log=attempts)
|
||||
return self._attach_transport(result, operation="base_conhecimento", attempts=attempts)
|
||||
except TimApiError as exc:
|
||||
if not exc.attempts:
|
||||
exc.attempts = attempts
|
||||
raise
|
||||
|
||||
def consultar_vas(self, msisdn: str) -> Any:
|
||||
if self.mock:
|
||||
return self.fixture("query_vas")
|
||||
base = os.getenv("TIM_URL_CONSULTA_VAS", "")
|
||||
url = base.replace("{msisdn}", quote(msisdn))
|
||||
return self.request("GET", url, headers=self._headers(client_id=None, auth=os.getenv("TIM_CONSULTA_AUTH", ""), extra={"clientId": os.getenv("TIM_CONSULTA_CLIENT_ID", "AIAAGENTCR")}))
|
||||
|
||||
def historico_vas(self, msisdn: str) -> Any:
|
||||
if self.mock:
|
||||
return self.fixture("vas_history")
|
||||
base = os.getenv("TIM_VAS_HISTORY_URL", "")
|
||||
sep = "&" if "?" in base else "?"
|
||||
url = f"{base}{sep}msisdn={quote(msisdn)}"
|
||||
headers = self._headers(
|
||||
client_id=None,
|
||||
auth=os.getenv("TIM_VAS_HISTORY_AUTH", ""),
|
||||
extra={
|
||||
"clientId": os.getenv("TIM_VAS_HISTORY_CLIENT_ID", os.getenv("TIM_DEFAULT_CLIENT_ID", "CHAT")),
|
||||
"messageId": os.getenv("TIM_VAS_HISTORY_MESSAGE_ID", "") or str(uuid.uuid4()),
|
||||
},
|
||||
)
|
||||
return self.request("GET", url, headers=headers, timeout=int(os.getenv("TIM_VAS_HISTORY_TIMEOUT", "30")))
|
||||
|
||||
def bloquear_vas(self, msisdn: str, service: dict[str, Any]) -> Any:
|
||||
if self.mock:
|
||||
return self.fixture("block_vas")
|
||||
url = os.getenv("TIM_URL_BLOQUEIO_VAS", "")
|
||||
digits = "".join(ch for ch in str(msisdn) if ch.isdigit())
|
||||
normalized = digits[2:] if len(digits) == 13 else digits
|
||||
app_id = str(service.get("appId") or service.get("app_id") or "")
|
||||
csp_id = str(service.get("cspId") or service.get("csp_id") or os.getenv("TIM_DEFAULT_CSP_ID", "740"))
|
||||
op = os.getenv("TIM_BLOQUEIO_OPERATION_TYPE", "block")
|
||||
base = {"Customer": {"Msisdn": normalized}, "AppId": app_id, "CspId": csp_id, "TypeOperation": op}
|
||||
payloads = [
|
||||
{"customer": {"msisdn": normalized}, "appId": app_id, "cspId": csp_id, "typeOperation": op},
|
||||
{"input": base, "Input": base},
|
||||
{"vasBlock": {"msisdn": normalized, "appId": app_id, "cspId": csp_id, "type": op}},
|
||||
]
|
||||
mode = os.getenv("TIM_BLOQUEIO_PAYLOAD_MODE", "auto").strip().lower()
|
||||
if mode == "vasblock": payloads = [payloads[2], payloads[0]]
|
||||
elif mode == "input": payloads = [payloads[1], payloads[0]]
|
||||
elif mode == "pmid": payloads = [payloads[0], payloads[1]]
|
||||
headers = self._headers(
|
||||
client_id=None,
|
||||
auth=os.getenv("TIM_BLOQUEIO_AUTH", ""),
|
||||
extra={
|
||||
"Accept-Encoding": os.getenv("TIM_BLOQUEIO_ACCEPT_ENCODING", "gzip,deflate"),
|
||||
"clientId": os.getenv("TIM_BLOQUEIO_CLIENT_ID", "AIAGENTCR"),
|
||||
"messageId": str(uuid.uuid4()),
|
||||
},
|
||||
)
|
||||
last = None
|
||||
for index, payload in enumerate(payloads):
|
||||
try:
|
||||
return self.request("POST", url, payload=payload, headers=headers, timeout=int(os.getenv("TIM_BLOQUEIO_TIMEOUT", "30")))
|
||||
except TimApiError as exc:
|
||||
last = exc
|
||||
if exc.status_code != 400 or index >= len(payloads) - 1:
|
||||
raise
|
||||
raise last or TimApiError("Bloqueio VAS falhou")
|
||||
|
||||
def cancelar_vas(self, msisdn: str, service: dict[str, Any], *, protocol: str = "") -> Any:
|
||||
if self.mock:
|
||||
return self.fixture("cancel_vas")
|
||||
url = os.getenv("TIM_CANCELAMENTO_URL", "")
|
||||
payload = {
|
||||
"channel": "AIAGENTCR",
|
||||
"msisdn": msisdn,
|
||||
"appId": str(service.get("appId") or service.get("app_id") or ""),
|
||||
"cspId": str(service.get("cspId") or service.get("csp_id") or os.getenv("TIM_DEFAULT_CSP_ID", "740")),
|
||||
"interactionProtocol": protocol,
|
||||
}
|
||||
headers = self._headers(
|
||||
client_id=None,
|
||||
auth=os.getenv("TIM_CANCELAMENTO_AUTH", ""),
|
||||
extra={
|
||||
"clientId": os.getenv("TIM_CANCELAMENTO_CLIENT_ID", os.getenv("TIM_DEFAULT_CLIENT_ID", "AIAGENTCR")),
|
||||
"messageId": str(uuid.uuid4()),
|
||||
"AuthorizationOAM": os.getenv("TIM_CANCELAMENTO_AUTHORIZATION_OAM", ""),
|
||||
"Cn_field": os.getenv("TIM_CANCELAMENTO_CN_FIELD", ""),
|
||||
"Type_field": os.getenv("TIM_CANCELAMENTO_TYPE_FIELD", ""),
|
||||
},
|
||||
)
|
||||
attempts: list[dict[str, Any]] = []
|
||||
result = self.request("DELETE", url, payload=payload, headers=headers, timeout=int(os.getenv("TIM_CANCELAMENTO_TIMEOUT", "30")), attempt_log=attempts)
|
||||
return self._attach_transport(result, operation="cancela_vas", attempts=attempts)
|
||||
|
||||
def contrato(self, msisdn: str) -> Any:
|
||||
if self.mock:
|
||||
return self.fixture("contrato")
|
||||
base = os.getenv("TIM_CONTRATO_URL", "").rstrip("/")
|
||||
headers = self._headers(
|
||||
client_id=None,
|
||||
auth=os.getenv("TIM_CONTRATO_AUTH", ""),
|
||||
extra={"clientId": os.getenv("TIM_CONTRATO_CLIENT_ID", os.getenv("TIM_DEFAULT_CLIENT_ID", "CHAT"))},
|
||||
)
|
||||
return self.request("GET", f"{base}/{quote(msisdn)}", headers=headers, timeout=int(os.getenv("TIM_CONTRATO_TIMEOUT", "30")))
|
||||
|
||||
def profile_full(self, msisdn: str) -> Any:
|
||||
if self.mock:
|
||||
# contract fixture carries representative customer identity in local mode.
|
||||
return self.fixture("contrato")
|
||||
url = os.getenv("TIM_PROFILE_FULL_URL", "").replace("{msisdn}", quote(msisdn))
|
||||
headers = self._headers(
|
||||
client_id=None,
|
||||
auth=os.getenv("TIM_PROFILE_FULL_AUTH", ""),
|
||||
extra={"ClientID": os.getenv("TIM_PROFILE_FULL_CLIENT_ID", "AIAGENTCR")},
|
||||
)
|
||||
return self.request("GET", url, headers=headers)
|
||||
|
||||
def abrir_protocolo(self, payload: dict[str, Any]) -> Any:
|
||||
"""Registra protocolo V2 preservando o contrato externo do Contas original.
|
||||
|
||||
As workflow actions trabalham com um payload lógico/achatado. O adapter é
|
||||
responsável por converter esse modelo para o contrato PMid/Siebel, sem
|
||||
contaminar o domínio ou o WorkflowRuntime com detalhes HTTP.
|
||||
"""
|
||||
if self.mock:
|
||||
return self.fixture("protocol")
|
||||
data = dict(payload or {})
|
||||
msisdn = str(data.get("msisdn") or data.get("assetId") or "")
|
||||
service_status = str(data.get("requestStatus") or data.get("status") or "")
|
||||
message_id = str(
|
||||
data.get("messageId")
|
||||
or data.get("interactionCallId")
|
||||
or data.get("ura_call_id")
|
||||
or data.get("session_id")
|
||||
or uuid.uuid4()
|
||||
)
|
||||
body = {
|
||||
"socialSecNo": str(data.get("socialSecNo") or data.get("social_sec_no") or ""),
|
||||
"channel": "AIAGENTCR",
|
||||
"customerId": str(data.get("customerId") or data.get("customer_id") or ""),
|
||||
"assetId": str(data.get("assetId") or data.get("asset_id") or msisdn),
|
||||
"customerName": str(data.get("customerName") or data.get("customer_name") or ""),
|
||||
"customerEmail": str(data.get("customerEmail") or data.get("customer_email") or ""),
|
||||
"customerPhone1": str(data.get("customerPhone1") or data.get("customer_phone1") or ""),
|
||||
"accessType": str(data.get("accessType") or data.get("access_type") or ""),
|
||||
"serviceRequest": {
|
||||
"userId": str(data.get("serviceRequestUserId") or data.get("service_request_user_id") or ""),
|
||||
"reason1": str(data.get("reason1") or ""),
|
||||
"reason2": str(data.get("reason2") or ""),
|
||||
"reason3": str(data.get("reason3") or ""),
|
||||
"status": service_status,
|
||||
"notes": str(data.get("serviceRequestNotes") or data.get("service_request_notes") or data.get("notes") or ""),
|
||||
"type": str(data.get("type") or "CLIENTE"),
|
||||
},
|
||||
"interaction": {
|
||||
"protocol": str(data.get("interactionProtocol") or data.get("interaction_protocol") or ""),
|
||||
"flagSms": True,
|
||||
"source": "AIAGENTCR",
|
||||
"callId": str(data.get("interactionCallId") or data.get("interaction_call_id") or ""),
|
||||
"crmSource": "Siebel Pós",
|
||||
"reasonId": str(data.get("interactionReasonId") or data.get("interaction_reason_id") or ""),
|
||||
"amount": str(data.get("interactionAmount") or data.get("interaction_amount") or ""),
|
||||
"directionContact": str(data.get("directionContact") or data.get("direction_contact") or "FROM-CLIENT"),
|
||||
"requestFlag": False,
|
||||
"requestSla": str(data.get("interactionRequestSla") or data.get("interaction_request_sla") or ""),
|
||||
"status": str(data.get("status") or service_status),
|
||||
},
|
||||
}
|
||||
headers = self._headers(
|
||||
client_id=None,
|
||||
auth=str(data.get("Authorization") or os.getenv("TIM_PROTOCOL_AUTH", "")),
|
||||
extra={
|
||||
"clientId": str(data.get("clientId") or data.get("client_id") or os.getenv("TIM_PROTOCOL_CLIENT_ID", "BFFDIGITAL")),
|
||||
"messageId": message_id,
|
||||
"Authorizationoam": str(data.get("authorization_oam") or os.getenv("TIM_PROTOCOL_AUTHORIZATION_OAM", "")),
|
||||
"Cn_field": str(data.get("cn_field") or os.getenv("TIM_PROTOCOL_CN_FIELD", "")),
|
||||
"Type_field": str(data.get("type_field") or os.getenv("TIM_PROTOCOL_TYPE_FIELD", "")),
|
||||
},
|
||||
)
|
||||
attempts: list[dict[str, Any]] = []
|
||||
result = self.request(
|
||||
"POST",
|
||||
os.getenv("TIM_PROTOCOL_URL", ""),
|
||||
payload=body,
|
||||
headers=headers,
|
||||
timeout=int(os.getenv("TIM_PROTOCOL_TIMEOUT", "30")),
|
||||
attempt_log=attempts,
|
||||
)
|
||||
operation = str(data.get("rct_operation") or "")
|
||||
return self._attach_transport(result, operation=operation, attempts=attempts) if operation else result
|
||||
|
||||
def contestar(self, payload: dict[str, Any]) -> Any:
|
||||
if self.mock:
|
||||
return self.fixture("contestacao_tool")
|
||||
data = dict(payload)
|
||||
data.setdefault("userId", "AIAGENTCR")
|
||||
data.setdefault("customerIdCurrent", data.get("customerId") or "")
|
||||
data.setdefault("customerType", "2")
|
||||
data.setdefault("customerStatus", "1")
|
||||
status_map = {"ABERTA": "0", "ABERTO": "0", "OPEN": "0", "FECHADA": "1", "FECHADO": "1", "CLOSED": "1"}
|
||||
raw_status = str(data.get("invoiceStatus") or "").strip()
|
||||
data["invoiceStatus"] = status_map.get(raw_status.upper(), raw_status or "0")
|
||||
data.setdefault("invoiceAmountOpen", "0")
|
||||
data.setdefault("invoiceAmount", "0")
|
||||
due = "".join(ch for ch in str(data.get("invoiceDueDate") or "") if ch.isdigit())
|
||||
if len(due) == 8 and str(data.get("invoiceDueDate") or "").startswith(tuple(str(y) for y in range(19, 22))):
|
||||
# YYYYMMDD already normalized
|
||||
pass
|
||||
elif len(due) == 8:
|
||||
# common ddMMyyyy -> yyyyMMdd
|
||||
due = due[4:]+due[2:4]+due[:2]
|
||||
data["invoiceDueDate"] = due
|
||||
data.setdefault("contestationType", "0")
|
||||
data.setdefault("adjustReason", "SERVICO_NAO_SOLICITADO")
|
||||
data.setdefault("observation", data.pop("description", ""))
|
||||
data.setdefault("refundOption", "0")
|
||||
data.setdefault("doubleRefund", False)
|
||||
data.setdefault("manualContaCertaIndicator", False)
|
||||
client_id = str(data.pop("clientId", "") or os.getenv("TIM_CUSTOMER_CONTESTATION_CLIENT_ID", "AIAGENTCR"))
|
||||
message_id = str(data.pop("messageId", "") or uuid.uuid4())
|
||||
user_id = str(data.get("userId") or "AIAGENTCR").upper()
|
||||
headers = self._headers(
|
||||
client_id=None,
|
||||
auth=os.getenv("TIM_CUSTOMER_CONTESTATION_AUTH", ""),
|
||||
extra={"clientId": client_id, "messageId": message_id, "X-Agent-Id": user_id},
|
||||
)
|
||||
attempts: list[dict[str, Any]] = []
|
||||
result = self.request("POST", os.getenv("TIM_CUSTOMER_CONTESTATION_URL", ""), payload=data, headers=headers, timeout=int(os.getenv("TIM_CUSTOMER_CONTESTATION_TIMEOUT", "30")), attempt_log=attempts)
|
||||
return self._attach_transport(result, operation="contestacao", attempts=attempts)
|
||||
|
||||
def status_sr(self, payload: dict[str, Any]) -> Any:
|
||||
if self.mock:
|
||||
return self.fixture("service_request_status")
|
||||
data = dict(payload)
|
||||
service_request = data.get("serviceRequest") if isinstance(data.get("serviceRequest"), dict) else {
|
||||
"status": data.get("status") or "",
|
||||
"notes": data.get("notes") or "",
|
||||
"protocolNumber": data.get("protocolNumber") or data.get("protocol") or "",
|
||||
"auditResult": data.get("auditResult") or "",
|
||||
"auditReasonResult": data.get("auditReasonResult") or "",
|
||||
"auditConvenienceTime": data.get("auditConvenienceTime") or "",
|
||||
"auditSubStatus": data.get("auditSubStatus") or "",
|
||||
}
|
||||
for key in ("reason1", "reason2", "reason3", "auditChecks", "attachments"):
|
||||
if data.get(key): service_request[key] = data[key]
|
||||
body = {"channel": data.get("channel") or "AIAGENTCR", "serviceRequest": service_request}
|
||||
for key in ("msisdn", "customers", "date"):
|
||||
if data.get(key): body[key] = data[key]
|
||||
message_id = str(data.get("messageId") or data.get("ura_call_id") or data.get("session_id") or uuid.uuid4())
|
||||
headers = self._headers(
|
||||
client_id=None,
|
||||
auth=os.getenv("TIM_SERVICE_REQUEST_STATUS_AUTH", ""),
|
||||
extra={
|
||||
"clientId": str(data.get("clientId") or os.getenv("TIM_SERVICE_REQUEST_STATUS_CLIENT_ID", "AIAGENTCR")),
|
||||
"messageId": message_id,
|
||||
"Authorizationoam": data.get("authorization_oam") or os.getenv("TIM_SERVICE_REQUEST_STATUS_AUTHORIZATION_OAM", ""),
|
||||
"Cn_field": data.get("cn_field") or os.getenv("TIM_SERVICE_REQUEST_STATUS_CN_FIELD", ""),
|
||||
"Type_field": data.get("type_field") or os.getenv("TIM_SERVICE_REQUEST_STATUS_TYPE_FIELD", ""),
|
||||
},
|
||||
)
|
||||
attempts: list[dict[str, Any]] = []
|
||||
result = self.request(
|
||||
"POST", os.getenv("TIM_SERVICE_REQUEST_STATUS_URL", ""), payload=body, headers=headers,
|
||||
timeout=int(os.getenv("TIM_SERVICE_REQUEST_STATUS_TIMEOUT", "30")), attempt_log=attempts
|
||||
)
|
||||
return self._attach_transport(result, operation="", attempts=attempts)
|
||||
|
||||
def tracking(self, payload: dict[str, Any]) -> Any:
|
||||
if self.mock:
|
||||
return self.fixture("tracking_activities")
|
||||
data = dict(payload)
|
||||
invoice = data.get("invoice") if isinstance(data.get("invoice"), dict) else {
|
||||
"emissionDate": data.get("invoiceEmissionDate") or "",
|
||||
"expirationDate": data.get("invoiceExpirationDate") or "",
|
||||
"number": data.get("invoiceNumber") or "",
|
||||
"status": data.get("invoiceStatus") or "",
|
||||
"openAmount": data.get("invoiceOpenAmount") or "",
|
||||
"totalAmount": data.get("invoiceTotalAmount") or "",
|
||||
}
|
||||
body = {
|
||||
"channel": data.get("channel") or os.getenv("TIM_TRACKING_ACTIVITIES_CHANNEL", "AIAGENTCR"),
|
||||
"customer": {"socialSecNo": data.get("socialSecNo") or "", "msisdn": data.get("msisdn") or ""},
|
||||
"protocolNumber": data.get("protocolNumber") or "",
|
||||
"invoice": invoice,
|
||||
"activity": {
|
||||
"type": data.get("activityType") or "",
|
||||
"status": data.get("activityStatus") or "",
|
||||
"id": data.get("activityId") or "",
|
||||
},
|
||||
"user": {"login": data.get("userLogin") or os.getenv("TIM_TRACKING_ACTIVITIES_USER_LOGIN", "SIEBELPOS_INBOUND")},
|
||||
}
|
||||
headers = self._headers(
|
||||
client_id=None,
|
||||
auth=os.getenv("TIM_TRACKING_ACTIVITIES_AUTH", ""),
|
||||
extra={"clientId": data.get("clientId") or os.getenv("TIM_TRACKING_ACTIVITIES_CLIENT_ID", "AIAGENTCR")},
|
||||
)
|
||||
return self.request("POST", os.getenv("TIM_TRACKING_ACTIVITIES_URL", ""), payload=body, headers=headers, timeout=int(os.getenv("TIM_TRACKING_ACTIVITIES_TIMEOUT", "30")))
|
||||
|
||||
def sms(self, msisdn: str, message: str, **context: Any) -> Any:
|
||||
if self.mock:
|
||||
return self.fixture("sms")
|
||||
payload: dict[str, Any] = {
|
||||
"msisdn": msisdn,
|
||||
"senderAddress": context.get("sender_address") or os.getenv("TIM_SMS_SENDER_ADDRESS", "324"),
|
||||
"senderName": context.get("sender_name") or os.getenv("TIM_SMS_SENDER_NAME", "TIM Brasil"),
|
||||
"message": message,
|
||||
"longURL": context.get("long_url") or message,
|
||||
}
|
||||
if context.get("notify_url"):
|
||||
payload["receiptRequest"] = {"notifyURL": context["notify_url"]}
|
||||
headers = self._headers(
|
||||
client_id=None,
|
||||
auth=os.getenv("TIM_SMS_AUTH", ""),
|
||||
extra={"clientId": os.getenv("TIM_SMS_CLIENT_ID", "AIAGENTCR")},
|
||||
)
|
||||
attempts: list[dict[str, Any]] = []
|
||||
result = self.request("POST", os.getenv("TIM_SMS_URL", ""), payload=payload, headers=headers, timeout=int(os.getenv("TIM_SMS_TIMEOUT", "30")), attempt_log=attempts)
|
||||
return self._attach_transport(result, operation="sgr_codbar", attempts=attempts)
|
||||
|
||||
def profile_bill(self, msisdn: str) -> Any:
|
||||
"""Perfil de faturamento usando o mesmo contrato CompleteInvoices do original."""
|
||||
if self.mock:
|
||||
return self.fixture("profile_bill")
|
||||
url = os.getenv("TIM_URL_PERFIL_FATURA", "") or os.getenv("TIM_COMPLETE_INVOICES_URL", "")
|
||||
return self.request(
|
||||
"POST",
|
||||
url,
|
||||
payload={"msisdn": msisdn},
|
||||
headers=self._headers(
|
||||
client_id=None,
|
||||
auth=os.getenv("TIM_PROFILE_BILL_AUTH", "") or os.getenv("TIM_COMPLETE_INVOICES_AUTH", ""),
|
||||
extra={"ClientID": os.getenv("TIM_PROFILE_BILL_CLIENT_ID", "AIAGENTCR")},
|
||||
),
|
||||
timeout=int(os.getenv("TIM_PROFILE_BILL_TIMEOUT", "30")),
|
||||
)
|
||||
|
||||
def line_info(self, msisdn: str) -> Any:
|
||||
"""Consulta dados cadastrais da linha/dependente e preserva o CPF correto."""
|
||||
if self.mock:
|
||||
payload = self.fixture("contrato")
|
||||
else:
|
||||
url = os.getenv("TIM_PROFILE_FULL_URL", "").replace("{msisdn}", quote(msisdn))
|
||||
payload = self.request(
|
||||
"GET",
|
||||
url,
|
||||
headers=self._headers(
|
||||
client_id=None,
|
||||
auth=os.getenv("TIM_PROFILE_FULL_AUTH", ""),
|
||||
extra={"ClientID": os.getenv("TIM_PROFILE_FULL_CLIENT_ID", "AIAGENTCR")},
|
||||
),
|
||||
)
|
||||
def extract(value: Any) -> str:
|
||||
if not isinstance(value, dict):
|
||||
return ""
|
||||
direct = value.get("socialSecNo")
|
||||
if direct not in (None, ""):
|
||||
return str(direct).strip()
|
||||
for key in ("customer", "contract", "billingProfile", "subscriber"):
|
||||
found = extract(value.get(key))
|
||||
if found:
|
||||
return found
|
||||
return ""
|
||||
return {"social_sec_no": extract(payload), "raw": payload if isinstance(payload, dict) else {}}
|
||||
|
||||
def bill_pdf(
|
||||
self,
|
||||
msisdn: str,
|
||||
invoice_id: str,
|
||||
customer_id: str,
|
||||
*,
|
||||
include_danfe: bool = False,
|
||||
output: str = "",
|
||||
) -> Any:
|
||||
"""Recupera a fatura detalhada via POST e normaliza seu PDF.
|
||||
|
||||
Esta operação é distinta de ``secure_pdf`` e corresponde ao BillPdfCommand
|
||||
original, usado por pró-rata, matching e explicação detalhada de fatura.
|
||||
"""
|
||||
if self.mock:
|
||||
parsed = self.fixture("invoice_pdf_include_danfe_true" if include_danfe else "invoice_pdf_include_danfe_false")
|
||||
return {
|
||||
"status": "SUCCESS",
|
||||
"message": "Fatura recuperada com sucesso",
|
||||
"file_content": None,
|
||||
"file_name": output or "antiga.pdf",
|
||||
"parsed_content": parsed,
|
||||
}
|
||||
url = os.getenv("TIM_BILL_PDF_URL", "") or os.getenv("TIM_SECURE_PDF_URL", "") or os.getenv("TIM_URL_INVOICE_RECOVER", "")
|
||||
payload = {
|
||||
"invoiceId": encrypt_secure_pdf_value(invoice_id),
|
||||
"customerId": encrypt_secure_pdf_value(customer_id),
|
||||
"invoiceType": "DETALHADA",
|
||||
}
|
||||
headers = self._headers(
|
||||
client_id=None,
|
||||
auth=os.getenv("TIM_BILL_PDF_AUTH", "") or os.getenv("TIM_SECURE_PDF_AUTH", ""),
|
||||
extra={
|
||||
"clientId": os.getenv("TIM_BILL_PDF_CLIENT_ID", "AIAGENTCR"),
|
||||
"Accept": "application/pdf",
|
||||
},
|
||||
)
|
||||
result = self.request(
|
||||
"POST",
|
||||
url,
|
||||
payload=payload,
|
||||
headers=headers,
|
||||
timeout=int(os.getenv("TIM_BILL_PDF_TIMEOUT", os.getenv("TIM_INVOICE_RECOVER_TIMEOUT", "30"))),
|
||||
)
|
||||
raw_content = result.get("raw_content") if isinstance(result, dict) else None
|
||||
parsed = result.get("parsed_content") if isinstance(result, dict) else None
|
||||
if raw_content and parsed is None:
|
||||
parsed = parse_tim_bill_pdf(raw_content, include_danfe=include_danfe)
|
||||
return {
|
||||
"status": "SUCCESS" if raw_content or parsed is not None else str((result or {}).get("status") if isinstance(result, dict) else ""),
|
||||
"message": "Fatura recuperada com sucesso" if raw_content or parsed is not None else "Resposta de fatura sem conteúdo",
|
||||
"file_content": raw_content,
|
||||
"file_name": output or "antiga.pdf",
|
||||
"parsed_content": parsed,
|
||||
"raw": result,
|
||||
}
|
||||
|
||||
def secure_pdf(self, msisdn: str, invoice_id: str, customer_id: str = "") -> Any:
|
||||
if self.mock:
|
||||
return self.fixture("invoice_pdf_include_danfe_false")
|
||||
url = os.getenv("TIM_URL_INVOICE_RECOVER", "")
|
||||
params = {
|
||||
"invoiceId": encrypt_secure_pdf_value(invoice_id),
|
||||
"msisdn": encrypt_secure_pdf_value(msisdn),
|
||||
"customerId": encrypt_secure_pdf_value(customer_id),
|
||||
}
|
||||
return self.request(
|
||||
"GET",
|
||||
url,
|
||||
params=params,
|
||||
headers=self._headers(
|
||||
client_id=None,
|
||||
auth=os.getenv("TIM_SECURE_PDF_AUTH", ""),
|
||||
extra={"clientId": os.getenv("TIM_INVOICE_RECOVER_CLIENT_ID", "AIAGENTCR")},
|
||||
),
|
||||
timeout=int(os.getenv("TIM_INVOICE_RECOVER_TIMEOUT", "30")),
|
||||
)
|
||||
|
||||
201
app/domain/contas/contestation_rules.py
Normal file
201
app/domain/contas/contestation_rules.py
Normal file
@@ -0,0 +1,201 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from calendar import monthrange
|
||||
from datetime import date, datetime
|
||||
import re
|
||||
from typing import Any
|
||||
|
||||
OPEN_STATUSES = {
|
||||
"em aberto", "a vencer", "em atraso", "atrasada", "atrasado", "vencida", "vencido",
|
||||
"aberto", "aberto deb aut", "aberto cc", "aberto pix", "open", "unpaid",
|
||||
}
|
||||
PAID_STATUSES = {"pago", "paga", "paid", "quitada", "quitado"}
|
||||
CONTESTED_STATUSES = {"contestada", "contestado", "em contestacao", "em contestação", "disputed"}
|
||||
PAYMENT_TYPE_LABELS = {1: "debito_automatico", 2: "fatura", 3: "cartao_credito", 4: "incobraveis"}
|
||||
PAYMENT_TYPES_REQUIRE_SMS = {1, 3}
|
||||
|
||||
|
||||
def _norm(value: Any) -> str:
|
||||
return " ".join(str(value or "").strip().casefold().replace("_", " ").split())
|
||||
|
||||
|
||||
def _invoice_id(item: dict[str, Any]) -> str:
|
||||
return str(item.get("invoiceId") or item.get("invoice_id") or item.get("invoiceNumber") or item.get("number") or "").strip()
|
||||
|
||||
|
||||
def payment_items(payload: Any) -> list[dict[str, Any]]:
|
||||
if not isinstance(payload, dict):
|
||||
return []
|
||||
rows = payload.get("paymentItems") or payload.get("payment_items") or payload.get("invoices") or []
|
||||
return [dict(x) for x in rows if isinstance(x, dict)] if isinstance(rows, (list, tuple)) else []
|
||||
|
||||
|
||||
def select_invoice(payload: Any, invoice_id: Any = "") -> dict[str, Any]:
|
||||
rows = payment_items(payload)
|
||||
wanted = str(invoice_id or "").strip()
|
||||
if wanted:
|
||||
for row in rows:
|
||||
if _invoice_id(row) == wanted:
|
||||
return row
|
||||
return {}
|
||||
return rows[0] if rows else {}
|
||||
|
||||
|
||||
def invoice_status(item: dict[str, Any]) -> str:
|
||||
return str(item.get("invoiceStatus") or item.get("invoice_status") or item.get("status") or "").strip()
|
||||
|
||||
|
||||
def is_unpaid_status(value: Any) -> bool:
|
||||
text = _norm(value)
|
||||
return text in OPEN_STATUSES or any(k in text for k in ("aberto", "atras", "vencid", "unpaid"))
|
||||
|
||||
|
||||
def is_paid_status(value: Any) -> bool:
|
||||
text = _norm(value)
|
||||
return text in PAID_STATUSES or text.startswith("pag")
|
||||
|
||||
|
||||
def is_contested_status(value: Any) -> bool:
|
||||
text = _norm(value)
|
||||
return text in CONTESTED_STATUSES or "contest" in text
|
||||
|
||||
|
||||
def complete_invoices_context(payload: Any, invoice_id: Any = "") -> dict[str, Any]:
|
||||
source = payload if isinstance(payload, dict) else {}
|
||||
billing = source.get("billingProfile") or source.get("billing_profile") or {}
|
||||
billing = billing if isinstance(billing, dict) else {}
|
||||
raw_pt = billing.get("paymentTypeId") if billing.get("paymentTypeId") is not None else billing.get("payment_type_id")
|
||||
try:
|
||||
payment_type_id = int(raw_pt) if raw_pt is not None else None
|
||||
except (TypeError, ValueError):
|
||||
payment_type_id = None
|
||||
method = str(
|
||||
source.get("payment_method") or source.get("method_payment") or source.get("forma_pagamento")
|
||||
or billing.get("paymentMethod") or billing.get("methodPayment") or PAYMENT_TYPE_LABELS.get(payment_type_id, "")
|
||||
).strip().lower()
|
||||
rows = payment_items(source)
|
||||
selected = select_invoice(source, invoice_id)
|
||||
statuses = [invoice_status(x) for x in rows if invoice_status(x)]
|
||||
selected_status = invoice_status(selected)
|
||||
if invoice_id:
|
||||
has_open = bool(selected and is_unpaid_status(selected_status))
|
||||
else:
|
||||
has_open = any(is_unpaid_status(x) for x in statuses)
|
||||
return {
|
||||
"invoice": selected,
|
||||
"invoice_status": selected_status,
|
||||
"invoice_statuses": statuses,
|
||||
"has_open_bill": has_open,
|
||||
"payment_type_id": payment_type_id,
|
||||
"payment_method": method,
|
||||
"method_payment": method,
|
||||
"forma_pagamento": method,
|
||||
"requires_sms": payment_type_id in PAYMENT_TYPES_REQUIRE_SMS,
|
||||
"requer_sms": payment_type_id in PAYMENT_TYPES_REQUIRE_SMS,
|
||||
}
|
||||
|
||||
|
||||
def _is_dacc(method: Any) -> bool:
|
||||
text = _norm(method)
|
||||
return any(k in text for k in ("dacc", "debito automatico", "débito automático", "cartao", "cartão", "parcelamento"))
|
||||
|
||||
|
||||
def resolve_refund_option(*, payment_method: str, has_open_bill: bool, invoice_statuses: list[str]) -> dict[str, Any]:
|
||||
is_dacc = _is_dacc(payment_method)
|
||||
is_contested = any(is_contested_status(x) for x in invoice_statuses)
|
||||
is_paid = any(is_paid_status(x) for x in invoice_statuses) and not has_open_bill
|
||||
is_unpaid = bool(has_open_bill) or any(is_unpaid_status(x) for x in invoice_statuses)
|
||||
boleto = bool(is_unpaid and not is_dacc and not is_contested)
|
||||
reason = "credito_conta_futura"
|
||||
if boleto:
|
||||
reason = "fatura_nao_paga"
|
||||
elif is_dacc:
|
||||
reason = "forma_pagamento_dacc_cartao_parcelamento"
|
||||
elif is_contested:
|
||||
reason = "fatura_ja_contestada"
|
||||
elif is_paid:
|
||||
reason = "fatura_paga"
|
||||
return {
|
||||
"refund_option": "1" if boleto else "0",
|
||||
"format_text": "sms" if boleto else "conta_futura",
|
||||
"resolution_type": "new_boleto" if boleto else "credit_bill",
|
||||
"decision_reason": reason,
|
||||
"is_dacc": is_dacc,
|
||||
"is_paid": is_paid,
|
||||
"is_unpaid": is_unpaid,
|
||||
"is_contested": is_contested,
|
||||
}
|
||||
|
||||
|
||||
def parse_date(value: Any) -> datetime | None:
|
||||
text = str(value or "").strip()
|
||||
if not text:
|
||||
return None
|
||||
for fmt in ("%Y-%m-%d", "%Y%m%d", "%d/%m/%Y", "%d-%m-%Y", "%Y-%m-%dT%H:%M:%S", "%Y-%m-%dT%H:%M:%SZ"):
|
||||
try:
|
||||
return datetime.strptime(text[:20] if "T" in fmt else text, fmt)
|
||||
except ValueError:
|
||||
continue
|
||||
digits = re.sub(r"\D", "", text)
|
||||
if len(digits) >= 8:
|
||||
for fmt in ("%Y%m%d", "%d%m%Y"):
|
||||
try: return datetime.strptime(digits[:8], fmt)
|
||||
except ValueError: pass
|
||||
return None
|
||||
|
||||
|
||||
def due_day(value: Any) -> int:
|
||||
parsed = parse_date(value)
|
||||
if parsed:
|
||||
return parsed.day
|
||||
m = re.search(r"(?:^|[-/])(\d{1,2})$", str(value or "").strip())
|
||||
return int(m.group(1)) if m else 0
|
||||
|
||||
|
||||
def billing_cutoff_reference(cutoff_day: int, *, reference_datetime: datetime | None = None) -> tuple[bool, datetime | None]:
|
||||
if cutoff_day <= 0:
|
||||
return False, None
|
||||
ref = reference_datetime or datetime.now()
|
||||
day = min(cutoff_day, monthrange(ref.year, ref.month)[1])
|
||||
cutoff = datetime(ref.year, ref.month, day)
|
||||
if ref < cutoff:
|
||||
month = ref.month - 1
|
||||
year = ref.year
|
||||
if month == 0:
|
||||
month, year = 12, year - 1
|
||||
cutoff = datetime(year, month, min(cutoff_day, monthrange(year, month)[1]))
|
||||
return ref >= cutoff, cutoff
|
||||
|
||||
|
||||
def next_cutoff_from_cut_date(cut_date: datetime, *, reference_datetime: datetime | None = None) -> tuple[bool, datetime]:
|
||||
ref = reference_datetime or datetime.now()
|
||||
month = cut_date.month + 1
|
||||
year = cut_date.year
|
||||
if month == 13:
|
||||
month, year = 1, year + 1
|
||||
nxt = datetime(year, month, min(cut_date.day, monthrange(year, month)[1]))
|
||||
return ref > nxt, nxt
|
||||
|
||||
|
||||
def manual_conta_certa_from_evidence(*, dependent_invoice_item: bool = False, invoice_item: dict[str, Any] | None = None, contract: Any = None, reference_datetime: datetime | None = None) -> bool:
|
||||
if dependent_invoice_item:
|
||||
return True
|
||||
row = invoice_item or {}
|
||||
for key in ("cutDate", "cutoffDate", "cut_date", "cutoff_date"):
|
||||
if row.get(key):
|
||||
parsed = parse_date(row.get(key))
|
||||
if parsed:
|
||||
return next_cutoff_from_cut_date(parsed, reference_datetime=reference_datetime)[0]
|
||||
try:
|
||||
return billing_cutoff_reference(int(row.get(key)), reference_datetime=reference_datetime)[0]
|
||||
except (TypeError, ValueError):
|
||||
pass
|
||||
payload = contract if isinstance(contract, dict) else {}
|
||||
billing = payload.get("billing_profile") or payload.get("billingProfile") or {}
|
||||
billing = billing if isinstance(billing, dict) else {}
|
||||
date_info = billing.get("date") if isinstance(billing.get("date"), dict) else {}
|
||||
try:
|
||||
cutoff_day = int(date_info.get("cutoffDay") or 0)
|
||||
except (TypeError, ValueError):
|
||||
cutoff_day = 0
|
||||
return billing_cutoff_reference(cutoff_day, reference_datetime=reference_datetime)[0] if cutoff_day else False
|
||||
5
app/domain/contas/fixtures/block_vas.json
Normal file
5
app/domain/contas/fixtures/block_vas.json
Normal file
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"status": 200,
|
||||
"message": "OK",
|
||||
"operation": "block"
|
||||
}
|
||||
5
app/domain/contas/fixtures/cancel_vas.json
Normal file
5
app/domain/contas/fixtures/cancel_vas.json
Normal file
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"status": 200,
|
||||
"message": "OK",
|
||||
"operation": "cancel"
|
||||
}
|
||||
25
app/domain/contas/fixtures/complete_invoices.json
Normal file
25
app/domain/contas/fixtures/complete_invoices.json
Normal file
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"billingProfile": {
|
||||
"paymentTypeId": 2,
|
||||
"customer": {
|
||||
"id": "MOCK-CUSTOMER-0001",
|
||||
"customerId": "MOCK-CUSTOMER-0001",
|
||||
"document": "12345678909",
|
||||
"name": "Cliente Mock"
|
||||
}
|
||||
},
|
||||
"paymentItems": [
|
||||
{
|
||||
"invoiceId": "3000131180",
|
||||
"invoiceStatus": "fechada",
|
||||
"status": "fechada",
|
||||
"barCode": "34191790010104351004791020150008291070026000",
|
||||
"dueDate": "2026-04-15",
|
||||
"customerId": "MOCK-CUSTOMER-0001",
|
||||
"customer": {
|
||||
"id": "MOCK-CUSTOMER-0001",
|
||||
"customerId": "MOCK-CUSTOMER-0001"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
128
app/domain/contas/fixtures/contestacao_tool.json
Normal file
128
app/domain/contas/fixtures/contestacao_tool.json
Normal file
@@ -0,0 +1,128 @@
|
||||
{
|
||||
"success": true,
|
||||
"protocolo_id": "PRT-TESTE-0001",
|
||||
"protocol_number": "PRT-TESTE-0001",
|
||||
"contestacao_protocol": "PRT-TESTE-0001",
|
||||
"protocol_closed": true,
|
||||
"codigo_boleto": "34191.79001 01043.510047 91020.150008 3 12340000001499",
|
||||
"barcode": "34191.79001 01043.510047 91020.150008 3 12340000001499",
|
||||
"contestation_id": "20259",
|
||||
"forma_pagamento": "debito_conta",
|
||||
"tipo_fatura": "debito_conta",
|
||||
"payment_method": "debito_conta",
|
||||
"sms_enviado": false,
|
||||
"sms_sent": false,
|
||||
"format_text": "sms",
|
||||
"resolution_type": "new_boleto",
|
||||
"data_credito_proxima_fatura": "05/04/2026",
|
||||
"items": [
|
||||
{
|
||||
"item_name": "Tamboro Mensal",
|
||||
"item_type": "VAS_AVULSO",
|
||||
"claimed_amount": "14.99",
|
||||
"validated_amount": "14.99"
|
||||
},
|
||||
{
|
||||
"item_name": "Tim Fashion",
|
||||
"item_type": "VAS_AVULSO",
|
||||
"claimed_amount": "10.00",
|
||||
"validated_amount": "10.00"
|
||||
},
|
||||
{
|
||||
"item_name": "Neymar Jr",
|
||||
"item_type": "VAS_AVULSO",
|
||||
"claimed_amount": "12.00",
|
||||
"validated_amount": "12.00"
|
||||
}
|
||||
],
|
||||
"body": {
|
||||
"barcode": "34191.79001 01043.510047 91020.150008 3 12340000001499",
|
||||
"contestationId": "20259",
|
||||
"itemsResponse": [
|
||||
{
|
||||
"correctAccountStatus": "CRIAR",
|
||||
"itemName": "Tamboro Mensal",
|
||||
"message": "Contestação criada/atualizada com sucesso",
|
||||
"status": "INICIADA"
|
||||
},
|
||||
{
|
||||
"correctAccountStatus": "CRIAR",
|
||||
"itemName": "Tim Fashion",
|
||||
"message": "Contestação criada/atualizada com sucesso",
|
||||
"status": "INICIADA"
|
||||
},
|
||||
{
|
||||
"correctAccountStatus": "CRIAR",
|
||||
"itemName": "Neymar Jr",
|
||||
"message": "Contestação criada/atualizada com sucesso",
|
||||
"status": "INICIADA"
|
||||
}
|
||||
],
|
||||
"sr": "PRT-TESTE-0001"
|
||||
},
|
||||
"items_response": [
|
||||
{
|
||||
"correctAccountStatus": "CRIAR",
|
||||
"itemName": "Tamboro Mensal",
|
||||
"message": "Item não existe na fatura com o valor informado",
|
||||
"status": "INICIADA"
|
||||
},
|
||||
{
|
||||
"correctAccountStatus": "CRIAR",
|
||||
"itemName": "Tim Fashion",
|
||||
"message": "Contestação criada/atualizada com sucesso",
|
||||
"status": "INICIADA"
|
||||
},
|
||||
{
|
||||
"correctAccountStatus": "CRIAR",
|
||||
"itemName": "Neymar Jr",
|
||||
"message": "Contestação criada/atualizada com sucesso",
|
||||
"status": "INICIADA"
|
||||
}
|
||||
],
|
||||
"contested_items": [
|
||||
{
|
||||
"correctAccountStatus": "CRIAR",
|
||||
"itemName": "Tim Fashion",
|
||||
"message": "Contestação criada/atualizada com sucesso",
|
||||
"status": "INICIADA"
|
||||
},
|
||||
{
|
||||
"correctAccountStatus": "CRIAR",
|
||||
"itemName": "Neymar Jr",
|
||||
"message": "Contestação criada/atualizada com sucesso",
|
||||
"status": "INICIADA"
|
||||
}
|
||||
],
|
||||
"not_contested_items": [
|
||||
],
|
||||
"validated_by_actions": true,
|
||||
"validated_items": [
|
||||
{
|
||||
"item_name": "Tamboro Mensal",
|
||||
"item_type": "VAS_AVULSO",
|
||||
"claimed_amount": "14.99",
|
||||
"validated_amount": "14.99"
|
||||
},
|
||||
{
|
||||
"item_name": "Tim Fashion",
|
||||
"item_type": "VAS_AVULSO",
|
||||
"claimed_amount": "10.00",
|
||||
"validated_amount": "10.00"
|
||||
},
|
||||
{
|
||||
"item_name": "Neymar Jr",
|
||||
"item_type": "VAS_AVULSO",
|
||||
"claimed_amount": "12.00",
|
||||
"validated_amount": "12.00"
|
||||
}
|
||||
],
|
||||
"validation_summary": {
|
||||
"requested_items_count": 3,
|
||||
"items_response_count": 3,
|
||||
"contested_items_count": 3,
|
||||
"not_contested_items_count": 0
|
||||
},
|
||||
"contested_invoice_amount_open": "36.99",
|
||||
"contested_invoice_amount": "36.99"
|
||||
}
|
||||
16
app/domain/contas/fixtures/contrato.json
Normal file
16
app/domain/contas/fixtures/contrato.json
Normal file
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"contract": {
|
||||
"status": "ACTIVE",
|
||||
"planName": "TIM Black C Light Disney 7 0"
|
||||
},
|
||||
"billingProfile": {
|
||||
"paymentTypeId": 2,
|
||||
"paymentType": "fatura",
|
||||
"customer": {
|
||||
"id": "MOCK-CUSTOMER-0001",
|
||||
"customerId": "MOCK-CUSTOMER-0001",
|
||||
"document": "12345678909",
|
||||
"name": "Cliente Mock"
|
||||
}
|
||||
}
|
||||
}
|
||||
207
app/domain/contas/fixtures/divergencia.json
Normal file
207
app/domain/contas/fixtures/divergencia.json
Normal file
@@ -0,0 +1,207 @@
|
||||
{
|
||||
"invoiceExplanation": "Analisando a sua fatura atual em relação a passada, o valor variou em R$ 46.99.\n\nHouve a cobrança dos seguintes serviços de valor adicionado:\n* Cobrança Tamboro Mensal no valor de R$ 14.99 no dia 01/11/25.\n* Cobrança TIM Fashion Mensal no valor de R$ 10.00 no dia 01/11/25.\n* Cobrança Neymar Jr no valor de R$ 12.00 no dia 01/11/25.\n* Cobrança Youtube Premium no valor de R$ 10.00 no dia 02/11/25.",
|
||||
"invoiceVariation": [
|
||||
{
|
||||
"value": "46.99",
|
||||
"type": "servicos_contratados_de_parceiros",
|
||||
"desc": "Serviços de valor adicionado",
|
||||
"nItems": "10",
|
||||
"items": [
|
||||
{
|
||||
"type": "servicos_contratados_de_parceiros",
|
||||
"desc": "Tamboro Mensal",
|
||||
"value": "19.99",
|
||||
"date": "2025-10-02T00:00:00.000Z",
|
||||
"invoice": "2025-10-20T00:00:00.000Z",
|
||||
"status": "Ativo"
|
||||
},
|
||||
{
|
||||
"type": "servicos_contratados_de_parceiros",
|
||||
"desc": "VOD + Canais abertos",
|
||||
"value": "10.0",
|
||||
"date": "2025-10-01T00:00:00.000Z",
|
||||
"invoice": "2025-10-20T00:00:00.000Z",
|
||||
"status": "Ativo"
|
||||
},
|
||||
{
|
||||
"type": "servicos_contratados_de_parceiros",
|
||||
"desc": "VOD + Canais abertos",
|
||||
"value": "10.0",
|
||||
"date": "2025-10-03T00:00:00.000Z",
|
||||
"invoice": "2025-10-20T00:00:00.000Z",
|
||||
"status": "Ativo"
|
||||
},
|
||||
{
|
||||
"type": "servicos_contratados_de_parceiros",
|
||||
"desc": "Tamboro Mensal",
|
||||
"value": "19.99",
|
||||
"date": "2025-11-02T00:00:00.000Z",
|
||||
"invoice": "2025-11-20T00:00:00.000Z",
|
||||
"status": "Ativo"
|
||||
},
|
||||
{
|
||||
"type": "servicos_contratados_de_parceiros",
|
||||
"desc": "VOD + Canais abertos",
|
||||
"value": "10.0",
|
||||
"date": "2025-11-01T00:00:00.000Z",
|
||||
"invoice": "2025-11-20T00:00:00.000Z",
|
||||
"status": "Ativo"
|
||||
},
|
||||
{
|
||||
"type": "servicos_contratados_de_parceiros",
|
||||
"desc": "VOD + Canais abertos",
|
||||
"value": "10.0",
|
||||
"date": "2025-11-03T00:00:00.000Z",
|
||||
"invoice": "2025-11-20T00:00:00.000Z",
|
||||
"status": "Ativo"
|
||||
},
|
||||
{
|
||||
"type": "servicos_contratados_de_parceiros",
|
||||
"desc": "Tamboro Mensal",
|
||||
"value": "14.99",
|
||||
"date": "2025-11-01T00:00:00.000Z",
|
||||
"invoice": "2025-11-20T00:00:00.000Z",
|
||||
"status": "Ativo"
|
||||
},
|
||||
{
|
||||
"type": "servicos_contratados_de_parceiros",
|
||||
"desc": "TIM Fashion Mensal",
|
||||
"value": "10.0",
|
||||
"date": "2025-11-01T00:00:00.000Z",
|
||||
"invoice": "2025-11-20T00:00:00.000Z",
|
||||
"status": "Ativo"
|
||||
},
|
||||
{
|
||||
"type": "servicos_contratados_de_parceiros",
|
||||
"desc": "Neymar Jr",
|
||||
"value": "12.0",
|
||||
"date": "2025-11-01T00:00:00.000Z",
|
||||
"invoice": "2025-11-20T00:00:00.000Z",
|
||||
"status": "Ativo"
|
||||
},
|
||||
{
|
||||
"type": "servicos_contratados_de_parceiros",
|
||||
"desc": "Youtube Premium",
|
||||
"value": "10.0",
|
||||
"date": "2025-11-02T00:00:00.000Z",
|
||||
"invoice": "2025-11-20T00:00:00.000Z",
|
||||
"status": "Ativo"
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"currentInvoice": [
|
||||
{
|
||||
"value": "176.98",
|
||||
"type": "plano",
|
||||
"desc": "Plano",
|
||||
"nItems": "2",
|
||||
"items": [
|
||||
{
|
||||
"type": "plano",
|
||||
"desc": "TIM Black A 8.0",
|
||||
"value": "104.99",
|
||||
"days": "31",
|
||||
"contestable": false
|
||||
},
|
||||
{
|
||||
"type": "plano",
|
||||
"desc": "TIM CTRL Redes Sociais 8.0",
|
||||
"value": "71.99",
|
||||
"contestable": false
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"value": "76.98",
|
||||
"type": "servicos_contratados_de_parceiros",
|
||||
"desc": "Serviços de valor adicionado",
|
||||
"nItems": "6",
|
||||
"items": [
|
||||
{
|
||||
"type": "servicos_contratados_de_parceiros",
|
||||
"desc": "Tamboro Mensal",
|
||||
"value": "14.99",
|
||||
"date": "2025-11-01T00:00:00.000Z",
|
||||
"contestable": true
|
||||
},
|
||||
{
|
||||
"type": "servicos_contratados_de_parceiros",
|
||||
"desc": "Tamboro Mensal",
|
||||
"value": "19.99",
|
||||
"date": "2025-11-02T00:00:00.000Z",
|
||||
"contestable": true
|
||||
},
|
||||
{
|
||||
"type": "servicos_contratados_de_parceiros",
|
||||
"desc": "TIM Fashion Mensal",
|
||||
"value": "10.0",
|
||||
"date": "2025-11-01T00:00:00.000Z",
|
||||
"contestable": true
|
||||
},
|
||||
{
|
||||
"type": "servicos_contratados_de_parceiros",
|
||||
"desc": "VOD + Canais abertos",
|
||||
"value": "10.0",
|
||||
"date": "2025-11-01T00:00:00.000Z",
|
||||
"contestable": true
|
||||
},
|
||||
{
|
||||
"type": "servicos_contratados_de_parceiros",
|
||||
"desc": "VOD + Canais abertos",
|
||||
"value": "10.0",
|
||||
"date": "2025-11-03T00:00:00.000Z",
|
||||
"contestable": true
|
||||
},
|
||||
{
|
||||
"type": "servicos_contratados_de_parceiros",
|
||||
"desc": "Neymar Jr",
|
||||
"value": "12.0",
|
||||
"date": "2025-11-01T00:00:00.000Z",
|
||||
"contestable": true
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"value": "20.0",
|
||||
"type": "streaming",
|
||||
"desc": "Streamings",
|
||||
"nItems": "2",
|
||||
"items": [
|
||||
{
|
||||
"type": "streaming",
|
||||
"desc": "Youtube Premium",
|
||||
"value": "10.0",
|
||||
"contestable": false
|
||||
},
|
||||
{
|
||||
"type": "streaming",
|
||||
"desc": "Paramount+",
|
||||
"value": "10.0",
|
||||
"contestable": false
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"value": "0.46",
|
||||
"type": "juros_e_multa_por_atraso_no_pagamento",
|
||||
"desc": "Juros / Multas",
|
||||
"nItems": "2",
|
||||
"items": [
|
||||
{
|
||||
"type": "juros_e_multa_por_atraso_no_pagamento",
|
||||
"desc": "JUROS: (VENC 10/11/25, PAGO EM 11/10/25)",
|
||||
"value": "0.26",
|
||||
"contestable": false
|
||||
},
|
||||
{
|
||||
"type": "juros_e_multa_por_atraso_no_pagamento",
|
||||
"desc": "MULTAS: (VENC 10/11/25, PAGO EM 11/10/25)",
|
||||
"value": "0.2",
|
||||
"contestable": false
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"messageId": "mock-billing-analysis-0001"
|
||||
}
|
||||
407
app/domain/contas/fixtures/invoice_pdf_include_danfe_false.json
Normal file
407
app/domain/contas/fixtures/invoice_pdf_include_danfe_false.json
Normal file
@@ -0,0 +1,407 @@
|
||||
{
|
||||
"Fatura Resumo": [
|
||||
{
|
||||
"desc": "PERÍODO",
|
||||
"period": "14/10 a 13/11"
|
||||
},
|
||||
{
|
||||
"desc": "EMISSÃO",
|
||||
"emissao": "20/11/2025"
|
||||
},
|
||||
{
|
||||
"desc": "Planos Contratados",
|
||||
"value": 176.98
|
||||
},
|
||||
{
|
||||
"desc": "Itens eventuais",
|
||||
"value": 46.99
|
||||
},
|
||||
{
|
||||
"desc": "JUROS",
|
||||
"value": 0.26
|
||||
},
|
||||
{
|
||||
"desc": "Multas",
|
||||
"value": 0.2
|
||||
},
|
||||
{
|
||||
"desc": "Total geral",
|
||||
"value": 207.43
|
||||
}
|
||||
],
|
||||
"1199999999": {
|
||||
"Planos": {
|
||||
"TIM Black A 8.0": {
|
||||
"period": "14/10 a 13/11",
|
||||
"days": 31,
|
||||
"msisdn": "1199999999",
|
||||
"valor_final": 104.99,
|
||||
"descontos": [
|
||||
{
|
||||
"desc": "Desc Fidel 80 TIM Black A compartilhado 8.0 1/12",
|
||||
"value": -80.0,
|
||||
"installment": "1/12"
|
||||
},
|
||||
{
|
||||
"desc": "Desc Esp TIM Black A compartilhado 8.0",
|
||||
"value": -5.0,
|
||||
"installment": null
|
||||
}
|
||||
],
|
||||
"total_descontos": -85.0,
|
||||
"valor_bruto": 189.99
|
||||
},
|
||||
"TIM CTRL Redes Sociais 8.0": {
|
||||
"days": null,
|
||||
"msisdn": "1199999999",
|
||||
"valor_final": 71.99,
|
||||
"is_controle": true,
|
||||
"descontos": [
|
||||
{
|
||||
"desc": "Desc Esp TIM CTRL Redes Sociais 8.0 1",
|
||||
"value": -3.0,
|
||||
"installment": null
|
||||
},
|
||||
{
|
||||
"desc": "Desc Fidel 33 TIM CTRL Redes Sociais 8.0 8/12",
|
||||
"value": -33.0,
|
||||
"installment": "8/12"
|
||||
}
|
||||
],
|
||||
"total_descontos": -36.0,
|
||||
"valor_bruto": 107.99
|
||||
}
|
||||
},
|
||||
"Outros Valores": [
|
||||
{
|
||||
"desc": "MULTAS: (VENC 10/11/25, PAGO EM 11/10/25)",
|
||||
"value": 0.2,
|
||||
"msisdn": "1199999999"
|
||||
},
|
||||
{
|
||||
"desc": "JUROS: (VENC 10/11/25, PAGO EM 11/11/25)",
|
||||
"value": 0.26,
|
||||
"msisdn": "1199999999"
|
||||
}
|
||||
],
|
||||
"SVA Detalhe Total": [
|
||||
{
|
||||
"desc": "Tamboro Mensal",
|
||||
"period": "01/11/25",
|
||||
"value": 14.99,
|
||||
"msisdn": "1199999999",
|
||||
"classe": "avulso",
|
||||
"verb": "cancelar"
|
||||
},
|
||||
{
|
||||
"desc": "Tamboro Mensal",
|
||||
"period": "02/11/25",
|
||||
"value": 19.99,
|
||||
"msisdn": "1199999999",
|
||||
"classe": "avulso",
|
||||
"verb": "cancelar"
|
||||
},
|
||||
{
|
||||
"desc": "TIM Fashion Mensal",
|
||||
"period": "01/11/25",
|
||||
"value": 10.00,
|
||||
"msisdn": "1199999999",
|
||||
"classe": "avulso",
|
||||
"verb": "cancelar"
|
||||
},
|
||||
{
|
||||
"desc": "VOD + Canais abertos",
|
||||
"period": "01/11/25",
|
||||
"value": 10.00,
|
||||
"msisdn": "1199999999",
|
||||
"classe": "avulso",
|
||||
"verb": "cancelar"
|
||||
},
|
||||
{
|
||||
"desc": "VOD + Canais abertos",
|
||||
"period": "03/11/25",
|
||||
"value": 10.00,
|
||||
"msisdn": "1199999999",
|
||||
"classe": "avulso",
|
||||
"verb": "cancelar"
|
||||
},
|
||||
{
|
||||
"desc": "Neymar Jr",
|
||||
"period": "01/11/25",
|
||||
"value": 12.00,
|
||||
"msisdn": "1199999999",
|
||||
"classe": "avulso",
|
||||
"verb": "cancelar"
|
||||
},
|
||||
{
|
||||
"desc": "Youtube Premium",
|
||||
"period": "02/11/25",
|
||||
"value": 10,
|
||||
"msisdn": "1199999999",
|
||||
"estrategico": true,
|
||||
"classe": "estrategico",
|
||||
"verb": "falar sobre"
|
||||
},
|
||||
{
|
||||
"desc": "Paramount+",
|
||||
"period": "02/11/25",
|
||||
"value": 10,
|
||||
"msisdn": "1199999999",
|
||||
"estrategico": true,
|
||||
"classe": "estrategico",
|
||||
"verb": "falar sobre"
|
||||
}
|
||||
],
|
||||
"Mensalidades Adicionais":
|
||||
[
|
||||
{
|
||||
"desc": "Apple Music SVA Mes",
|
||||
"value": 5.0,
|
||||
"msisdn": "1199999999",
|
||||
"estrategico": true,
|
||||
"classe": "estrategico",
|
||||
"verb": "falar sobre"
|
||||
},
|
||||
{
|
||||
"desc": "Apple Music Dados Mes",
|
||||
"value": 5.0,
|
||||
"msisdn": "1199999999",
|
||||
"estrategico": true,
|
||||
"classe": "estrategico",
|
||||
"verb": "falar sobre"
|
||||
},
|
||||
{
|
||||
"desc": "Plugin 5G Plus",
|
||||
"value": 5.0
|
||||
}
|
||||
],
|
||||
"Serviços Bundle Inclusos": [
|
||||
{
|
||||
"desc": "Minutos Locais e DDD com 41",
|
||||
"period": "14/10 a 13/11",
|
||||
"days": 31,
|
||||
"value": "Incluído",
|
||||
"msisdn": "1199999999",
|
||||
"franchise": "Ilimitado",
|
||||
"consumption": "43m30s",
|
||||
"classe": "bundle",
|
||||
"verb": "falar sobre"
|
||||
},
|
||||
{
|
||||
"desc": "Pacote Américas Promocional",
|
||||
"period": "14/10 a 13/11",
|
||||
"days": 31,
|
||||
"value": "Incluído",
|
||||
"msisdn": "1199999999",
|
||||
"classe": "bundle",
|
||||
"verb": "falar sobre"
|
||||
},
|
||||
{
|
||||
"desc": "Tim Music",
|
||||
"period": "14/10 a 13/11",
|
||||
"days": 31,
|
||||
"value": "Incluído",
|
||||
"msisdn": "1199999999",
|
||||
"classe": "bundle",
|
||||
"verb": "falar sobre"
|
||||
},
|
||||
{
|
||||
"desc": "Fluid Premium",
|
||||
"period": "14/10 a 13/11",
|
||||
"days": 31,
|
||||
"value": "Incluído",
|
||||
"msisdn": "1199999999",
|
||||
"classe": "bundle",
|
||||
"verb": "falar sobre"
|
||||
},
|
||||
{
|
||||
"desc": "Fit Me App",
|
||||
"period": "14/10 a 13/11",
|
||||
"days": 31,
|
||||
"value": "Incluído",
|
||||
"msisdn": "1199999999",
|
||||
"classe": "bundle",
|
||||
"verb": "falar sobre"
|
||||
},
|
||||
{
|
||||
"desc": "Busuu 2",
|
||||
"period": "14/10 a 13/11",
|
||||
"days": 31,
|
||||
"value": "Incluído",
|
||||
"msisdn": "1199999999",
|
||||
"classe": "bundle",
|
||||
"verb": "falar sobre"
|
||||
},
|
||||
{
|
||||
"desc": "ITGame Light",
|
||||
"period": "14/10 a 13/11",
|
||||
"days": 31,
|
||||
"value": "Incluído",
|
||||
"msisdn": "1199999999",
|
||||
"classe": "bundle",
|
||||
"verb": "falar sobre"
|
||||
},
|
||||
{
|
||||
"desc": "EXA Segurança Premium",
|
||||
"period": "14/10 a 13/11",
|
||||
"days": 31,
|
||||
"value": "Incluído",
|
||||
"msisdn": "1199999999",
|
||||
"classe": "bundle",
|
||||
"verb": "falar sobre"
|
||||
},
|
||||
{
|
||||
"desc": "EXA Cloud 500GB",
|
||||
"period": "14/10 a 13/11",
|
||||
"days": 31,
|
||||
"value": "Incluído",
|
||||
"msisdn": "1199999999",
|
||||
"classe": "bundle",
|
||||
"verb": "falar sobre"
|
||||
},
|
||||
{
|
||||
"desc": "Aya Audiobooks Premium",
|
||||
"period": "14/10 a 13/11",
|
||||
"days": 31,
|
||||
"value": "Incluído",
|
||||
"msisdn": "1199999999",
|
||||
"classe": "bundle",
|
||||
"verb": "falar sobre"
|
||||
},
|
||||
{
|
||||
"desc": "Bancah Premium + Jornais",
|
||||
"period": "14/10 a 13/11",
|
||||
"days": 31,
|
||||
"value": "Incluído",
|
||||
"msisdn": "1199999999",
|
||||
"classe": "bundle",
|
||||
"verb": "falar sobre"
|
||||
},
|
||||
{
|
||||
"desc": "Aya Ensinah Premium",
|
||||
"period": "14/10 a 13/11",
|
||||
"days": 31,
|
||||
"value": "Incluído",
|
||||
"msisdn": "1199999999",
|
||||
"classe": "bundle",
|
||||
"verb": "falar sobre"
|
||||
},
|
||||
{
|
||||
"desc": "6GB Internet",
|
||||
"days": null,
|
||||
"value": "Incluído",
|
||||
"msisdn": "1199999999",
|
||||
"classe": "bundle",
|
||||
"verb": "falar sobre"
|
||||
},
|
||||
{
|
||||
"desc": "Minutos Locais e DDD com 41 - Ilimitado",
|
||||
"days": null,
|
||||
"value": "Incluído",
|
||||
"msisdn": "1199999999",
|
||||
"classe": "bundle",
|
||||
"verb": "falar sobre"
|
||||
},
|
||||
{
|
||||
"desc": "Fluid Light",
|
||||
"days": null,
|
||||
"value": "Incluído",
|
||||
"msisdn": "1199999999",
|
||||
"classe": "bundle",
|
||||
"verb": "falar sobre"
|
||||
},
|
||||
{
|
||||
"desc": "EXA Segurança + Proteção Stand",
|
||||
"days": null,
|
||||
"value": "Incluído",
|
||||
"msisdn": "1199999999",
|
||||
"classe": "bundle",
|
||||
"verb": "falar sobre"
|
||||
},
|
||||
{
|
||||
"desc": "Aya Ensinah Premium",
|
||||
"days": null,
|
||||
"value": "Incluído",
|
||||
"msisdn": "1199999999",
|
||||
"classe": "bundle",
|
||||
"verb": "falar sobre"
|
||||
},
|
||||
{
|
||||
"desc": "Aya Books Premium",
|
||||
"days": null,
|
||||
"value": "Incluído",
|
||||
"msisdn": "1199999999",
|
||||
"classe": "bundle",
|
||||
"verb": "falar sobre"
|
||||
},
|
||||
{
|
||||
"desc": "Bancah Jornais II",
|
||||
"days": null,
|
||||
"value": "Incluído",
|
||||
"msisdn": "1199999999",
|
||||
"classe": "bundle",
|
||||
"verb": "falar sobre"
|
||||
}
|
||||
],
|
||||
"Cobranças de Terceiros":[
|
||||
{
|
||||
"desc": "Seguro Celular Proteção Total",
|
||||
"value": 5.0,
|
||||
"msisdn": "1199999999"
|
||||
}
|
||||
]
|
||||
},
|
||||
"vocalized_msisdn": {
|
||||
"1199999999": "nove nove nove nove"
|
||||
},
|
||||
"91998188119": {
|
||||
"Planos": {
|
||||
"TIM Black B Light 8.0": {
|
||||
"period": "25/04 a 24/05",
|
||||
"days": 30,
|
||||
"msisdn": "91998188119",
|
||||
"valor_final": 95.99,
|
||||
"descontos": [
|
||||
{
|
||||
"desc": "Desc Fidel 30 TIM Black B Light 8.0 7/12",
|
||||
"value": -30.0,
|
||||
"installment": "7/12"
|
||||
},
|
||||
{
|
||||
"desc": "Desc Basic R$10 TIM Black B Light 8.0 4/6",
|
||||
"value": -10.0,
|
||||
"installment": "4/6"
|
||||
}
|
||||
],
|
||||
"total_descontos": -40.0,
|
||||
"valor_bruto": 135.99
|
||||
}
|
||||
},
|
||||
"SVA Detalhe Total": [
|
||||
{
|
||||
"desc": "TIM Fashion Mensal",
|
||||
"period": "30/04/25",
|
||||
"value": 10.0,
|
||||
"msisdn": "91998188119",
|
||||
"classe": "avulso",
|
||||
"verb": "cancelar"
|
||||
},
|
||||
{
|
||||
"desc": "Tamboro Mensal",
|
||||
"period": "10/05/25",
|
||||
"value": 12.99,
|
||||
"msisdn": "91998188119",
|
||||
"classe": "avulso",
|
||||
"verb": "cancelar"
|
||||
},
|
||||
{
|
||||
"desc": "Tamboro Mensal",
|
||||
"period": "12/05/25",
|
||||
"value": 7.99,
|
||||
"msisdn": "91998188119",
|
||||
"classe": "avulso",
|
||||
"verb": "cancelar"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
858
app/domain/contas/fixtures/invoice_pdf_include_danfe_true.json
Normal file
858
app/domain/contas/fixtures/invoice_pdf_include_danfe_true.json
Normal file
@@ -0,0 +1,858 @@
|
||||
{
|
||||
"Fatura Resumo": [
|
||||
{
|
||||
"desc": "PERÍODO",
|
||||
"period": "14/10 a 13/11"
|
||||
},
|
||||
{
|
||||
"desc": "EMISSÃO",
|
||||
"emissao": "20/11/2025"
|
||||
},
|
||||
{
|
||||
"desc": "Planos Contratados",
|
||||
"value": 176.98
|
||||
},
|
||||
{
|
||||
"desc": "Itens eventuais",
|
||||
"value": 46.99
|
||||
},
|
||||
{
|
||||
"desc": "JUROS",
|
||||
"value": 0.26
|
||||
},
|
||||
{
|
||||
"desc": "Multas",
|
||||
"value": 0.2
|
||||
},
|
||||
{
|
||||
"desc": "Total geral",
|
||||
"value": 197.43
|
||||
}
|
||||
],
|
||||
"DANFE-COM": {
|
||||
"Planos": {
|
||||
"TIM Black A 8.0": [
|
||||
{
|
||||
"desc": "TIM Black A 8.0",
|
||||
"unit": "UN",
|
||||
"qty": 1,
|
||||
"preco_unit": 98.69,
|
||||
"pis_cofins": 1.9,
|
||||
"bc_icms": 52.13,
|
||||
"aliq_icms": "17%",
|
||||
"icms": 8.86,
|
||||
"valor_bruto": 98.69,
|
||||
"total_descontos": -46.56,
|
||||
"valor_final": 52.13,
|
||||
"descontos": [
|
||||
{
|
||||
"desc": "Desc Fidel 80 TIM Black A compartilhado 8.0",
|
||||
"unit": "UN",
|
||||
"qty": 1,
|
||||
"preco_unit": -41.56,
|
||||
"pis_cofins": null,
|
||||
"bc_icms": null,
|
||||
"aliq_icms": null,
|
||||
"icms": null,
|
||||
"value": -41.56
|
||||
},
|
||||
{
|
||||
"desc": "Desc Esp TIM Black A compartilhado 8.0",
|
||||
"unit": "UN",
|
||||
"qty": 1,
|
||||
"preco_unit": -5.0,
|
||||
"pis_cofins": null,
|
||||
"bc_icms": null,
|
||||
"aliq_icms": null,
|
||||
"icms": null,
|
||||
"value": -5.0
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"desc": "Aya Ensinah Premium",
|
||||
"unit": "UN",
|
||||
"qty": 1,
|
||||
"preco_unit": 11.0,
|
||||
"pis_cofins": 0.0,
|
||||
"bc_icms": null,
|
||||
"aliq_icms": null,
|
||||
"icms": null,
|
||||
"valor_bruto": 11.0,
|
||||
"total_descontos": -4.63,
|
||||
"valor_final": 6.37,
|
||||
"descontos": [
|
||||
{
|
||||
"desc": "Desc Fidel 80 Aya Ensinah Premium",
|
||||
"unit": "UN",
|
||||
"qty": 1,
|
||||
"preco_unit": -4.63,
|
||||
"pis_cofins": null,
|
||||
"bc_icms": null,
|
||||
"aliq_icms": null,
|
||||
"icms": null,
|
||||
"value": -4.63
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"desc": "Bancah Premium + Jornais",
|
||||
"unit": "UN",
|
||||
"qty": 1,
|
||||
"preco_unit": 12.9,
|
||||
"pis_cofins": 0.27,
|
||||
"bc_icms": null,
|
||||
"aliq_icms": null,
|
||||
"icms": null,
|
||||
"valor_bruto": 12.9,
|
||||
"total_descontos": -5.43,
|
||||
"valor_final": 7.47,
|
||||
"descontos": [
|
||||
{
|
||||
"desc": "Desc Fidel 80 Bancah Premium + Jornais",
|
||||
"unit": "UN",
|
||||
"qty": 1,
|
||||
"preco_unit": -5.43,
|
||||
"pis_cofins": null,
|
||||
"bc_icms": null,
|
||||
"aliq_icms": null,
|
||||
"icms": null,
|
||||
"value": -5.43
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"desc": "Aya Audiobooks Premium",
|
||||
"unit": "UN",
|
||||
"qty": 1,
|
||||
"preco_unit": 28.0,
|
||||
"pis_cofins": 0.0,
|
||||
"bc_icms": null,
|
||||
"aliq_icms": null,
|
||||
"icms": null,
|
||||
"valor_bruto": 28.0,
|
||||
"total_descontos": -11.79,
|
||||
"valor_final": 16.21,
|
||||
"descontos": [
|
||||
{
|
||||
"desc": "Desc Fidel 80 Aya Audiobooks Premium",
|
||||
"unit": "UN",
|
||||
"qty": 1,
|
||||
"preco_unit": -11.79,
|
||||
"pis_cofins": null,
|
||||
"bc_icms": null,
|
||||
"aliq_icms": null,
|
||||
"icms": null,
|
||||
"value": -11.79
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"desc": "EXA Cloud 500GB",
|
||||
"unit": "UN",
|
||||
"qty": 1,
|
||||
"preco_unit": 4.5,
|
||||
"pis_cofins": 0.24,
|
||||
"bc_icms": null,
|
||||
"aliq_icms": null,
|
||||
"icms": null,
|
||||
"valor_bruto": 4.5,
|
||||
"total_descontos": -1.9,
|
||||
"valor_final": 2.6,
|
||||
"descontos": [
|
||||
{
|
||||
"desc": "Desc Fidel 80 EXA Cloud 500GB",
|
||||
"unit": "UN",
|
||||
"qty": 1,
|
||||
"preco_unit": -1.9,
|
||||
"pis_cofins": null,
|
||||
"bc_icms": null,
|
||||
"aliq_icms": null,
|
||||
"icms": null,
|
||||
"value": -1.9
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"desc": "EXA Segurança Premium",
|
||||
"unit": "UN",
|
||||
"qty": 1,
|
||||
"preco_unit": 6.9,
|
||||
"pis_cofins": 0.37,
|
||||
"bc_icms": null,
|
||||
"aliq_icms": null,
|
||||
"icms": null,
|
||||
"valor_bruto": 6.9,
|
||||
"total_descontos": -2.9,
|
||||
"valor_final": 4.0,
|
||||
"descontos": [
|
||||
{
|
||||
"desc": "Desc Fidel 80 EXA Segurança Premium",
|
||||
"unit": "UN",
|
||||
"qty": 1,
|
||||
"preco_unit": -2.9,
|
||||
"pis_cofins": null,
|
||||
"bc_icms": null,
|
||||
"aliq_icms": null,
|
||||
"icms": null,
|
||||
"value": -2.9
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"desc": "ITGame Light",
|
||||
"unit": "UN",
|
||||
"qty": 1,
|
||||
"preco_unit": 4.2,
|
||||
"pis_cofins": 0.22,
|
||||
"bc_icms": null,
|
||||
"aliq_icms": null,
|
||||
"icms": null,
|
||||
"valor_bruto": 4.2,
|
||||
"total_descontos": -1.77,
|
||||
"valor_final": 2.43,
|
||||
"descontos": [
|
||||
{
|
||||
"desc": "Desc Fidel 80 ITGame Light",
|
||||
"unit": "UN",
|
||||
"qty": 1,
|
||||
"preco_unit": -1.77,
|
||||
"pis_cofins": null,
|
||||
"bc_icms": null,
|
||||
"aliq_icms": null,
|
||||
"icms": null,
|
||||
"value": -1.77
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"desc": "Busuu 2",
|
||||
"unit": "UN",
|
||||
"qty": 1,
|
||||
"preco_unit": 5.0,
|
||||
"pis_cofins": 0.27,
|
||||
"bc_icms": null,
|
||||
"aliq_icms": null,
|
||||
"icms": null,
|
||||
"valor_bruto": 5.0,
|
||||
"total_descontos": -2.1,
|
||||
"valor_final": 2.9,
|
||||
"descontos": [
|
||||
{
|
||||
"desc": "Desc Fidel 80 Busuu 2",
|
||||
"unit": "UN",
|
||||
"qty": 1,
|
||||
"preco_unit": -2.1,
|
||||
"pis_cofins": null,
|
||||
"bc_icms": null,
|
||||
"aliq_icms": null,
|
||||
"icms": null,
|
||||
"value": -2.1
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"desc": "Fit Me App",
|
||||
"unit": "UN",
|
||||
"qty": 1,
|
||||
"preco_unit": 4.2,
|
||||
"pis_cofins": 0.22,
|
||||
"bc_icms": null,
|
||||
"aliq_icms": null,
|
||||
"icms": null,
|
||||
"valor_bruto": 4.2,
|
||||
"total_descontos": -1.77,
|
||||
"valor_final": 2.43,
|
||||
"descontos": [
|
||||
{
|
||||
"desc": "Desc Fidel 80 Fit Me App",
|
||||
"unit": "UN",
|
||||
"qty": 1,
|
||||
"preco_unit": -1.77,
|
||||
"pis_cofins": null,
|
||||
"bc_icms": null,
|
||||
"aliq_icms": null,
|
||||
"icms": null,
|
||||
"value": -1.77
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"desc": "Fluid Premium",
|
||||
"unit": "UN",
|
||||
"qty": 1,
|
||||
"preco_unit": 4.7,
|
||||
"pis_cofins": 0.25,
|
||||
"bc_icms": null,
|
||||
"aliq_icms": null,
|
||||
"icms": null,
|
||||
"valor_bruto": 4.7,
|
||||
"total_descontos": -1.98,
|
||||
"valor_final": 2.72,
|
||||
"descontos": [
|
||||
{
|
||||
"desc": "Desc Fidel 80 Fluid Premium",
|
||||
"unit": "UN",
|
||||
"qty": 1,
|
||||
"preco_unit": -1.98,
|
||||
"pis_cofins": null,
|
||||
"bc_icms": null,
|
||||
"aliq_icms": null,
|
||||
"icms": null,
|
||||
"value": -1.98
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"desc": "Tim Music",
|
||||
"unit": "UN",
|
||||
"qty": 1,
|
||||
"preco_unit": 9.9,
|
||||
"pis_cofins": 0.53,
|
||||
"bc_icms": null,
|
||||
"aliq_icms": null,
|
||||
"icms": null,
|
||||
"valor_bruto": 9.9,
|
||||
"total_descontos": -4.17,
|
||||
"valor_final": 5.73,
|
||||
"descontos": [
|
||||
{
|
||||
"desc": "Desc Fidel 80 Tim Music",
|
||||
"unit": "UN",
|
||||
"qty": 1,
|
||||
"preco_unit": -4.17,
|
||||
"pis_cofins": null,
|
||||
"bc_icms": null,
|
||||
"aliq_icms": null,
|
||||
"icms": null,
|
||||
"value": -4.17
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"TIM CTRL Redes Sociais 8.0": [
|
||||
{
|
||||
"desc": "TIM CTRL Redes Sociais 8.0",
|
||||
"unit": "UN",
|
||||
"qty": 1,
|
||||
"preco_unit": 56.09,
|
||||
"pis_cofins": 1.31,
|
||||
"bc_icms": 35.96,
|
||||
"aliq_icms": "17%",
|
||||
"icms": 6.11,
|
||||
"valor_bruto": 56.09,
|
||||
"total_descontos": -20.13,
|
||||
"valor_final": 35.96,
|
||||
"descontos": [
|
||||
{
|
||||
"desc": "Desc Fidel 33 TIM CTRL Redes Sociais 8.0",
|
||||
"unit": "UN",
|
||||
"qty": 1,
|
||||
"preco_unit": -17.13,
|
||||
"pis_cofins": null,
|
||||
"bc_icms": null,
|
||||
"aliq_icms": null,
|
||||
"icms": null,
|
||||
"value": -17.13
|
||||
},
|
||||
{
|
||||
"desc": "Desc Esp TIM CTRL Redes Sociais 8.0",
|
||||
"unit": "UN",
|
||||
"qty": 1,
|
||||
"preco_unit": -3.0,
|
||||
"pis_cofins": null,
|
||||
"bc_icms": null,
|
||||
"aliq_icms": null,
|
||||
"icms": null,
|
||||
"value": -3.0
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"desc": "Bancah Jornais II",
|
||||
"unit": "UN",
|
||||
"qty": 1,
|
||||
"preco_unit": 6.5,
|
||||
"pis_cofins": 0.17,
|
||||
"bc_icms": null,
|
||||
"aliq_icms": null,
|
||||
"icms": null,
|
||||
"valor_bruto": 6.5,
|
||||
"total_descontos": -1.99,
|
||||
"valor_final": 4.51,
|
||||
"descontos": [
|
||||
{
|
||||
"desc": "Desc Fidel 33 Bancah Jornais II",
|
||||
"unit": "UN",
|
||||
"qty": 1,
|
||||
"preco_unit": -1.99,
|
||||
"pis_cofins": null,
|
||||
"bc_icms": null,
|
||||
"aliq_icms": null,
|
||||
"icms": null,
|
||||
"value": -1.99
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"desc": "VOD + Canais Abertos",
|
||||
"unit": "UN",
|
||||
"qty": 1,
|
||||
"preco_unit": 6.5,
|
||||
"pis_cofins": 0.17,
|
||||
"bc_icms": null,
|
||||
"aliq_icms": null,
|
||||
"icms": null,
|
||||
"valor_bruto": 6.5,
|
||||
"total_descontos": -1.99,
|
||||
"valor_final": 4.51,
|
||||
"descontos": [
|
||||
{
|
||||
"desc": "VOD + Canais Abertos",
|
||||
"unit": "UN",
|
||||
"qty": 1,
|
||||
"preco_unit": -1.99,
|
||||
"pis_cofins": null,
|
||||
"bc_icms": null,
|
||||
"aliq_icms": null,
|
||||
"icms": null,
|
||||
"value": -1.99
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"desc": "Aluguel de filmes 1",
|
||||
"unit": "UN",
|
||||
"qty": 1,
|
||||
"preco_unit": 6.5,
|
||||
"pis_cofins": 0.17,
|
||||
"bc_icms": null,
|
||||
"aliq_icms": null,
|
||||
"icms": null,
|
||||
"valor_bruto": 6.5,
|
||||
"total_descontos": -1.99,
|
||||
"valor_final": 4.51,
|
||||
"descontos": [
|
||||
{
|
||||
"desc": "Aluguel de filmes 1",
|
||||
"unit": "UN",
|
||||
"qty": 1,
|
||||
"preco_unit": -1.99,
|
||||
"pis_cofins": null,
|
||||
"bc_icms": null,
|
||||
"aliq_icms": null,
|
||||
"icms": null,
|
||||
"value": -1.99
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"desc": "Aya Books Premium",
|
||||
"unit": "UN",
|
||||
"qty": 1,
|
||||
"preco_unit": 28.0,
|
||||
"pis_cofins": 0.0,
|
||||
"bc_icms": null,
|
||||
"aliq_icms": null,
|
||||
"icms": null,
|
||||
"valor_bruto": 28.0,
|
||||
"total_descontos": -8.56,
|
||||
"valor_final": 19.44,
|
||||
"descontos": [
|
||||
{
|
||||
"desc": "Desc Fidel 33 Aya Books Premium",
|
||||
"unit": "UN",
|
||||
"qty": 1,
|
||||
"preco_unit": -8.56,
|
||||
"pis_cofins": null,
|
||||
"bc_icms": null,
|
||||
"aliq_icms": null,
|
||||
"icms": null,
|
||||
"value": -8.56
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"desc": "Aya Ensinah Premium",
|
||||
"unit": "UN",
|
||||
"qty": 1,
|
||||
"preco_unit": 11.0,
|
||||
"pis_cofins": 0.0,
|
||||
"bc_icms": null,
|
||||
"aliq_icms": null,
|
||||
"icms": null,
|
||||
"valor_bruto": 11.0,
|
||||
"total_descontos": -3.36,
|
||||
"valor_final": 7.64,
|
||||
"descontos": [
|
||||
{
|
||||
"desc": "Desc Fidel 33 Aya Ensinah Premium",
|
||||
"unit": "UN",
|
||||
"qty": 1,
|
||||
"preco_unit": -3.36,
|
||||
"pis_cofins": null,
|
||||
"bc_icms": null,
|
||||
"aliq_icms": null,
|
||||
"icms": null,
|
||||
"value": -3.36
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"desc": "EXA Segurança + Proteção Stand",
|
||||
"unit": "UN",
|
||||
"qty": 1,
|
||||
"preco_unit": 4.8,
|
||||
"pis_cofins": 0.3,
|
||||
"bc_icms": null,
|
||||
"aliq_icms": null,
|
||||
"icms": null,
|
||||
"valor_bruto": 4.8,
|
||||
"total_descontos": -1.47,
|
||||
"valor_final": 3.33,
|
||||
"descontos": [
|
||||
{
|
||||
"desc": "Desc Fidel 33 EXA Segurança + Proteção Stand",
|
||||
"unit": "UN",
|
||||
"qty": 1,
|
||||
"preco_unit": -1.47,
|
||||
"pis_cofins": null,
|
||||
"bc_icms": null,
|
||||
"aliq_icms": null,
|
||||
"icms": null,
|
||||
"value": -1.47
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"desc": "Fluid Light",
|
||||
"unit": "UN",
|
||||
"qty": 1,
|
||||
"preco_unit": 1.6,
|
||||
"pis_cofins": 0.1,
|
||||
"bc_icms": null,
|
||||
"aliq_icms": null,
|
||||
"icms": null,
|
||||
"valor_bruto": 1.6,
|
||||
"total_descontos": -0.49,
|
||||
"valor_final": 1.11,
|
||||
"descontos": [
|
||||
{
|
||||
"desc": "Desc Fidel 33 Fluid Light",
|
||||
"unit": "UN",
|
||||
"qty": 1,
|
||||
"preco_unit": -0.49,
|
||||
"pis_cofins": null,
|
||||
"bc_icms": null,
|
||||
"aliq_icms": null,
|
||||
"icms": null,
|
||||
"value": -0.49
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"desc": "Serviços de Valor Adicionado Conteúdo",
|
||||
"unit": "UN",
|
||||
"qty": 1,
|
||||
"preco_unit": 14.99,
|
||||
"pis_cofins": 1.39,
|
||||
"bc_icms": null,
|
||||
"aliq_icms": null,
|
||||
"icms": null,
|
||||
"valor_bruto": 14.99,
|
||||
"total_descontos": 0.0,
|
||||
"valor_final": 14.99,
|
||||
"descontos": []
|
||||
}
|
||||
]
|
||||
},
|
||||
"total_geral": 191.97
|
||||
},
|
||||
"1199999999": {
|
||||
"Planos": {
|
||||
"TIM Black A 8.0": {
|
||||
"period": "14/10 a 13/11",
|
||||
"days": 31,
|
||||
"msisdn": "1199999999",
|
||||
"valor_final": 104.99,
|
||||
"descontos": [
|
||||
{
|
||||
"desc": "Desc Fidel 80 TIM Black A compartilhado 8.0 1/12",
|
||||
"value": -80.0,
|
||||
"installment": "1/12"
|
||||
},
|
||||
{
|
||||
"desc": "Desc Esp TIM Black A compartilhado 8.0",
|
||||
"value": -5.0,
|
||||
"installment": null
|
||||
}
|
||||
],
|
||||
"total_descontos": -85.0,
|
||||
"valor_bruto": 189.99
|
||||
},
|
||||
"TIM CTRL Redes Sociais 8.0": {
|
||||
"days": null,
|
||||
"msisdn": "1199999999",
|
||||
"valor_final": 71.99,
|
||||
"is_controle": true,
|
||||
"descontos": [
|
||||
{
|
||||
"desc": "Desc Esp TIM CTRL Redes Sociais 8.0 1",
|
||||
"value": -3.0,
|
||||
"installment": null
|
||||
},
|
||||
{
|
||||
"desc": "Desc Fidel 33 TIM CTRL Redes Sociais 8.0 8/12",
|
||||
"value": -33.0,
|
||||
"installment": "8/12"
|
||||
}
|
||||
],
|
||||
"total_descontos": -36.0,
|
||||
"valor_bruto": 107.99
|
||||
}
|
||||
},
|
||||
"Outros Valores": [
|
||||
{
|
||||
"desc": "MULTAS: (VENC 10/11/25, PAGO EM 11/10/25)",
|
||||
"value": 0.2,
|
||||
"msisdn": "1199999999"
|
||||
},
|
||||
{
|
||||
"desc": "JUROS: (VENC 10/11/25, PAGO EM 11/10/25)",
|
||||
"value": 0.26,
|
||||
"msisdn": "1199999999"
|
||||
}
|
||||
],
|
||||
"SVA Detalhe Total": [
|
||||
{
|
||||
"desc": "Tamboro Mensal",
|
||||
"period": "01/11/25",
|
||||
"value": 14.99,
|
||||
"msisdn": "1199999999",
|
||||
"classe": "avulso",
|
||||
"verb": "cancelar"
|
||||
},
|
||||
{
|
||||
"desc": "Tim Fashion",
|
||||
"period": "01/11/25",
|
||||
"value": 10.00,
|
||||
"msisdn": "1199999999",
|
||||
"classe": "avulso",
|
||||
"verb": "cancelar"
|
||||
},
|
||||
{
|
||||
"desc": "Neymar Jr",
|
||||
"period": "01/11/25",
|
||||
"value": 12.00,
|
||||
"msisdn": "1199999999",
|
||||
"classe": "avulso",
|
||||
"verb": "cancelar"
|
||||
},
|
||||
{
|
||||
"desc": "Youtube Premium",
|
||||
"period": "02/11/25",
|
||||
"value": 10,
|
||||
"msisdn": "1199999999",
|
||||
"estrategico": true,
|
||||
"classe": "estrategico",
|
||||
"verb": "falar sobre"
|
||||
},
|
||||
{
|
||||
"desc": "Paramount+",
|
||||
"period": "02/11/25",
|
||||
"value": 10,
|
||||
"msisdn": "1199999999",
|
||||
"estrategico": true,
|
||||
"classe": "estrategico",
|
||||
"verb": "falar sobre"
|
||||
},
|
||||
{
|
||||
"desc": "Apple Music SVA Mes",
|
||||
"value": 5.0,
|
||||
"msisdn": "1199999999",
|
||||
"estrategico": true,
|
||||
"classe": "estrategico",
|
||||
"verb": "falar sobre"
|
||||
},
|
||||
{
|
||||
"desc": "Apple Music Dados Mes",
|
||||
"value": 5.0,
|
||||
"msisdn": "1199999999",
|
||||
"estrategico": true,
|
||||
"classe": "estrategico",
|
||||
"verb": "falar sobre"
|
||||
}
|
||||
],
|
||||
"Serviços Bundle Inclusos": [
|
||||
{
|
||||
"desc": "Minutos Locais e DDD com 41",
|
||||
"period": "14/10 a 13/11",
|
||||
"days": 31,
|
||||
"value": "Incluído",
|
||||
"msisdn": "1199999999",
|
||||
"franchise": "Ilimitado",
|
||||
"consumption": "43m30s",
|
||||
"classe": "bundle",
|
||||
"verb": "falar sobre"
|
||||
},
|
||||
{
|
||||
"desc": "Pacote Américas Promocional",
|
||||
"period": "14/10 a 13/11",
|
||||
"days": 31,
|
||||
"value": "Incluído",
|
||||
"msisdn": "1199999999",
|
||||
"classe": "bundle",
|
||||
"verb": "falar sobre"
|
||||
},
|
||||
{
|
||||
"desc": "Tim Music",
|
||||
"period": "14/10 a 13/11",
|
||||
"days": 31,
|
||||
"value": "Incluído",
|
||||
"msisdn": "1199999999",
|
||||
"classe": "bundle",
|
||||
"verb": "falar sobre"
|
||||
},
|
||||
{
|
||||
"desc": "Fluid Premium",
|
||||
"period": "14/10 a 13/11",
|
||||
"days": 31,
|
||||
"value": "Incluído",
|
||||
"msisdn": "1199999999",
|
||||
"classe": "bundle",
|
||||
"verb": "falar sobre"
|
||||
},
|
||||
{
|
||||
"desc": "Fit Me App",
|
||||
"period": "14/10 a 13/11",
|
||||
"days": 31,
|
||||
"value": "Incluído",
|
||||
"msisdn": "1199999999",
|
||||
"classe": "bundle",
|
||||
"verb": "falar sobre"
|
||||
},
|
||||
{
|
||||
"desc": "Busuu 2",
|
||||
"period": "14/10 a 13/11",
|
||||
"days": 31,
|
||||
"value": "Incluído",
|
||||
"msisdn": "1199999999",
|
||||
"classe": "bundle",
|
||||
"verb": "falar sobre"
|
||||
},
|
||||
{
|
||||
"desc": "ITGame Light",
|
||||
"period": "14/10 a 13/11",
|
||||
"days": 31,
|
||||
"value": "Incluído",
|
||||
"msisdn": "1199999999",
|
||||
"classe": "bundle",
|
||||
"verb": "falar sobre"
|
||||
},
|
||||
{
|
||||
"desc": "EXA Segurança Premium",
|
||||
"period": "14/10 a 13/11",
|
||||
"days": 31,
|
||||
"value": "Incluído",
|
||||
"msisdn": "1199999999",
|
||||
"classe": "bundle",
|
||||
"verb": "falar sobre"
|
||||
},
|
||||
{
|
||||
"desc": "EXA Cloud 500GB",
|
||||
"period": "14/10 a 13/11",
|
||||
"days": 31,
|
||||
"value": "Incluído",
|
||||
"msisdn": "1199999999",
|
||||
"classe": "bundle",
|
||||
"verb": "falar sobre"
|
||||
},
|
||||
{
|
||||
"desc": "Aya Audiobooks Premium",
|
||||
"period": "14/10 a 13/11",
|
||||
"days": 31,
|
||||
"value": "Incluído",
|
||||
"msisdn": "1199999999",
|
||||
"classe": "bundle",
|
||||
"verb": "falar sobre"
|
||||
},
|
||||
{
|
||||
"desc": "Bancah Premium + Jornais",
|
||||
"period": "14/10 a 13/11",
|
||||
"days": 31,
|
||||
"value": "Incluído",
|
||||
"msisdn": "1199999999",
|
||||
"classe": "bundle",
|
||||
"verb": "falar sobre"
|
||||
},
|
||||
{
|
||||
"desc": "Aya Ensinah Premium",
|
||||
"period": "14/10 a 13/11",
|
||||
"days": 31,
|
||||
"value": "Incluído",
|
||||
"msisdn": "1199999999",
|
||||
"classe": "bundle",
|
||||
"verb": "falar sobre"
|
||||
},
|
||||
{
|
||||
"desc": "6GB Internet",
|
||||
"days": null,
|
||||
"value": "Incluído",
|
||||
"msisdn": "1199999999",
|
||||
"classe": "bundle",
|
||||
"verb": "falar sobre"
|
||||
},
|
||||
{
|
||||
"desc": "Minutos Locais e DDD com 41 - Ilimitado",
|
||||
"days": null,
|
||||
"value": "Incluído",
|
||||
"msisdn": "1199999999",
|
||||
"classe": "bundle",
|
||||
"verb": "falar sobre"
|
||||
},
|
||||
{
|
||||
"desc": "Fluid Light",
|
||||
"days": null,
|
||||
"value": "Incluído",
|
||||
"msisdn": "1199999999",
|
||||
"classe": "bundle",
|
||||
"verb": "falar sobre"
|
||||
},
|
||||
{
|
||||
"desc": "EXA Segurança + Proteção Stand",
|
||||
"days": null,
|
||||
"value": "Incluído",
|
||||
"msisdn": "1199999999",
|
||||
"classe": "bundle",
|
||||
"verb": "falar sobre"
|
||||
},
|
||||
{
|
||||
"desc": "Aya Ensinah Premium",
|
||||
"days": null,
|
||||
"value": "Incluído",
|
||||
"msisdn": "1199999999",
|
||||
"classe": "bundle",
|
||||
"verb": "falar sobre"
|
||||
},
|
||||
{
|
||||
"desc": "Aya Books Premium",
|
||||
"days": null,
|
||||
"value": "Incluído",
|
||||
"msisdn": "1199999999",
|
||||
"classe": "bundle",
|
||||
"verb": "falar sobre"
|
||||
},
|
||||
{
|
||||
"desc": "Bancah Jornais II",
|
||||
"days": null,
|
||||
"value": "Incluído",
|
||||
"msisdn": "1199999999",
|
||||
"classe": "bundle",
|
||||
"verb": "falar sobre"
|
||||
}
|
||||
]
|
||||
},
|
||||
"vocalized_msisdn": {
|
||||
"1199999999": "nove nove nove nove"
|
||||
}
|
||||
}
|
||||
5
app/domain/contas/fixtures/profile_bill.json
Normal file
5
app/domain/contas/fixtures/profile_bill.json
Normal file
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"billingProfile": {
|
||||
"paymentTypeId": 2
|
||||
}
|
||||
}
|
||||
4
app/domain/contas/fixtures/protocol.json
Normal file
4
app/domain/contas/fixtures/protocol.json
Normal file
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"interactionProtocol": "1234567890",
|
||||
"status": "OPENED"
|
||||
}
|
||||
68
app/domain/contas/fixtures/query_vas.json
Normal file
68
app/domain/contas/fixtures/query_vas.json
Normal file
@@ -0,0 +1,68 @@
|
||||
{
|
||||
"products": [
|
||||
{
|
||||
"appId": "1001",
|
||||
"cspId": "740",
|
||||
"ippId": "IPP-1001",
|
||||
"name": "TIM Fashion Mensal",
|
||||
"can": {
|
||||
"cancel": true
|
||||
},
|
||||
"status": "ACTIVE",
|
||||
"details": {
|
||||
"valor": "10,00",
|
||||
"can": {
|
||||
"cancel": true
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"appId": "1002",
|
||||
"cspId": "740",
|
||||
"ippId": "IPP-1002",
|
||||
"name": "Aya Audiobooks Premium",
|
||||
"can": {
|
||||
"cancel": true
|
||||
},
|
||||
"status": "ACTIVE",
|
||||
"details": {
|
||||
"valor": "9,99",
|
||||
"can": {
|
||||
"cancel": true
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"appId": "1003",
|
||||
"cspId": "740",
|
||||
"ippId": "IPP-1003",
|
||||
"name": "Neymar Jr",
|
||||
"can": {
|
||||
"cancel": true
|
||||
},
|
||||
"status": "ACTIVE",
|
||||
"details": {
|
||||
"valor": "12,00",
|
||||
"can": {
|
||||
"cancel": true
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"appId": "1004",
|
||||
"cspId": "740",
|
||||
"ippId": "IPP-1004",
|
||||
"name": "Tamboro Mensal",
|
||||
"can": {
|
||||
"cancel": true
|
||||
},
|
||||
"status": "ACTIVE",
|
||||
"details": {
|
||||
"valor": "14,99",
|
||||
"can": {
|
||||
"cancel": true
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
4
app/domain/contas/fixtures/service_request_status.json
Normal file
4
app/domain/contas/fixtures/service_request_status.json
Normal file
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"status": 204,
|
||||
"message": "Service Request atualizada com sucesso"
|
||||
}
|
||||
3
app/domain/contas/fixtures/sms.json
Normal file
3
app/domain/contas/fixtures/sms.json
Normal file
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"resourceURL": "mock://sms/AUTO"
|
||||
}
|
||||
5
app/domain/contas/fixtures/tracking_activities.json
Normal file
5
app/domain/contas/fixtures/tracking_activities.json
Normal file
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"status": 202,
|
||||
"message": "TrackingActivities registrado com sucesso",
|
||||
"trackingId": "MOCK-TRACKING-0001"
|
||||
}
|
||||
30
app/domain/contas/fixtures/vas_history.json
Normal file
30
app/domain/contas/fixtures/vas_history.json
Normal file
@@ -0,0 +1,30 @@
|
||||
{
|
||||
"services": [
|
||||
{
|
||||
"activationChannel": "APP",
|
||||
"activationDate": "2026-06-09T20:07:59.000Z",
|
||||
"appId": 14395,
|
||||
"billDescription": "HBO Max Standard",
|
||||
"canCancel": true,
|
||||
"canResendEvent": false,
|
||||
"cspDescription": "HBO",
|
||||
"cspId": 825,
|
||||
"description": "Max mensal fatura",
|
||||
"eventDate": "2026-06-09T20:07:59.000Z",
|
||||
"eventType": "activation",
|
||||
"externalCall": {
|
||||
"id": "OCSG",
|
||||
"name": "OCSG",
|
||||
"type": "OCSG"
|
||||
},
|
||||
"largeAccount": "323",
|
||||
"lastEventDate": "2026-06-09T20:07:59.000Z",
|
||||
"name": "Max mensal fatura",
|
||||
"paymentMethod": "Fatura",
|
||||
"price": 44.9,
|
||||
"provider": "HBO",
|
||||
"providerContact": "Missing - Missing",
|
||||
"subscriptionType": "MENSAL"
|
||||
}
|
||||
]
|
||||
}
|
||||
1060
app/domain/contas/ic_tags.py
Normal file
1060
app/domain/contas/ic_tags.py
Normal file
File diff suppressed because it is too large
Load Diff
68
app/domain/contas/informational_context.py
Normal file
68
app/domain/contas/informational_context.py
Normal file
@@ -0,0 +1,68 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from typing import Any
|
||||
|
||||
_STRATEGIC = (
|
||||
"paramount", "netflix", "disney", "hbo", "youtube premium", "aya ",
|
||||
"deezer", "globoplay", "truecaller", "food balance",
|
||||
)
|
||||
|
||||
|
||||
def _norm(value: Any) -> str:
|
||||
text = str(value or "").casefold()
|
||||
text = re.sub(r"[^a-z0-9áàâãéèêíìîóòôõúùûç+ ]+", " ", text)
|
||||
return " ".join(text.split())
|
||||
|
||||
|
||||
def _iter_invoice_rows(invoice_detail: Any):
|
||||
if not isinstance(invoice_detail, dict):
|
||||
return
|
||||
for _, sections in invoice_detail.items():
|
||||
if not isinstance(sections, dict):
|
||||
continue
|
||||
for section, rows in sections.items():
|
||||
if not isinstance(rows, list):
|
||||
continue
|
||||
for row in rows:
|
||||
if isinstance(row, dict):
|
||||
yield str(section or ""), row
|
||||
|
||||
|
||||
def infer_informational_context(query: str, invoice_detail: Any = None) -> dict[str, list[str]]:
|
||||
"""Infere a marca informacional usada pela finalização após uma consulta RAG.
|
||||
|
||||
Não executa RAG e não guarda sessão. Apenas transforma a pergunta + evidência
|
||||
de fatura em metadata de domínio que o grafo/framework poderá persistir.
|
||||
"""
|
||||
q = _norm(query)
|
||||
if not q:
|
||||
return {}
|
||||
names: list[str] = []
|
||||
types: list[str] = []
|
||||
for section, row in _iter_invoice_rows(invoice_detail):
|
||||
name = str(row.get("desc") or row.get("name") or row.get("description") or "").strip()
|
||||
if not name:
|
||||
continue
|
||||
n = _norm(name)
|
||||
if n and (n in q or q in n):
|
||||
names.append(name)
|
||||
sec = _norm(section)
|
||||
if "sva" in sec or "servico" in sec or "serviço" in sec:
|
||||
types.append("avulso")
|
||||
# Serviços estratégicos podem ser reconhecidos mesmo sem detalhe da fatura.
|
||||
if not names:
|
||||
for alias in _STRATEGIC:
|
||||
if _norm(alias) in q:
|
||||
names.append(alias.strip().title())
|
||||
types.append("estrategico")
|
||||
break
|
||||
if not names:
|
||||
return {}
|
||||
dedup_names = list(dict.fromkeys(names))
|
||||
dedup_types = list(dict.fromkeys(types))
|
||||
return {
|
||||
"informational_service_names": dedup_names,
|
||||
"informational_vas_types": dedup_types,
|
||||
"informational_rag_vas_types": dedup_types,
|
||||
}
|
||||
30
app/domain/contas/integrations/secure_pdf_crypto.py
Normal file
30
app/domain/contas/integrations/secure_pdf_crypto.py
Normal file
@@ -0,0 +1,30 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
from urllib.parse import quote_plus, unquote_plus
|
||||
|
||||
from cryptography.hazmat.decrepit.ciphers.algorithms import Blowfish
|
||||
from cryptography.hazmat.primitives.ciphers import Cipher, modes
|
||||
from cryptography.hazmat.primitives.padding import PKCS7
|
||||
|
||||
_SECURE_PDF_KEY = b"T1mC0nT4"
|
||||
|
||||
|
||||
def encrypt_secure_pdf_value(value: str) -> str:
|
||||
plaintext = str(value).encode("utf-8")
|
||||
padder = PKCS7(Blowfish.block_size).padder()
|
||||
padded = padder.update(plaintext) + padder.finalize()
|
||||
encryptor = Cipher(Blowfish(_SECURE_PDF_KEY), modes.ECB()).encryptor()
|
||||
ciphertext = encryptor.update(padded) + encryptor.finalize()
|
||||
encoded = base64.b64encode(ciphertext).decode("ascii")
|
||||
return quote_plus(encoded, safe="")
|
||||
|
||||
|
||||
def decrypt_secure_pdf_value(value: str) -> str:
|
||||
encoded = unquote_plus(str(value))
|
||||
ciphertext = base64.b64decode(encoded, validate=True)
|
||||
decryptor = Cipher(Blowfish(_SECURE_PDF_KEY), modes.ECB()).decryptor()
|
||||
padded = decryptor.update(ciphertext) + decryptor.finalize()
|
||||
unpadder = PKCS7(Blowfish.block_size).unpadder()
|
||||
plaintext = unpadder.update(padded) + unpadder.finalize()
|
||||
return plaintext.decode("utf-8")
|
||||
228
app/domain/contas/invoice_context.py
Normal file
228
app/domain/contas/invoice_context.py
Normal file
@@ -0,0 +1,228 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import os
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
from agent_framework.cache.cache import Cache
|
||||
|
||||
from .client import TimApiClient
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class InvoiceContext:
|
||||
msisdn: str
|
||||
invoice_id: str = ""
|
||||
complete_invoices: dict[str, Any] | None = None
|
||||
billing_analysis: dict[str, Any] | None = None
|
||||
invoice_detail: Any = None
|
||||
customer_id: str = ""
|
||||
error: str | None = None
|
||||
cache_hit: bool = False
|
||||
business_events: list[dict[str, Any]] | None = None
|
||||
errors: dict[str, str] | None = None
|
||||
metadata: dict[str, Any] | None = None
|
||||
|
||||
def as_dict(self) -> dict[str, Any]:
|
||||
return {
|
||||
"msisdn": self.msisdn,
|
||||
"invoice_id": self.invoice_id,
|
||||
"complete_invoices_payload": self.complete_invoices,
|
||||
"billing_analysis": self.billing_analysis,
|
||||
"invoice_detail": self.invoice_detail,
|
||||
"customer_id": self.customer_id,
|
||||
"invoice_context_error": self.error,
|
||||
"invoice_context_cache_hit": self.cache_hit,
|
||||
"invoice_context_business_events": list(self.business_events or []),
|
||||
"invoice_context_errors": dict(self.errors or {}),
|
||||
"invoice_context_metadata": dict(self.metadata or {}),
|
||||
}
|
||||
|
||||
|
||||
class InvoiceContextService:
|
||||
"""Session-scoped invoice prefetch backed by the framework cache.
|
||||
|
||||
This replaces the legacy InvoiceContextProvider without owning session,
|
||||
thread, LangGraph or LLM infrastructure. The cache implementation and TTL
|
||||
belong to ``agent_framework``; this class only knows which TIM evidence is
|
||||
useful to the Contas domain.
|
||||
"""
|
||||
|
||||
def __init__(self, client: TimApiClient, cache: Cache, *, ttl_seconds: int | None = None) -> None:
|
||||
self.client = client
|
||||
self.cache = cache
|
||||
self.ttl_seconds = ttl_seconds or int(os.getenv("TIM_INVOICE_CONTEXT_TTL_SECONDS", "1800"))
|
||||
self._inflight: dict[str, asyncio.Task[InvoiceContext]] = {}
|
||||
self._inflight_lock = asyncio.Lock()
|
||||
|
||||
@staticmethod
|
||||
def _key(*, session_id: str, msisdn: str, invoice_id: str) -> str | None:
|
||||
# No global cache without a session: prevents cross-customer leakage.
|
||||
sid = str(session_id or "").strip()
|
||||
if not sid:
|
||||
return None
|
||||
return f"contas:invoice-context:{sid}:{msisdn}:{invoice_id or 'latest'}"
|
||||
|
||||
@staticmethod
|
||||
def _extract_identity(complete: Any, requested_invoice_id: str = "") -> tuple[str, str]:
|
||||
if not isinstance(complete, dict):
|
||||
return "", requested_invoice_id
|
||||
billing = complete.get("billingProfile") or complete.get("billing_profile") or {}
|
||||
customer = billing.get("customer") if isinstance(billing, dict) else {}
|
||||
customer_id = str((customer or {}).get("customerId") or (customer or {}).get("id") or "") if isinstance(customer, dict) else ""
|
||||
items = complete.get("paymentItems") or complete.get("payment_items") or []
|
||||
invoice_id = requested_invoice_id
|
||||
if not invoice_id and isinstance(items, list):
|
||||
first = next((x for x in items if isinstance(x, dict)), {})
|
||||
invoice_id = str(first.get("invoiceId") or first.get("invoiceNumber") or "") if isinstance(first, dict) else ""
|
||||
return customer_id, invoice_id
|
||||
|
||||
@staticmethod
|
||||
def _event(code: str, *, msisdn: str, invoice_id: str, session_id: str, message_id: str = "", api_status_code: int = 0, error: str = "", channel_id: str = "URA", ura_call_id: str = "") -> dict[str, Any]:
|
||||
payload = {
|
||||
"tag": code,
|
||||
"agentId": os.getenv("TIM_AGENT_ID", "contas"),
|
||||
"gsm": msisdn,
|
||||
"sessionId": session_id,
|
||||
"messageId": message_id,
|
||||
"channelId": channel_id or "URA",
|
||||
"uraCallId": ura_call_id or "",
|
||||
"agentSpecificData": json.dumps({"billingId": invoice_id}, ensure_ascii=False) if invoice_id else "",
|
||||
"billingId": invoice_id or "",
|
||||
"apiStatusCode": int(api_status_code or 0),
|
||||
}
|
||||
if error:
|
||||
payload["error"] = error
|
||||
return {"code": code, "payload": payload, "component": "invoice_context_prefetch"}
|
||||
|
||||
async def _result_event_once(self, code: str, *, session_id: str, msisdn: str, invoice_id: str, message_id: str, api_status_code: int = 0, error: str = "") -> list[dict[str, Any]]:
|
||||
if not session_id:
|
||||
return [self._event(code, msisdn=msisdn, invoice_id=invoice_id, session_id=session_id, message_id=message_id, api_status_code=api_status_code, error=error)]
|
||||
marker = f"contas:invoice-context:event-result:{code}:{session_id}:{msisdn}:{invoice_id or 'latest'}"
|
||||
if await self.cache.get(marker):
|
||||
return []
|
||||
await self.cache.set(marker, True, ttl_seconds=self.ttl_seconds)
|
||||
return [self._event(code, msisdn=msisdn, invoice_id=invoice_id, session_id=session_id, message_id=message_id, api_status_code=api_status_code, error=error)]
|
||||
|
||||
async def _started_event_once(self, *, session_id: str, msisdn: str, invoice_id: str, message_id: str) -> list[dict[str, Any]]:
|
||||
if not session_id:
|
||||
return [self._event("CVN.002", msisdn=msisdn, invoice_id=invoice_id, session_id=session_id, message_id=message_id)]
|
||||
marker = f"contas:invoice-context:event-start:{session_id}:{msisdn}:{invoice_id or 'latest'}"
|
||||
if await self.cache.get(marker):
|
||||
return []
|
||||
await self.cache.set(marker, True, ttl_seconds=self.ttl_seconds)
|
||||
return [self._event("CVN.002", msisdn=msisdn, invoice_id=invoice_id, session_id=session_id, message_id=message_id)]
|
||||
|
||||
@staticmethod
|
||||
async def _timed_to_thread(name: str, fn: Any, *args: Any, **kwargs: Any) -> tuple[Any, str, float]:
|
||||
started = time.perf_counter()
|
||||
try:
|
||||
value = await asyncio.to_thread(fn, *args, **kwargs)
|
||||
return value, "", round((time.perf_counter() - started) * 1000, 3)
|
||||
except Exception as exc:
|
||||
return None, f"{type(exc).__name__}: {exc}", round((time.perf_counter() - started) * 1000, 3)
|
||||
|
||||
async def _fetch(
|
||||
self, *, session_id: str, msisdn: str, invoice_id: str, include_detail: bool, message_id: str
|
||||
) -> InvoiceContext:
|
||||
events = await self._started_event_once(
|
||||
session_id=session_id, msisdn=msisdn, invoice_id=invoice_id, message_id=message_id
|
||||
)
|
||||
fetch_started = time.perf_counter()
|
||||
complete_result, billing_result = await asyncio.gather(
|
||||
self._timed_to_thread("complete_invoices", self.client.consultar_faturas, msisdn),
|
||||
self._timed_to_thread("billing_analysis", self.client.billing_analysis, msisdn, invoice_id=invoice_id),
|
||||
)
|
||||
complete, complete_error, complete_ms = complete_result
|
||||
billing, billing_error, billing_ms = billing_result
|
||||
errors: dict[str, str] = {}
|
||||
if complete_error:
|
||||
errors["complete_invoices"] = complete_error
|
||||
if billing_error:
|
||||
errors["billing_analysis"] = billing_error
|
||||
error_parts = [f"{k}: {v}" for k, v in errors.items()]
|
||||
billing_status = 0
|
||||
|
||||
customer_id, resolved_invoice_id = self._extract_identity(complete, invoice_id)
|
||||
event_invoice_id = resolved_invoice_id or invoice_id
|
||||
if billing is None:
|
||||
events.extend(await self._result_event_once("CVN.007", msisdn=msisdn, invoice_id=event_invoice_id, session_id=session_id, message_id=message_id, api_status_code=billing_status, error=error_parts[-1] if error_parts else "billing_analysis failed"))
|
||||
else:
|
||||
events.extend(await self._result_event_once("CVN.006", msisdn=msisdn, invoice_id=event_invoice_id, session_id=session_id, message_id=message_id))
|
||||
|
||||
detail = None
|
||||
detail_ms = 0.0
|
||||
if include_detail and resolved_invoice_id:
|
||||
detail, detail_error, detail_ms = await self._timed_to_thread(
|
||||
"invoice_detail", self.client.bill_pdf, msisdn, resolved_invoice_id, customer_id,
|
||||
include_danfe=True, output="json"
|
||||
)
|
||||
if detail_error:
|
||||
errors["invoice_detail"] = detail_error
|
||||
error_parts.append(f"invoice_detail: {detail_error}")
|
||||
|
||||
metadata = {
|
||||
"cache_hit": False,
|
||||
"fetch_elapsed_ms": round((time.perf_counter() - fetch_started) * 1000, 3),
|
||||
"task_timings": {
|
||||
"complete_invoices": complete_ms,
|
||||
"billing_analysis": billing_ms,
|
||||
**({"invoice_detail": detail_ms} if include_detail else {}),
|
||||
},
|
||||
}
|
||||
return InvoiceContext(
|
||||
msisdn=msisdn, invoice_id=resolved_invoice_id,
|
||||
complete_invoices=complete if isinstance(complete, dict) else None,
|
||||
billing_analysis=billing if isinstance(billing, dict) else None,
|
||||
invoice_detail=detail, customer_id=customer_id,
|
||||
error="; ".join(error_parts) or None, cache_hit=False, business_events=events, errors=errors, metadata=metadata,
|
||||
)
|
||||
|
||||
async def get(
|
||||
self, *, session_id: str, msisdn: str, invoice_id: str = "",
|
||||
use_cache: bool = True, include_detail: bool = False, message_id: str = "",
|
||||
) -> InvoiceContext:
|
||||
key = self._key(session_id=session_id, msisdn=msisdn, invoice_id=invoice_id)
|
||||
if use_cache and key:
|
||||
cached = await self.cache.get(key)
|
||||
if isinstance(cached, dict) and (not include_detail or cached.get("invoice_detail") is not None):
|
||||
cached = dict(cached)
|
||||
cached["cache_hit"] = True
|
||||
cached["business_events"] = [] # cache hit must not duplicate business effects
|
||||
metadata = dict(cached.get("metadata") or {})
|
||||
metadata["cache_hit"] = True
|
||||
fetched_at = float(cached.get("_fetched_at") or time.time())
|
||||
metadata["cache_age_ms"] = max(0.0, round((time.time() - fetched_at) * 1000, 3))
|
||||
cached["metadata"] = metadata
|
||||
return InvoiceContext(**{k: cached.get(k) for k in InvoiceContext.__dataclass_fields__})
|
||||
|
||||
task_key = (f"{key}:detail={int(include_detail)}" if key else f"nocache:{session_id}:{msisdn}:{invoice_id}:{include_detail}")
|
||||
if use_cache and key:
|
||||
async with self._inflight_lock:
|
||||
task = self._inflight.get(task_key)
|
||||
if task is None:
|
||||
task = asyncio.create_task(self._fetch(session_id=session_id, msisdn=msisdn, invoice_id=invoice_id, include_detail=include_detail, message_id=message_id))
|
||||
self._inflight[task_key] = task
|
||||
try:
|
||||
ctx = await task
|
||||
finally:
|
||||
async with self._inflight_lock:
|
||||
if self._inflight.get(task_key) is task and task.done():
|
||||
self._inflight.pop(task_key, None)
|
||||
else:
|
||||
ctx = await self._fetch(session_id=session_id, msisdn=msisdn, invoice_id=invoice_id, include_detail=include_detail, message_id=message_id)
|
||||
|
||||
if key:
|
||||
await self.cache.set(key, {
|
||||
"msisdn": ctx.msisdn, "invoice_id": ctx.invoice_id,
|
||||
"complete_invoices": ctx.complete_invoices, "billing_analysis": ctx.billing_analysis,
|
||||
"invoice_detail": ctx.invoice_detail, "customer_id": ctx.customer_id,
|
||||
"error": ctx.error, "cache_hit": False,
|
||||
"errors": dict(ctx.errors or {}), "metadata": dict(ctx.metadata or {}),
|
||||
"_fetched_at": time.time(),
|
||||
}, ttl_seconds=self.ttl_seconds)
|
||||
return ctx
|
||||
|
||||
30
app/domain/contas/invoice_models.py
Normal file
30
app/domain/contas/invoice_models.py
Normal file
@@ -0,0 +1,30 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from decimal import Decimal
|
||||
from typing import Any, Literal, Optional
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class ResolvedInvoiceItem:
|
||||
canonical_name: str
|
||||
tool_category: Optional[Literal["cancelar_vas_avulso", "vas_estrategico", "pro_rata"]]
|
||||
item_type: Literal["avulso", "estrategico", "bundle", "plano", "out_of_scope"]
|
||||
msisdn: str
|
||||
value: Optional[Decimal]
|
||||
section: str
|
||||
charge_date: Optional[str] = None
|
||||
raw_entry: dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class MentionedItem:
|
||||
desc: str
|
||||
msisdn: Optional[str] = None
|
||||
value: Optional[str] = None
|
||||
date: Optional[str] = None
|
||||
|
||||
|
||||
def is_period_range(period: Any) -> bool:
|
||||
text = str(period)
|
||||
return " a " in text or "~" in text
|
||||
1119
app/domain/contas/invoice_resolver.py
Normal file
1119
app/domain/contas/invoice_resolver.py
Normal file
File diff suppressed because it is too large
Load Diff
135
app/domain/contas/item_matcher.py
Normal file
135
app/domain/contas/item_matcher.py
Normal file
@@ -0,0 +1,135 @@
|
||||
"""Matcher conservador de nomes de item para o InvoiceResolver.
|
||||
|
||||
Porta o scoring determinístico do Contas original (grafia + fonética), mas não
|
||||
mantém LangChain/gateway próprio. O fallback conversacional é responsabilidade do
|
||||
agent_framework: quando mais de um candidato permanece plausível, o resultado é
|
||||
ambíguo e o runtime pede clarificação ao cliente.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import unicodedata
|
||||
from typing import Any
|
||||
|
||||
try:
|
||||
import jellyfish # type: ignore
|
||||
except Exception: # pragma: no cover - fallback usado em ambientes mínimos
|
||||
jellyfish = None
|
||||
from difflib import SequenceMatcher
|
||||
from . import string_metrics as _fallback_metrics
|
||||
|
||||
from .invoice_resolver import ItemMatcherError
|
||||
|
||||
_TOP_K = 10
|
||||
_MIN_TOKEN_LEN = 3
|
||||
_GENERIC_TOKENS = frozenset({"app", "premium", "plus", "light", "mensal", "mes", "dados", "sva"})
|
||||
|
||||
|
||||
def _normalize(text: str) -> str:
|
||||
decomposed = unicodedata.normalize("NFKD", str(text).lower())
|
||||
return "".join(c for c in decomposed if not unicodedata.combining(c))
|
||||
|
||||
|
||||
def _phrase_sim(a: str, b: str) -> float:
|
||||
if jellyfish is not None:
|
||||
return jellyfish.jaro_winkler_similarity(a, b)
|
||||
return _fallback_metrics.jaro_winkler_similarity(a, b)
|
||||
|
||||
|
||||
def _significant_tokens(text: str) -> list[str]:
|
||||
return [t for t in text.split() if len(t) >= _MIN_TOKEN_LEN and t not in _GENERIC_TOKENS]
|
||||
|
||||
|
||||
def _token_sim(mention: str, candidate: str) -> float:
|
||||
tokens = _significant_tokens(candidate)
|
||||
if not tokens:
|
||||
return _phrase_sim(mention, candidate)
|
||||
return max(_phrase_sim(mention, t) for t in tokens)
|
||||
|
||||
|
||||
def _code_sim(a: str, b: str) -> float:
|
||||
if not a or not b:
|
||||
return 0.0
|
||||
if jellyfish is not None:
|
||||
distance = jellyfish.levenshtein_distance(a, b)
|
||||
return 1.0 - distance / max(len(a), len(b))
|
||||
distance = _fallback_metrics.levenshtein_distance(a, b)
|
||||
return 1.0 - distance / max(len(a), len(b))
|
||||
|
||||
|
||||
def _phon_sim(mention: str, candidate: str) -> float:
|
||||
code_m = jellyfish.metaphone(mention) if jellyfish is not None else _fallback_metrics.metaphone(mention)
|
||||
if not code_m:
|
||||
return 0.0
|
||||
tokens = _significant_tokens(candidate) or [candidate]
|
||||
return max(_code_sim(code_m, jellyfish.metaphone(token) if jellyfish is not None else _fallback_metrics.metaphone(token)) for token in tokens)
|
||||
|
||||
|
||||
|
||||
|
||||
def _token_pair_sim(a: str, b: str) -> float:
|
||||
"""Combina evidência ortográfica e fonética para um par de tokens.
|
||||
|
||||
O pequeno bônus pela segunda evidência resolve transcrições curtas como
|
||||
``apou`` -> ``apple`` sem transformar prefixos puramente gráficos em match.
|
||||
"""
|
||||
jw = _phrase_sim(a, b)
|
||||
code_a = jellyfish.metaphone(a) if jellyfish is not None else _fallback_metrics.metaphone(a)
|
||||
code_b = jellyfish.metaphone(b) if jellyfish is not None else _fallback_metrics.metaphone(b)
|
||||
ph = _code_sim(code_a, code_b)
|
||||
return min(1.0, max(jw, ph) + 0.15 * min(jw, ph))
|
||||
|
||||
|
||||
def _token_alignment_sim(mention: str, candidate: str) -> float:
|
||||
mention_tokens = [t for t in mention.split() if len(t) >= 2]
|
||||
candidate_tokens = [t for t in candidate.split() if len(t) >= _MIN_TOKEN_LEN and t not in _GENERIC_TOKENS]
|
||||
if not mention_tokens or not candidate_tokens:
|
||||
return 0.0
|
||||
# Cada token reconhecido pelo ASR precisa encontrar seu melhor correspondente.
|
||||
# A média impede que um token genérico perfeito (ex.: ``tim``) esconda o
|
||||
# discriminante errado (``miusic`` vs ``games``).
|
||||
return sum(max(_token_pair_sim(mt, ct) for ct in candidate_tokens) for mt in mention_tokens) / len(mention_tokens)
|
||||
|
||||
def _grafia_sim(mention: str, candidate: str) -> float:
|
||||
return max(_phrase_sim(mention, candidate), _token_sim(mention, candidate))
|
||||
|
||||
|
||||
class SimilarityItemMatcher:
|
||||
"""Matcher síncrono compatível com ``InvoiceResolver.ItemMatcherLLM``.
|
||||
|
||||
A implementação é deliberadamente fail-closed: só resolve automaticamente
|
||||
quando o melhor candidato tem score alto e margem suficiente. Empates
|
||||
plausíveis são devolvidos juntos para o framework pedir clarificação.
|
||||
"""
|
||||
|
||||
def __init__(self, *, accept_threshold: float = 0.78, ambiguity_margin: float = 0.06, top_k: int = _TOP_K) -> None:
|
||||
self.accept_threshold = float(accept_threshold)
|
||||
self.ambiguity_margin = float(ambiguity_margin)
|
||||
self.top_k = int(top_k)
|
||||
|
||||
def score(self, mention: str, candidate: str) -> float:
|
||||
nm, nd = _normalize(mention), _normalize(candidate)
|
||||
return max(_grafia_sim(nm, nd), _phon_sim(nm, nd), _token_alignment_sim(nm, nd))
|
||||
|
||||
def best_similarity(self, mention: str, candidates: list[str]) -> float:
|
||||
if not candidates:
|
||||
return 0.0
|
||||
return max(self.score(mention, candidate) for candidate in candidates)
|
||||
|
||||
def ranked(self, mention: str, candidates: list[str]) -> list[tuple[str, float]]:
|
||||
ranked = [(candidate, self.score(mention, candidate)) for candidate in candidates]
|
||||
ranked.sort(key=lambda pair: pair[1], reverse=True)
|
||||
return ranked[: self.top_k]
|
||||
|
||||
def match(self, mention: str, candidates: list[str], *, callbacks: list[Any] | None = None) -> list[str]:
|
||||
del callbacks
|
||||
if not candidates:
|
||||
return []
|
||||
ranked = self.ranked(mention, candidates)
|
||||
if not ranked or ranked[0][1] < self.accept_threshold:
|
||||
return []
|
||||
best = ranked[0][1]
|
||||
plausible = [name for name, score in ranked if score >= self.accept_threshold and best - score <= self.ambiguity_margin]
|
||||
return plausible or [ranked[0][0]]
|
||||
|
||||
|
||||
__all__ = ["SimilarityItemMatcher"]
|
||||
16
app/domain/contas/parsers/__init__.py
Normal file
16
app/domain/contas/parsers/__init__.py
Normal file
@@ -0,0 +1,16 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
from typing import Any
|
||||
|
||||
from .bill_parser import TimBillParser
|
||||
from .bill_processor import PDFBillingProcessor
|
||||
|
||||
def parse_tim_bill_pdf(pdf_content: bytes, *, include_danfe: bool = False) -> dict[str, Any]:
|
||||
pdf_bytes = io.BytesIO(pdf_content)
|
||||
parser = TimBillParser()
|
||||
dfs = parser.parse_pdf(pdf_bytes)
|
||||
processor = PDFBillingProcessor(include_danfe=include_danfe)
|
||||
return processor.normalize(dfs)
|
||||
|
||||
__all__ = ["parse_tim_bill_pdf", "TimBillParser", "PDFBillingProcessor"]
|
||||
533
app/domain/contas/parsers/bill_parser.py
Normal file
533
app/domain/contas/parsers/bill_parser.py
Normal file
@@ -0,0 +1,533 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
import re
|
||||
import unicodedata as ud
|
||||
from pathlib import Path
|
||||
from typing import Dict, Union, Any
|
||||
|
||||
import pandas as pd
|
||||
import pdfplumber
|
||||
|
||||
###############################################################################
|
||||
# Normalização #
|
||||
###############################################################################
|
||||
_DASH_CHARS = "\u2010\u2011\u2012\u2013\u2014\u2212"
|
||||
_NBSP_CHARS = "\u00A0\u202F\u2007"
|
||||
_dash_trans = str.maketrans({c: "-" for c in _DASH_CHARS})
|
||||
_nbsp_trans = str.maketrans({c: " " for c in _NBSP_CHARS})
|
||||
|
||||
|
||||
def _normalize_line(s: str) -> str:
|
||||
s = s.translate(_dash_trans).translate(_nbsp_trans)
|
||||
s = ud.normalize("NFKC", s)
|
||||
return re.sub(r"\s{2,}", " ", s.strip())
|
||||
|
||||
|
||||
def _parse_money(value: str) -> float | None:
|
||||
value = value.strip()
|
||||
if value == "-":
|
||||
return None
|
||||
return float(value.replace(".", "").replace(",", "."))
|
||||
|
||||
###############################################################################
|
||||
# Regex – Cabeçalhos #
|
||||
###############################################################################
|
||||
RX_MSISDN_HEADER = re.compile(r"Vantagens que seu plano oferece:\s*(?P<msisdn>\d{2}\s\d{5}-\d{4})", re.I)
|
||||
RX_MSISDN_SEU_NUM = re.compile(r"SEU\s+NÚMERO\s+TIM\s+(?P<msisdn>\d{2}\s\d{5}-\d{4})", re.I)
|
||||
RX_MSISDN_DETALHE = re.compile(r"Detalhamento de Serviços\s+N[°º]\s*(?P<msisdn>\d{2}\s\d{5}-\d{4})", re.I)
|
||||
|
||||
SECTION_HEADERS: Dict[str, str] = {
|
||||
"IGNORE Ilimitados": r"^Detalhamento de Serviços Ilimitados",
|
||||
"IGNORE Detalhamento": r"^Detalhamento de Serviç[io]s\b",
|
||||
"Fatura Resumo": r"^FATURA\s+RESUMO",
|
||||
"DANFE-COM": r"^DANFE-COM\b",
|
||||
"Plano": r"^Plano\b",
|
||||
"Mensalidades Adicionais": r"^MENSALIDADES\s+ADICIONAIS",
|
||||
"Itens Eventuais": r"^ITENS\s+EVENTUAIS",
|
||||
"TIM Viagem": r"^TIM\s+VIAGEM",
|
||||
"SVA Detalhe Total": r"^Serviços\s+de\s+Valor\s+Adicionado\s+Total",
|
||||
"Desconto Franquia": r"^Desconto\(s\)\s+Franquia",
|
||||
"Desconto SVA": r"^Desconto\(s\)\s+Serviç[io]s",
|
||||
"Franquia": r"^Franquia\s*\(s\)",
|
||||
"SVA": r"^Serviços\s+de\s+valor\s+adicionado\(SVA\)",
|
||||
"Chamadas Rede TIM": r"^CHAMADAS\s+DENTRO\s+DA\s+REDE\s+TIM",
|
||||
"Chamadas Fora Rede TIM": r"^CHAMADAS\s+FORA\s+DA\s+REDE\s+TIM",
|
||||
"Outros Valores": r"^OUTROS\s+VALORES",
|
||||
"Deduções": r"^DEDUÇÕES",
|
||||
"Roaming Internacional": r"^ROAMING\s+INTERNACIONAL",
|
||||
"Cobranças de Terceiros": r"^COBRANÇAS\s+DE\s+TERCEIROS",
|
||||
"Débitos de outras operadoras": r"^DÉBITOS\s+DE\s+OUTRAS\s+OPERADORAS"
|
||||
}
|
||||
SECTION_REGEX = {k: re.compile(v, re.I) for k, v in SECTION_HEADERS.items()}
|
||||
RX_SECTION_TOTAL = re.compile(r"(?:R\$\s*)?(-?[\d\.]+,\d{2})")
|
||||
|
||||
# Patterns que encerram a seção atual sem iniciar uma nova
|
||||
RX_SECTION_BREAK = re.compile(
|
||||
r"^(Nota Fiscal de Servi|SEUS\s+DADOS|ITEM\s+QTDE\s+ICMS|TOTAL\s+TIM|"
|
||||
r"Ficou\s+com\s+dúvidas|Bancos\s+Conveniados|Reservado\s+ao\s+fisco|"
|
||||
r"Tipo:\s+N\s+-\s+Normal)",
|
||||
re.I,
|
||||
)
|
||||
|
||||
###############################################################################
|
||||
# Regex – Linhas #
|
||||
###############################################################################
|
||||
RX_PERIOD = r"(?P<period>(?:\d{2}/\d{2}\s+a\s+\d{2}/\d{2}|-))"
|
||||
RX_DAYS = r"(?P<days>(?:\d+|-))"
|
||||
RX_VALUE = r"(?P<value>-?\d+,\d{2}|Incluído)"
|
||||
RX_PARCEL = r"(?P<parcel>(?:\d+/\d+|-))"
|
||||
|
||||
# --- Plano / Desconto / Chamadas (layout padrão: QTY DESC PARCELA PERIOD DIAS VALOR) ---
|
||||
RX_SIMPLE = re.compile(rf"^\s*(?P<qty>\d+)\s+(?P<desc>.+?)\s+{RX_PARCEL}\s+{RX_PERIOD}\s+{RX_DAYS}\s+{RX_VALUE}\s*$")
|
||||
RX_CONSUMPTION = re.compile(rf"^\s*(?P<qty>\d+)\s+(?P<desc>.+?)\s+{RX_PARCEL}\s+(?P<franchise>Ilimitado|-)\s+(?P<consumption>\d{{1,3}}m\d{{2}}s)\s+{RX_PERIOD}\s+{RX_DAYS}\s+{RX_VALUE}\s*$")
|
||||
RX_SUBTOTAL = re.compile(r"^Subtotal\s+(?P<value>-?\d+,\d{2})\s*$", re.I)
|
||||
RX_DETAIL = re.compile(r"^\d+\s+.+?\s+(-?\d+,\d{2})\s*$")
|
||||
# Fatura Resumo
|
||||
RX_RESUMO = re.compile(r"^\s*(?P<desc>.+?)\s+R\$\s*(?P<value>-?\d+,\d{2})\s*$")
|
||||
RX_TOTAL_GERAL = re.compile(r"^Total\s+geral\s+R\$\s*(?P<value>-?\d+,\d{2})", re.I)
|
||||
RX_FATURA_METADATA = re.compile(
|
||||
r"FATURA\s+PER[ÍI]ODO\s+EMISS[ÃA]O\s+POSTAGEM\s+"
|
||||
r"(?P<fatura>\S+)\s+"
|
||||
r"(?P<period>\d{2}/\d{2}\s+a\s+\d{2}/\d{2})\s+"
|
||||
r"(?P<emissao>\d{2}/\d{2}/\d{4})\s+"
|
||||
r"(?P<postagem>\d{2}/\d{2}/\d{4})",
|
||||
re.I,
|
||||
)
|
||||
RX_DANFE_TOTAL = re.compile(r"^Total\s+geral\s+R\$\s*(?P<value>-?\d+(?:\.\d{3})*,\d{2})", re.I)
|
||||
RX_DANFE_ROW = re.compile(
|
||||
r"^(?P<desc>.+?)\s+"
|
||||
r"(?P<unit>[A-Z]{2})\s+"
|
||||
r"(?P<qty>\d+(?:,\d+)?)\s+"
|
||||
r"(?P<preco_unit>-?\d+(?:\.\d{3})*,\d{2})\s+"
|
||||
r"(?P<pis_cofins>-?\d+(?:\.\d{3})*,\d{2}|-)\s+"
|
||||
r"(?P<bc_icms>-?\d+(?:\.\d{3})*,\d{2}|-)\s+"
|
||||
r"(?P<aliq_icms>\d+(?:,\d+)?%|-)\s+"
|
||||
r"(?P<icms>-?\d+(?:\.\d{3})*,\d{2}|-)\s+"
|
||||
r"(?P<value>-?\d+(?:\.\d{3})*,\d{2})$"
|
||||
)
|
||||
|
||||
# --- Itens Eventuais (layout: QTY DESC PARCELA FRANQUIA CONSUMO PERIODO DIAS VALOR) ---
|
||||
# Com consumo real (ex: 19,77GB) ou consumo zerado ("0")
|
||||
RX_EVENTUAIS = re.compile(
|
||||
r"^\s*(?P<qty>\d+)\s+(?P<desc>.+?)\s+-\s+-\s+"
|
||||
r"(?P<consumption>(?:\S+(?:GB|MB|KB)|0))\s+-\s+-\s+"
|
||||
r"(?P<value>-?\d+,\d{2})\s*$"
|
||||
)
|
||||
# Sem consumo (todos "-")
|
||||
RX_EVENTUAIS_NO_CONS = re.compile(
|
||||
r"^\s*(?P<qty>\d+)\s+(?P<desc>.+?)"
|
||||
r"(?:\s+-){5}\s+"
|
||||
r"(?P<value>-?\d+,\d{2})\s*$"
|
||||
)
|
||||
|
||||
# --- SVA Detalhe Total (layout: # DATA HORA ORIGEM DESC NUMERO TIPO PACOTE - - VALOR) ---
|
||||
# Exemplo: 1 09/04/25 - 02:54:18 RJ AREA 21 TIM Saude Mensal 00700001003511 N FP - - 14,99
|
||||
RX_SVA_DETAIL = re.compile(
|
||||
r"^\s*(?P<seq>\d+)\s+"
|
||||
r"(?P<date>\d{2}/\d{2}/\d{2})\s+-\s+(?P<time>\d{2}:\d{2}:\d{2})\s+"
|
||||
r"\S+\s+AREA\s+\d{2}\s+" # origem (ex: RJ AREA 21)
|
||||
r"(?P<desc>.+?)\s+"
|
||||
r"(?P<number>\d{10,})\s+"
|
||||
r"[A-Z/]+\s+[A-Z]+\s+"
|
||||
r"-\s+-\s+"
|
||||
r"(?P<value>-?\d+,\d{2})\s*$"
|
||||
)
|
||||
# Linha de total SVA (ex: "3 - 2 2 29,98")
|
||||
RX_SVA_SUMMARY = re.compile(
|
||||
r"^\s*(?P<seq>\d+)\s+-\s+\d+\s+\d+\s+(?P<value>-?\d+,\d{2})\s*$"
|
||||
)
|
||||
|
||||
# --- Informações Complementares (layout: QTY DESC PARCELA PERÍODO DIAS VALOR) ---
|
||||
RX_INFO_COMPL = re.compile(
|
||||
r"^\s*(?P<qty>\d+)\s+(?P<desc>.+?)\s+"
|
||||
r"(?P<parcel>(?:\d+/\d+|-))\s+"
|
||||
r"(?P<period>\d{2}/\d{2}\s+a\s+\d{2}/\d{2})\s+"
|
||||
r"(?P<days>\d+)\s+"
|
||||
r"(?P<value>-?\d+,\d{2})\s*$"
|
||||
)
|
||||
# --- Chamadas resumo (layout: QTY DESC PARCELA FRANQUIA CONSUMO PERIODO DIAS VALOR) ---
|
||||
RX_CHAMADAS = re.compile(
|
||||
r"^\s*(?P<qty>\d+)\s+(?P<desc>.+?)\s+-\s+-\s+"
|
||||
r"(?P<consumption>\d{1,3}m\d{2}s)\s+-\s+-\s+"
|
||||
r"(?P<value>-?\d+,\d{2})\s*$"
|
||||
)
|
||||
|
||||
# --- Mensalidades Adicionais (layout: QTY DESC PARCELA FRANQUIA CONSUMO PERIODO DIAS VALOR) ---
|
||||
# Ex.: "1 Apple Music SVA Mes - - 0 - - 21,40" (consumo "0" e sem período)
|
||||
# "1 Internet 30GB - - - 25/04 a 24/05 30 0,00"
|
||||
RX_MENSALIDADE = re.compile(
|
||||
rf"^\s*(?P<qty>\d+)\s+(?P<desc>.+?)\s+{RX_PARCEL}\s+"
|
||||
rf"(?P<franchise>Ilimitado|-)\s+(?P<consumption>\S+)\s+"
|
||||
rf"{RX_PERIOD}\s+{RX_DAYS}\s+{RX_VALUE}\s*$"
|
||||
)
|
||||
|
||||
###############################################################################
|
||||
# Parser #
|
||||
###############################################################################
|
||||
class TimBillParser:
|
||||
"""Extrai e estrutura faturas TIM."""
|
||||
|
||||
def __init__(self, *, x_tolerance: float = 1.5, y_tolerance: float = 3.0):
|
||||
self.x_tol = x_tolerance
|
||||
self.y_tol = y_tolerance
|
||||
self.data: Dict[str, pd.DataFrame] = {}
|
||||
|
||||
# ---------------- API pública ----------------
|
||||
def parse_pdf(self, pdf_bytes: io.BytesIO) -> Dict[str, pd.DataFrame]:
|
||||
|
||||
# 1. Extrai texto bruto de todas as páginas
|
||||
with pdfplumber.open(pdf_bytes) as pdf:
|
||||
raw_text = "\n".join(
|
||||
page.extract_text(x_tolerance=self.x_tol,
|
||||
y_tolerance=self.y_tol) or ""
|
||||
for page in pdf.pages
|
||||
)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 2. Captura o bloco FATURA RESUMO inteiro
|
||||
bloco_pat = re.compile(
|
||||
r"FATURA\s+RESUMO(?P<body>.*?)Total\s+geral\s+R\$\s*(?P<total>-?[\d\.,]+)",
|
||||
re.S | re.I,
|
||||
)
|
||||
resumo_items = []
|
||||
resumo_metadata = self._extract_fatura_metadata(raw_text)
|
||||
if resumo_metadata.get("period"):
|
||||
resumo_items.append(
|
||||
dict(qty=None, desc="PERÍODO", parcel=None, period=resumo_metadata["period"],
|
||||
days=None, value=None, franchise=None, consumption=None, msisdn=None,
|
||||
emissao=None, section_total=None)
|
||||
)
|
||||
if resumo_metadata.get("emissao"):
|
||||
resumo_items.append(
|
||||
dict(qty=None, desc="EMISSÃO", parcel=None, period=None,
|
||||
days=None, value=None, franchise=None, consumption=None, msisdn=None,
|
||||
emissao=resumo_metadata["emissao"], section_total=None)
|
||||
)
|
||||
m_resumo = bloco_pat.search(raw_text)
|
||||
if m_resumo:
|
||||
corpo = m_resumo.group("body")
|
||||
total_val = float(m_resumo.group("total").replace(".", "").replace(",", "."))
|
||||
for ln in corpo.splitlines():
|
||||
ln = _normalize_line(ln)
|
||||
if not ln:
|
||||
continue
|
||||
m_ln = re.match(r"(.+?)\s+R\$\s*([-\d\.,]+)", ln)
|
||||
if m_ln:
|
||||
resumo_items.append(
|
||||
dict(qty=None, desc=m_ln.group(1), parcel=None, period=None,
|
||||
days=None,
|
||||
value=float(m_ln.group(2).replace(".", "").replace(",", ".")),
|
||||
franchise=None, consumption=None, msisdn=None,
|
||||
section_total=None)
|
||||
)
|
||||
resumo_items.append(
|
||||
dict(qty=None, desc="Total geral", parcel=None, period=None,
|
||||
days=None, value=total_val, franchise=None, consumption=None,
|
||||
msisdn=None, section_total=None)
|
||||
)
|
||||
# 3. Remove o bloco para que não polua a etapa linha-a-linha
|
||||
raw_text = raw_text.replace(m_resumo.group(0), "")
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 4. Processa o restante normalmente
|
||||
dfs = self._parse_text(raw_text)
|
||||
if resumo_items:
|
||||
dfs["Fatura Resumo"] = pd.DataFrame(resumo_items)
|
||||
|
||||
self.data = dfs
|
||||
return dfs
|
||||
|
||||
# ---------------- interno -------------------
|
||||
def _extract_fatura_metadata(self, text: str) -> dict[str, str]:
|
||||
normalized_lines = [_normalize_line(line) for line in text.splitlines()]
|
||||
normalized_text = " ".join(line for line in normalized_lines if line)
|
||||
if m := RX_FATURA_METADATA.search(normalized_text):
|
||||
return {
|
||||
"period": m.group("period"),
|
||||
"emissao": m.group("emissao"),
|
||||
}
|
||||
return {}
|
||||
|
||||
def _parse_text(self, text: str) -> Dict[str, pd.DataFrame]:
|
||||
"""Quebra o texto extraído em DataFrames por seção."""
|
||||
buf: Dict[str, list] = {k: [] for k in SECTION_HEADERS if not k.startswith("IGNORE")}
|
||||
current_sec = current_msisdn = None
|
||||
current_total: float | None = None
|
||||
last_sva_detail_item: dict | None = None
|
||||
|
||||
for raw in text.splitlines():
|
||||
line = _normalize_line(raw)
|
||||
if not line:
|
||||
continue
|
||||
|
||||
# cabeçalho de MSISDN (3 variantes)
|
||||
for rx_ms in (RX_MSISDN_HEADER, RX_MSISDN_SEU_NUM, RX_MSISDN_DETALHE):
|
||||
if (m := rx_ms.search(line)):
|
||||
current_msisdn = m.group("msisdn").replace(" ", "")
|
||||
break
|
||||
else:
|
||||
m = None
|
||||
if m:
|
||||
continue
|
||||
|
||||
# section break — encerra seção atual sem iniciar outra
|
||||
if RX_SECTION_BREAK.match(line):
|
||||
current_sec = None
|
||||
continue
|
||||
|
||||
# detecta novo cabeçalho (a menos que estejamos em Fatura Resumo)
|
||||
if current_sec != "Fatura Resumo":
|
||||
sec = next((s for s, rx in SECTION_REGEX.items() if rx.match(line)), None)
|
||||
else:
|
||||
sec = None
|
||||
|
||||
if (sec in ("IGNORE Ilimitados", "IGNORE Detalhamento")):
|
||||
current_sec = None
|
||||
continue
|
||||
if sec is not None:
|
||||
current_sec = sec
|
||||
last_sva_detail_item = None
|
||||
mt = RX_SECTION_TOTAL.search(line)
|
||||
current_total = float(mt.group(1).replace(".", "").replace(",", ".")) if mt else None
|
||||
continue
|
||||
|
||||
if not current_sec:
|
||||
if line[0].isdigit():
|
||||
item = self._parse_sva_detail(line)
|
||||
if item:
|
||||
current_sec = "SVA Detalhe Total"
|
||||
current_total = None
|
||||
item["msisdn"] = current_msisdn
|
||||
item["section_total"] = None
|
||||
buf.setdefault(current_sec, []).append(item)
|
||||
last_sva_detail_item = item
|
||||
continue
|
||||
|
||||
# pula cabeçalhos de página
|
||||
if re.match(r"^Página\s+\d+\s+de\s+\d+", line, re.I):
|
||||
continue
|
||||
|
||||
if current_sec == "DANFE-COM":
|
||||
item = self._parse_danfe(line)
|
||||
if item:
|
||||
item["msisdn"] = None
|
||||
item["section_total"] = current_total
|
||||
buf.setdefault(current_sec, []).append(item)
|
||||
if item.get("is_total"):
|
||||
current_sec = None
|
||||
continue
|
||||
|
||||
if current_sec == "SVA Detalhe Total":
|
||||
if self._is_sva_header_line(line):
|
||||
continue
|
||||
if RX_SVA_SUMMARY.match(line):
|
||||
last_sva_detail_item = None
|
||||
continue
|
||||
if line[0].isdigit():
|
||||
item = self._parse_sva_detail(line)
|
||||
if item:
|
||||
item["msisdn"] = current_msisdn
|
||||
item["section_total"] = current_total
|
||||
buf.setdefault(current_sec, []).append(item)
|
||||
last_sva_detail_item = item
|
||||
continue
|
||||
if last_sva_detail_item:
|
||||
last_sva_detail_item["desc"] = (
|
||||
f"{last_sva_detail_item['desc']} {line}"
|
||||
).strip()
|
||||
continue
|
||||
|
||||
# -------- linhas padrão --------
|
||||
if not (line[0].isdigit() or line.lower().startswith("subtotal")):
|
||||
continue
|
||||
|
||||
item = self._parse_item_line(line, current_sec)
|
||||
if item:
|
||||
item["msisdn"] = current_msisdn
|
||||
item["section_total"] = current_total
|
||||
buf.setdefault(current_sec, []).append(item)
|
||||
|
||||
return {s: pd.DataFrame(lst) for s, lst in buf.items() if lst}
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
def _parse_item_line(self, line: str, section: str = "") -> dict | None:
|
||||
"""Parseia uma linha de item de acordo com a seção."""
|
||||
|
||||
# Ignora subtotal
|
||||
if RX_SUBTOTAL.match(line):
|
||||
return None
|
||||
|
||||
# ── Mensalidades Adicionais ──
|
||||
if section == "Mensalidades Adicionais":
|
||||
return self._parse_mensalidades(line)
|
||||
|
||||
# ── Itens Eventuais ──
|
||||
if section == "Itens Eventuais":
|
||||
return self._parse_eventuais(line)
|
||||
|
||||
# ── SVA Detalhe Total ──
|
||||
if section == "SVA Detalhe Total":
|
||||
return self._parse_sva_detail(line)
|
||||
|
||||
# ── DANFE-COM ──
|
||||
if section == "DANFE-COM":
|
||||
return self._parse_danfe(line)
|
||||
|
||||
# ── Chamadas Rede TIM / Chamadas Fora Rede TIM ──
|
||||
if section.startswith("Chamadas"):
|
||||
return self._parse_chamadas(line)
|
||||
|
||||
# ── Plano / Franquia / SVA / Desconto Franquia / Desconto SVA (layout padrão) ──
|
||||
return self._parse_standard(line)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
def _parse_danfe(self, line: str) -> dict | None:
|
||||
"""DANFE-COM: linhas da tabela de itens e total geral."""
|
||||
if line.upper().startswith("ITENS "):
|
||||
return None
|
||||
|
||||
if (m := RX_DANFE_TOTAL.match(line)):
|
||||
return {
|
||||
"desc": "Total geral",
|
||||
"unit": None,
|
||||
"qty": None,
|
||||
"preco_unit": None,
|
||||
"pis_cofins": None,
|
||||
"bc_icms": None,
|
||||
"aliq_icms": None,
|
||||
"icms": None,
|
||||
"value": _parse_money(m.group("value")),
|
||||
"is_total": True,
|
||||
}
|
||||
|
||||
if (m := RX_DANFE_ROW.match(line)):
|
||||
d = m.groupdict()
|
||||
return {
|
||||
"desc": d["desc"].strip(),
|
||||
"unit": d["unit"],
|
||||
"qty": float(d["qty"].replace(",", ".")),
|
||||
"preco_unit": _parse_money(d["preco_unit"]),
|
||||
"pis_cofins": _parse_money(d["pis_cofins"]),
|
||||
"bc_icms": _parse_money(d["bc_icms"]),
|
||||
"aliq_icms": None if d["aliq_icms"] == "-" else d["aliq_icms"],
|
||||
"icms": _parse_money(d["icms"]),
|
||||
"value": _parse_money(d["value"]),
|
||||
"is_total": False,
|
||||
}
|
||||
|
||||
return None
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
def _parse_standard(self, line: str) -> dict | None:
|
||||
"""Regex padrão: QTY DESC PARCELA [FRANCHISE CONSUMPTION] PERIOD DAYS VALUE."""
|
||||
for rx in (RX_CONSUMPTION, RX_SIMPLE):
|
||||
if (m := rx.match(line)):
|
||||
d = m.groupdict()
|
||||
|
||||
# --- corrige parcel dentro de desc (caso d["parcel"] == "-") ----
|
||||
if d["parcel"] == "-":
|
||||
tail = re.search(r"\b(\d+/\d+)$", d["desc"])
|
||||
if tail:
|
||||
d["parcel"] = tail.group(1)
|
||||
d["desc"] = d["desc"][: tail.start()].rstrip(" -")
|
||||
# ----------------------------------------------------------------
|
||||
|
||||
# limpa trailing dashes e valores de franquia residuais do desc
|
||||
d["desc"] = re.sub(r"(\s+-\s+\d+(?:GB|MB|KB))(?:\s+-)*\s*$|(?:\s+-)+\s*$", "", d["desc"]).strip()
|
||||
|
||||
value = d["value"]
|
||||
d["qty"] = float(d["qty"])
|
||||
d["days"] = None if d.get("days") in (None, "-") else int(d["days"])
|
||||
d["_is_included_value"] = value == "Incluído"
|
||||
d["value"] = 0.0 if d["_is_included_value"] else float(value.replace(",", "."))
|
||||
return d
|
||||
return None
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
def _parse_eventuais(self, line: str) -> dict | None:
|
||||
"""Itens Eventuais: QTY DESC PARCELA FRANQUIA CONSUMO PERIODO DIAS VALUE."""
|
||||
# Tenta com consumo real (ex: 19,77GB)
|
||||
if (m := RX_EVENTUAIS.match(line)):
|
||||
return {
|
||||
"qty": float(m.group("qty")),
|
||||
"desc": m.group("desc").strip(),
|
||||
"parcel": None,
|
||||
"period": None,
|
||||
"days": None,
|
||||
"value": float(m.group("value").replace(",", ".")),
|
||||
"franchise": None,
|
||||
"consumption": None if m.group("consumption") == "0" else m.group("consumption"),
|
||||
}
|
||||
# Tenta sem consumo (todos "-")
|
||||
if (m := RX_EVENTUAIS_NO_CONS.match(line)):
|
||||
return {
|
||||
"qty": float(m.group("qty")),
|
||||
"desc": m.group("desc").strip(),
|
||||
"parcel": None,
|
||||
"period": None,
|
||||
"days": None,
|
||||
"value": float(m.group("value").replace(",", ".")),
|
||||
"franchise": None,
|
||||
"consumption": None,
|
||||
}
|
||||
return None
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
def _parse_mensalidades(self, line: str) -> dict | None:
|
||||
"""Mensalidades Adicionais: QTY DESC PARCELA FRANQUIA CONSUMO PERIODO DIAS VALOR."""
|
||||
if (m := RX_MENSALIDADE.match(line)):
|
||||
d = m.groupdict()
|
||||
d["qty"] = float(d["qty"])
|
||||
d["desc"] = d["desc"].strip()
|
||||
d["parcel"] = None if d["parcel"] == "-" else d["parcel"]
|
||||
d["franchise"] = None if d["franchise"] == "-" else d["franchise"]
|
||||
d["consumption"] = None if d["consumption"] in ("-", "0") else d["consumption"]
|
||||
d["period"] = None if d["period"] == "-" else d["period"]
|
||||
d["days"] = None if d["days"] == "-" else int(d["days"])
|
||||
value = d["value"]
|
||||
d["_is_included_value"] = value == "Incluído"
|
||||
d["value"] = 0.0 if d["_is_included_value"] else float(value.replace(",", "."))
|
||||
return d
|
||||
# Layout de 6 colunas (sem franquia/consumo) → regex padrão
|
||||
return self._parse_standard(line)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
def _is_sva_header_line(self, line: str) -> bool:
|
||||
return bool(
|
||||
re.match(r"^(DURAÇÃO/VOLUME|#\s+DATA\s*/\s*HORA)\b", line, re.I)
|
||||
)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
def _parse_sva_detail(self, line: str) -> dict | None:
|
||||
"""SVA Detalhe Total: linhas com data/hora e linha de totalização."""
|
||||
# Linha de detalhe com data/hora
|
||||
if (m := RX_SVA_DETAIL.match(line)):
|
||||
return {
|
||||
"qty": 1.0,
|
||||
"desc": m.group("desc").strip(),
|
||||
"parcel": None,
|
||||
"period": m.group("date"),
|
||||
"days": None,
|
||||
"value": float(m.group("value").replace(",", ".")),
|
||||
"franchise": None,
|
||||
"consumption": None,
|
||||
}
|
||||
return None
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
def _parse_chamadas(self, line: str) -> dict | None:
|
||||
"""Chamadas Rede TIM / Fora Rede: QTY DESC - - CONSUMPTION - - VALUE."""
|
||||
if (m := RX_CHAMADAS.match(line)):
|
||||
return {
|
||||
"qty": float(m.group("qty")),
|
||||
"desc": m.group("desc").strip(),
|
||||
"parcel": None,
|
||||
"period": None,
|
||||
"days": None,
|
||||
"value": float(m.group("value").replace(",", ".")),
|
||||
"franchise": None,
|
||||
"consumption": m.group("consumption"),
|
||||
}
|
||||
# Fallback para o padrão (algumas chamadas usam formato padrão)
|
||||
return self._parse_standard(line)
|
||||
743
app/domain/contas/parsers/bill_processor.py
Normal file
743
app/domain/contas/parsers/bill_processor.py
Normal file
@@ -0,0 +1,743 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import unicodedata as ud
|
||||
from typing import Any, Dict, List, Tuple
|
||||
|
||||
import pandas as pd
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Regex para linha de VAS
|
||||
# ---------------------------------------------------------------------------
|
||||
RX_VAS_LINE = re.compile(
|
||||
r"""
|
||||
^\d+\s+ # índice "#"
|
||||
(?P<date>\d{2}/\d{2}/\d{2}) # DD/MM/YY
|
||||
\s+-\s+\d{2}:\d{2}:\d{2}\s+ # " - HH:MM:SS "
|
||||
[A-Z]{2}\sAREA\s\d+\s+ # "SP AREA 11"
|
||||
(?P<name>.+?)\s+ # nome do serviço
|
||||
\d{8,} # número chamado
|
||||
""",
|
||||
re.X,
|
||||
)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
class PDFBillingProcessor:
|
||||
"""
|
||||
Pós-processamento dos DataFrames gerados pelo TimBillParser.
|
||||
"""
|
||||
|
||||
SECTIONS = [
|
||||
"Fatura Resumo",
|
||||
"Plano",
|
||||
"DANFE-COM",
|
||||
"Descontos",
|
||||
"Itens Eventuais",
|
||||
"Mensalidades Adicionais",
|
||||
"TIM Viagem",
|
||||
"Outros Valores",
|
||||
"Chamadas Rede TIM",
|
||||
"SVA Detalhe Total",
|
||||
"Serviços Bundle Inclusos",
|
||||
"Deduções",
|
||||
"Roaming Internacional",
|
||||
"Cobranças de Terceiros",
|
||||
"Débitos de outras operadoras",
|
||||
]
|
||||
STRATEGIC_SERVICE_SECTIONS = {
|
||||
"SVA Detalhe Total",
|
||||
"Itens Eventuais",
|
||||
"Serviços Bundle Inclusos",
|
||||
"Mensalidades Adicionais",
|
||||
"Outros Valores",
|
||||
"Cobranças de Terceiros",
|
||||
}
|
||||
SECTION_CLASS_MAP = {
|
||||
"SVA Detalhe Total": "avulso",
|
||||
"Itens Eventuais": "avulso",
|
||||
"Serviços Bundle Inclusos": "bundle",
|
||||
}
|
||||
CLASSE_VERB_MAP = {
|
||||
"avulso": "cancelar",
|
||||
"bundle": "falar sobre",
|
||||
"estrategico": "falar sobre",
|
||||
}
|
||||
STRATEGIC_SERVICES = [
|
||||
"Apple Music",
|
||||
"Deezer",
|
||||
"Disney",
|
||||
"Fuze",
|
||||
"Forge",
|
||||
"HBO",
|
||||
"Looke",
|
||||
"Max Mensal",
|
||||
"Netflix",
|
||||
"Paramount",
|
||||
"TIM Cloud Gaming",
|
||||
"YouTube",
|
||||
"Globoplay",
|
||||
"Amazon Prime"
|
||||
]
|
||||
|
||||
DIGIT_WORDS = {
|
||||
"0": "zero",
|
||||
"1": "um",
|
||||
"2": "dois",
|
||||
"3": "tres",
|
||||
"4": "quatro",
|
||||
"5": "cinco",
|
||||
"6": "seis",
|
||||
"7": "sete",
|
||||
"8": "oito",
|
||||
"9": "nove",
|
||||
}
|
||||
|
||||
def __init__(self, *, include_danfe: bool = False):
|
||||
self.include_danfe = include_danfe
|
||||
self._strategic_service_patterns = [
|
||||
re.compile(rf"(?:^|\s){re.escape(self._service_match_key(service))}(?:\s|$)")
|
||||
for service in self.STRATEGIC_SERVICES
|
||||
]
|
||||
|
||||
def remove_parentheses(self, text: str) -> str:
|
||||
return re.sub(r'\s*\([^)]*\)', '', str(text)).strip()
|
||||
|
||||
def remove_plan_recognition_marker(self, text: str) -> str:
|
||||
return re.sub(
|
||||
r"\s*\(\s*\d+\s*/\s*P[ÓO]S\s*/\s*SMP\s*\)",
|
||||
"",
|
||||
str(text),
|
||||
flags=re.I,
|
||||
).strip()
|
||||
|
||||
def _format_plan_version_number(self, text: str) -> str:
|
||||
return re.sub(r"(?<!\d)(\d)\s+(\d)(?!\d)", r"\1.\2", str(text))
|
||||
|
||||
def _format_msisdn(self, text: Any) -> str:
|
||||
return re.sub(r"\D", "", str(text))
|
||||
|
||||
def _vocalize_digits(self, digits: str) -> str:
|
||||
return " ".join(self.DIGIT_WORDS[d] for d in digits if d in self.DIGIT_WORDS)
|
||||
|
||||
def _collect_msisdns(self, node: Any, found: set) -> None:
|
||||
"""Percorre recursivamente o payload coletando todos os msisdn presentes."""
|
||||
if isinstance(node, dict):
|
||||
for key, value in node.items():
|
||||
if key == "msisdn" and value is not None:
|
||||
msisdn = self._format_msisdn(value)
|
||||
if msisdn:
|
||||
found.add(msisdn)
|
||||
else:
|
||||
self._collect_msisdns(value, found)
|
||||
elif isinstance(node, list):
|
||||
for item in node:
|
||||
self._collect_msisdns(item, found)
|
||||
|
||||
def _build_vocalized_msisdn(self, final_output: Dict[str, Any]) -> Dict[str, str]:
|
||||
"""Mapeia cada msisdn da fatura para a vocalização dos seus 4 últimos dígitos."""
|
||||
found: set = set()
|
||||
for key, value in final_output.items():
|
||||
if re.fullmatch(r"\d+", str(key)):
|
||||
found.add(self._format_msisdn(key))
|
||||
self._collect_msisdns(value, found)
|
||||
|
||||
return {
|
||||
msisdn: self._vocalize_digits(msisdn[-4:])
|
||||
for msisdn in sorted(found)
|
||||
}
|
||||
|
||||
def _clean_plan_name(self, desc: str, *, format_version_number: bool = True) -> str:
|
||||
text = self.remove_parentheses(self.remove_plan_recognition_marker(desc))
|
||||
if format_version_number:
|
||||
text = self._format_plan_version_number(text)
|
||||
return text
|
||||
|
||||
def _discount_match_key(self, desc: str) -> str:
|
||||
text = self._clean_plan_name(desc)
|
||||
text = ud.normalize("NFKD", text)
|
||||
text = "".join(ch for ch in text if not ud.combining(ch))
|
||||
text = text.casefold()
|
||||
text = re.sub(r"\b\d+/\d+\b", " ", text)
|
||||
text = re.sub(r"\b\d+\b", " ", text)
|
||||
text = re.sub(r"[^a-z]+", " ", text)
|
||||
return re.sub(r"\s+", " ", text).strip()
|
||||
|
||||
def _service_match_key(self, desc: str) -> str:
|
||||
text = ud.normalize("NFKD", str(desc))
|
||||
text = "".join(ch for ch in text if not ud.combining(ch))
|
||||
text = text.casefold()
|
||||
text = re.sub(r"[^a-z0-9]+", " ", text)
|
||||
return re.sub(r"\s+", " ", text).strip()
|
||||
|
||||
def _is_strategic_service(self, desc: Any) -> bool:
|
||||
desc_key = self._service_match_key(str(desc or ""))
|
||||
return any(pattern.search(desc_key) for pattern in self._strategic_service_patterns)
|
||||
|
||||
def _is_controle_plan(self, desc: Any) -> bool:
|
||||
desc_key = self._service_match_key(str(desc or ""))
|
||||
return bool(re.search(r"\b(?:controle|ctrl|crtl)\b", desc_key))
|
||||
|
||||
def calculate_discount(self, desc_match: str, msisdn: str, descontos_df: pd.DataFrame) -> float:
|
||||
# Filtra por msisdn e verifica se o desc_match está contido no desc
|
||||
descontos_filtrados = descontos_df[
|
||||
(descontos_df['msisdn'] == msisdn) &
|
||||
(descontos_df['desc'].str.lower().str.contains(desc_match.lower(), na=False))
|
||||
]
|
||||
return descontos_filtrados['value'].sum()
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
def normalize(self, raw: Dict[str, pd.DataFrame]) -> Dict[str, Any]:
|
||||
all_msisdns = set()
|
||||
for sec, df in raw.items():
|
||||
if sec != "Fatura Resumo" and "msisdn" in df.columns:
|
||||
all_msisdns.update(df["msisdn"].dropna().astype(str).unique())
|
||||
|
||||
final_output: Dict[str, Any] = {}
|
||||
|
||||
# Processa Fatura Resumo primeiro (independente de MSISDN)
|
||||
if "Fatura Resumo" in raw:
|
||||
fatura_raw = {"Fatura Resumo": raw["Fatura Resumo"]}
|
||||
fatura_dfs = self._clean_dataframes(fatura_raw)
|
||||
fatura_json = self._build_final_json(fatura_dfs)
|
||||
if "Fatura Resumo" in fatura_json:
|
||||
final_output["Fatura Resumo"] = fatura_json["Fatura Resumo"]
|
||||
|
||||
if self.include_danfe and "DANFE-COM" in raw:
|
||||
danfe_json = self._build_danfe_payload(raw["DANFE-COM"], raw)
|
||||
if danfe_json:
|
||||
final_output["DANFE-COM"] = danfe_json
|
||||
|
||||
if not all_msisdns:
|
||||
# Se não houver MSISDNs, processa tudo como global
|
||||
dfs = self._clean_dataframes(raw)
|
||||
dfs = self._process_plano(dfs)
|
||||
dfs = self._process_eventuais(dfs)
|
||||
dfs = self._process_vas(dfs)
|
||||
dfs = self._process_descontos(dfs)
|
||||
fallback_json = self._build_final_json(dfs)
|
||||
for k, v in fallback_json.items():
|
||||
if k not in final_output:
|
||||
final_output[k] = v
|
||||
final_output["vocalized_msisdn"] = self._build_vocalized_msisdn(final_output)
|
||||
return final_output
|
||||
|
||||
for msisdn in all_msisdns:
|
||||
msisdn_raw = {}
|
||||
for sec, df in raw.items():
|
||||
if sec == "Fatura Resumo":
|
||||
continue
|
||||
if "msisdn" in df.columns:
|
||||
mask = df["msisdn"].astype(str) == msisdn
|
||||
filtered_df = df[mask].copy()
|
||||
if not filtered_df.empty:
|
||||
msisdn_raw[sec] = filtered_df
|
||||
else:
|
||||
# Seções sem MSISDN podem ser globais? (Ex: Fatura Resumo já tratada)
|
||||
pass
|
||||
|
||||
if msisdn_raw:
|
||||
dfs = self._clean_dataframes(msisdn_raw)
|
||||
dfs = self._process_plano(dfs)
|
||||
dfs = self._process_eventuais(dfs)
|
||||
dfs = self._process_vas(dfs)
|
||||
dfs = self._process_descontos(dfs)
|
||||
msisdn_json = self._build_final_json(dfs)
|
||||
|
||||
if msisdn_json:
|
||||
final_output[self._format_msisdn(msisdn)] = msisdn_json
|
||||
|
||||
final_output["vocalized_msisdn"] = self._build_vocalized_msisdn(final_output)
|
||||
return final_output
|
||||
|
||||
def _clean_dataframes(self, raw: Dict[str, pd.DataFrame]) -> Dict[str, pd.DataFrame]:
|
||||
# 1) remove value == 0 exceto de fatura resumo e plano (que pode ter o bundle)
|
||||
dfs = {
|
||||
sec: (df if sec in ["Fatura Resumo", "Plano"] else df[df.value != 0.0]).copy()
|
||||
for sec, df in raw.items() if "value" in df.columns
|
||||
}
|
||||
# 2) drop colunas totalmente NaN
|
||||
dfs = {
|
||||
sec: df.dropna(axis=1, how="all")
|
||||
for sec, df in dfs.items() if not df.dropna(axis=1, how="all").empty
|
||||
}
|
||||
# 3) remove sufixo " - -" do campo desc
|
||||
for df in dfs.values():
|
||||
if "desc" in df.columns:
|
||||
df["desc"] = df["desc"].str.replace(r"\s*- -\s*$", "", regex=True)
|
||||
df["desc"] = df["desc"].str.strip()
|
||||
return dfs
|
||||
|
||||
def _process_plano(self, dfs: Dict[str, pd.DataFrame]) -> Dict[str, pd.DataFrame]:
|
||||
if "Plano" not in dfs:
|
||||
return dfs
|
||||
|
||||
plano_df = dfs["Plano"]
|
||||
if 'msisdn' in plano_df.columns:
|
||||
plano_df['msisdn'] = plano_df['msisdn'].astype(str)
|
||||
|
||||
# Separa somente itens que vieram textualmente como "Incluído".
|
||||
# Planos dependentes podem aparecer como R$ 0,00 e continuam sendo Plano.
|
||||
if "_is_included_value" in plano_df.columns:
|
||||
included_mask = plano_df["_is_included_value"].fillna(False).astype(bool)
|
||||
else:
|
||||
included_mask = plano_df.value == 0.0
|
||||
bundle_df = plano_df[included_mask].copy()
|
||||
plano_df = plano_df[~included_mask].copy()
|
||||
|
||||
if not bundle_df.empty:
|
||||
bundle_df["value"] = "Incluído"
|
||||
if "section_total" in bundle_df.columns:
|
||||
bundle_df = bundle_df.drop(columns=["section_total"])
|
||||
dfs["Serviços Bundle Inclusos"] = bundle_df
|
||||
|
||||
# Seleciona planos principais
|
||||
mask_plan = plano_df["desc"].str.contains(r"PÓS/SMP", case=False, na=False)
|
||||
if mask_plan.sum() == 0:
|
||||
mask_plan = plano_df["desc"].str.match(r"(?i)^tim", case=False)
|
||||
|
||||
# Seleciona descontos
|
||||
descontos_df = plano_df[plano_df['desc'].apply(lambda x: 'desc' in str(x).lower() and 'tim' in str(x).lower())]
|
||||
plano_df = plano_df[mask_plan].copy()
|
||||
plano_df["desc"] = plano_df["desc"].apply(self.remove_plan_recognition_marker)
|
||||
|
||||
dfs["_descontos_temp"] = descontos_df
|
||||
|
||||
if plano_df.empty:
|
||||
dfs.pop("Plano", None)
|
||||
else:
|
||||
dfs["Plano"] = plano_df
|
||||
|
||||
return dfs
|
||||
|
||||
def _process_eventuais(self, dfs: Dict[str, pd.DataFrame]) -> Dict[str, pd.DataFrame]:
|
||||
if "Itens Eventuais" in dfs:
|
||||
ie_df = dfs["Itens Eventuais"]
|
||||
mask_remove = (ie_df["value"] == 0) | (ie_df["desc"].str.contains("Serviços de Valor Adicionado Conteúdo", case=False, na=False))
|
||||
ie_df = ie_df[~mask_remove].copy()
|
||||
|
||||
if ie_df.empty:
|
||||
dfs.pop("Itens Eventuais")
|
||||
else:
|
||||
dfs["Itens Eventuais"] = ie_df
|
||||
return dfs
|
||||
|
||||
def _process_vas(self, dfs: Dict[str, pd.DataFrame]) -> Dict[str, pd.DataFrame]:
|
||||
"""Remove de 'Mensalidades Adicionais' os serviços recorrentes que já vêm
|
||||
detalhados (com data de ativação) em 'SVA Detalhe Total', evitando duplicata.
|
||||
|
||||
A seção 'Serviços de Valor Adicionado Total' do PDF é o detalhamento que
|
||||
reúne tanto os itens eventuais quanto as mensalidades recorrentes; quando o
|
||||
mesmo serviço aparece nas duas seções, mantemos a cópia detalhada do SVA."""
|
||||
sva_df = dfs.get("SVA Detalhe Total")
|
||||
if sva_df is None or sva_df.empty or "desc" not in sva_df.columns:
|
||||
return dfs
|
||||
|
||||
detalhados = {
|
||||
(self._format_msisdn(rec.get("msisdn")), self._service_match_key(rec.get("desc")))
|
||||
for rec in sva_df.to_dict(orient="records")
|
||||
}
|
||||
|
||||
for section in ("Mensalidades Adicionais", "Itens Eventuais"):
|
||||
df = dfs.get(section)
|
||||
if df is None or df.empty or "desc" not in df.columns:
|
||||
continue
|
||||
|
||||
mask_dup = df.apply(
|
||||
lambda r: (
|
||||
self._format_msisdn(r.get("msisdn")),
|
||||
self._service_match_key(r.get("desc")),
|
||||
) in detalhados,
|
||||
axis=1,
|
||||
)
|
||||
if mask_dup.any():
|
||||
df = df[~mask_dup].copy()
|
||||
if df.empty:
|
||||
dfs.pop(section, None)
|
||||
else:
|
||||
dfs[section] = df
|
||||
return dfs
|
||||
|
||||
def _process_descontos(self, dfs: Dict[str, pd.DataFrame]) -> Dict[str, pd.DataFrame]:
|
||||
if "_descontos_temp" in dfs:
|
||||
descontos_df = dfs.pop("_descontos_temp")
|
||||
if not descontos_df.empty:
|
||||
if "section_total" in descontos_df.columns:
|
||||
descontos_df = descontos_df.drop(columns=["section_total"])
|
||||
descontos_df['installment'] = descontos_df['desc'].str.extract(r'(\d+/\d+)')
|
||||
descontos_df['desc'] = descontos_df['desc'].apply(self._format_plan_version_number)
|
||||
if "Descontos" in dfs and not dfs["Descontos"].empty:
|
||||
dfs["Descontos"] = pd.concat(
|
||||
[dfs["Descontos"], descontos_df],
|
||||
ignore_index=True,
|
||||
)
|
||||
else:
|
||||
dfs["Descontos"] = descontos_df
|
||||
return dfs
|
||||
|
||||
def _clean_record(self, rec: Dict[str, Any]) -> Dict[str, Any]:
|
||||
redundant_values = {
|
||||
"qty": [1.0, 1],
|
||||
"parcel": ["-", None],
|
||||
"franchise": [None, "null", "-"],
|
||||
"consumption": [None, "null"],
|
||||
"period": ["-", None],
|
||||
"value": [None],
|
||||
"emissao": [None, "null", "-"],
|
||||
}
|
||||
|
||||
cleaned_rec = {}
|
||||
for k, v in rec.items():
|
||||
if k == "section_total" or k.startswith("_"):
|
||||
continue
|
||||
if k == "msisdn":
|
||||
v = self._format_msisdn(v)
|
||||
if pd.isna(v):
|
||||
v = None
|
||||
elif k == "value" and isinstance(v, (int, float)):
|
||||
v = self._round_money(v)
|
||||
elif k == "days" and isinstance(v, float) and v.is_integer():
|
||||
v = int(v)
|
||||
if k in redundant_values and v in redundant_values[k]:
|
||||
continue
|
||||
cleaned_rec[k] = v
|
||||
return cleaned_rec
|
||||
|
||||
def _records_for_section(self, df: pd.DataFrame) -> List[Dict[str, Any]]:
|
||||
return [
|
||||
self._clean_record(rec)
|
||||
for rec in df.to_dict(orient="records")
|
||||
]
|
||||
|
||||
def _split_strategic_records(
|
||||
self,
|
||||
df: pd.DataFrame,
|
||||
section: str,
|
||||
) -> Tuple[List[Dict[str, Any]], List[Dict[str, Any]]]:
|
||||
records = self._records_for_section(df)
|
||||
if section not in self.STRATEGIC_SERVICE_SECTIONS:
|
||||
return records, []
|
||||
|
||||
regular_records = []
|
||||
strategic_records = []
|
||||
for record in records:
|
||||
if "desc" in record and self._is_strategic_service(record["desc"]):
|
||||
record["estrategico"] = True
|
||||
strategic_records.append(record)
|
||||
else:
|
||||
regular_records.append(record)
|
||||
return regular_records, strategic_records
|
||||
|
||||
def _annotate_class(
|
||||
self,
|
||||
records: List[Dict[str, Any]],
|
||||
section: str,
|
||||
) -> List[Dict[str, Any]]:
|
||||
section_classe = self.SECTION_CLASS_MAP.get(section)
|
||||
if not section_classe:
|
||||
return records
|
||||
for rec in records:
|
||||
# Itens estratégicos dobrados nesta seção (commit 7fc38a4e) não
|
||||
# herdam a classe da seção (avulso): mantêm classe=estrategico e
|
||||
# verbo "falar sobre". Os demais seguem o default da seção.
|
||||
classe = "estrategico" if rec.get("estrategico") is True else section_classe
|
||||
rec["classe"] = classe
|
||||
verb = self.CLASSE_VERB_MAP.get(classe)
|
||||
if verb:
|
||||
rec["verb"] = verb
|
||||
return records
|
||||
|
||||
def _round_money(self, value: float) -> float:
|
||||
return round(float(value) + 0.0, 2)
|
||||
|
||||
def _is_discount_desc(self, desc: str) -> bool:
|
||||
return bool(re.match(r"(?i)^\s*desc(?:onto)?\b", desc))
|
||||
|
||||
def _danfe_record(self, rec: Dict[str, Any]) -> Dict[str, Any]:
|
||||
cleaned = {}
|
||||
for k, v in rec.items():
|
||||
if k in {"section_total", "msisdn", "is_total"}:
|
||||
continue
|
||||
if pd.isna(v):
|
||||
v = None
|
||||
elif k in {"preco_unit", "pis_cofins", "bc_icms", "icms", "value"} and isinstance(v, (int, float)):
|
||||
v = self._round_money(v)
|
||||
elif k == "qty" and isinstance(v, float) and v.is_integer():
|
||||
v = int(v)
|
||||
cleaned[k] = v
|
||||
return cleaned
|
||||
|
||||
def _danfe_item_payload(self, rec: Dict[str, Any]) -> Dict[str, Any]:
|
||||
item = self._danfe_record(rec)
|
||||
valor_bruto = self._round_money(item.pop("value", 0.0) or 0.0)
|
||||
item["valor_bruto"] = valor_bruto
|
||||
item["total_descontos"] = 0.0
|
||||
item["valor_final"] = valor_bruto
|
||||
item["descontos"] = []
|
||||
return item
|
||||
|
||||
def _danfe_discount_payload(self, rec: Dict[str, Any]) -> Dict[str, Any]:
|
||||
discount = self._danfe_record(rec)
|
||||
if "value" in discount and discount["value"] is not None:
|
||||
discount["value"] = self._round_money(discount["value"])
|
||||
return discount
|
||||
|
||||
def _desc_matches_key(self, desc_key: str, target_key: str) -> bool:
|
||||
return bool(
|
||||
desc_key
|
||||
and target_key
|
||||
and (desc_key == target_key or desc_key.startswith(target_key) or target_key.startswith(desc_key))
|
||||
)
|
||||
|
||||
def _discount_matches_item(self, discount_desc: str, item_desc: str) -> bool:
|
||||
discount_key = self._discount_match_key(discount_desc)
|
||||
item_key = self._discount_match_key(item_desc)
|
||||
return bool(item_key and item_key in discount_key)
|
||||
|
||||
def _build_danfe_payload(
|
||||
self,
|
||||
danfe_df: pd.DataFrame,
|
||||
raw: Dict[str, pd.DataFrame],
|
||||
) -> Dict[str, Any]:
|
||||
if danfe_df.empty:
|
||||
return {}
|
||||
|
||||
total_geral = None
|
||||
item_records = []
|
||||
for rec in danfe_df.to_dict(orient="records"):
|
||||
if bool(rec.get("is_total")):
|
||||
total_geral = self._round_money(rec.get("value", 0.0) or 0.0)
|
||||
else:
|
||||
item_records.append(rec)
|
||||
|
||||
plan_names: List[str] = []
|
||||
if "Plano" in raw and "desc" in raw["Plano"].columns:
|
||||
plano_df = raw["Plano"]
|
||||
mask_plan = plano_df["desc"].str.contains(r"PÓS/SMP", case=False, na=False)
|
||||
if mask_plan.sum() == 0:
|
||||
mask_plan = plano_df["desc"].str.match(r"(?i)^tim", na=False)
|
||||
for desc in plano_df.loc[mask_plan, "desc"].dropna().astype(str):
|
||||
plan_name = self._clean_plan_name(desc, format_version_number=False)
|
||||
if plan_name not in plan_names:
|
||||
plan_names.append(plan_name)
|
||||
|
||||
if not plan_names:
|
||||
for rec in item_records:
|
||||
desc = str(rec.get("desc", ""))
|
||||
desc_key = self._discount_match_key(desc)
|
||||
if desc_key.startswith("tim ") and not self._is_discount_desc(desc):
|
||||
plan_name = self._clean_plan_name(desc, format_version_number=False)
|
||||
if plan_name.casefold() != "tim music" and plan_name not in plan_names:
|
||||
plan_names.append(plan_name)
|
||||
|
||||
plan_matchers = [(name, self._discount_match_key(name)) for name in plan_names]
|
||||
|
||||
other_keys: List[str] = []
|
||||
if "Outros Valores" in raw and "desc" in raw["Outros Valores"].columns:
|
||||
for desc in raw["Outros Valores"]["desc"].dropna().astype(str):
|
||||
key = self._discount_match_key(desc)
|
||||
if key and key not in other_keys:
|
||||
other_keys.append(key)
|
||||
|
||||
payload: Dict[str, Any] = {"Planos": {name: [] for name in plan_names}}
|
||||
if total_geral is not None:
|
||||
payload["total_geral"] = total_geral
|
||||
|
||||
outros_itens: List[Dict[str, Any]] = []
|
||||
current_plan: str | None = None
|
||||
last_item_by_plan: Dict[str, Dict[str, Any]] = {}
|
||||
|
||||
for rec in item_records:
|
||||
desc = str(rec.get("desc", ""))
|
||||
desc_key = self._discount_match_key(desc)
|
||||
matched_plan = next(
|
||||
(
|
||||
name
|
||||
for name, plan_key in plan_matchers
|
||||
if self._desc_matches_key(desc_key, plan_key)
|
||||
),
|
||||
None,
|
||||
)
|
||||
|
||||
is_known_other = any(
|
||||
self._desc_matches_key(desc_key, other_key)
|
||||
for other_key in other_keys
|
||||
)
|
||||
is_discount = self._is_discount_desc(desc)
|
||||
|
||||
if matched_plan and not is_discount:
|
||||
current_plan = matched_plan
|
||||
item = self._danfe_item_payload(rec)
|
||||
payload["Planos"].setdefault(current_plan, []).append(item)
|
||||
last_item_by_plan[current_plan] = item
|
||||
continue
|
||||
|
||||
if is_known_other and not is_discount:
|
||||
outros_itens.append(self._danfe_item_payload(rec))
|
||||
continue
|
||||
|
||||
if is_discount:
|
||||
target_item = None
|
||||
if current_plan:
|
||||
for item in reversed(payload["Planos"].get(current_plan, [])):
|
||||
if self._discount_matches_item(desc, str(item.get("desc", ""))):
|
||||
target_item = item
|
||||
break
|
||||
if target_item is None:
|
||||
target_item = last_item_by_plan.get(current_plan)
|
||||
|
||||
if target_item is None:
|
||||
outros_itens.append(self._danfe_discount_payload(rec))
|
||||
continue
|
||||
|
||||
discount = self._danfe_discount_payload(rec)
|
||||
target_item["descontos"].append(discount)
|
||||
continue
|
||||
|
||||
if current_plan:
|
||||
item = self._danfe_item_payload(rec)
|
||||
payload["Planos"].setdefault(current_plan, []).append(item)
|
||||
last_item_by_plan[current_plan] = item
|
||||
else:
|
||||
outros_itens.append(self._danfe_item_payload(rec))
|
||||
|
||||
for items in payload["Planos"].values():
|
||||
for item in items:
|
||||
item["descontos"].sort(
|
||||
key=lambda discount: abs(float(discount.get("value") or 0.0)),
|
||||
reverse=True,
|
||||
)
|
||||
total_descontos = sum(
|
||||
float(discount.get("value") or 0.0)
|
||||
for discount in item["descontos"]
|
||||
)
|
||||
item["total_descontos"] = self._round_money(total_descontos)
|
||||
item["valor_final"] = self._round_money(
|
||||
float(item.get("valor_bruto") or 0.0) + total_descontos
|
||||
)
|
||||
|
||||
if outros_itens:
|
||||
payload["Outros Itens"] = outros_itens
|
||||
|
||||
return payload
|
||||
|
||||
def _match_discount_to_plan(
|
||||
self,
|
||||
discount: Dict[str, Any],
|
||||
plans: List[Tuple[str, Dict[str, Any], str]],
|
||||
) -> str | None:
|
||||
discount_key = self._discount_match_key(str(discount.get("desc", "")))
|
||||
matches = [
|
||||
(plan_name, plan)
|
||||
for plan_name, plan, plan_key in plans
|
||||
if plan_key and plan_key in discount_key
|
||||
]
|
||||
if not matches:
|
||||
return None
|
||||
|
||||
discount_period = discount.get("period")
|
||||
if discount_period:
|
||||
for plan_name, plan in matches:
|
||||
if plan.get("period") == discount_period:
|
||||
return plan_name
|
||||
|
||||
return matches[0][0]
|
||||
|
||||
def _build_plan_payload(
|
||||
self,
|
||||
plano_df: pd.DataFrame | None,
|
||||
descontos_df: pd.DataFrame | None,
|
||||
) -> tuple[Dict[str, Dict[str, Any]], List[Dict[str, Any]]]:
|
||||
if plano_df is None or plano_df.empty:
|
||||
return {}, self._records_for_section(descontos_df) if descontos_df is not None else []
|
||||
|
||||
plan_records = self._records_for_section(plano_df)
|
||||
discount_records = self._records_for_section(descontos_df) if descontos_df is not None else []
|
||||
|
||||
payload: Dict[str, Dict[str, Any]] = {}
|
||||
plan_matchers: List[Tuple[str, Dict[str, Any], str]] = []
|
||||
|
||||
for plan in plan_records:
|
||||
desc = plan.get("desc")
|
||||
if not desc:
|
||||
continue
|
||||
|
||||
plan_name = self._clean_plan_name(str(desc))
|
||||
plan_payload = {}
|
||||
for field in ("period", "days", "msisdn"):
|
||||
if field in plan:
|
||||
plan_payload[field] = plan[field]
|
||||
valor_bruto = self._round_money(plan.get("value", 0.0))
|
||||
plan_payload["valor_final"] = valor_bruto
|
||||
for field, value in plan.items():
|
||||
if field not in {"desc", "period", "days", "msisdn", "value"}:
|
||||
plan_payload[field] = value
|
||||
|
||||
if self._is_controle_plan(desc):
|
||||
plan_payload["is_controle"] = True
|
||||
|
||||
plan_payload["descontos"] = []
|
||||
plan_payload["total_descontos"] = 0.0
|
||||
plan_payload["valor_bruto"] = valor_bruto
|
||||
|
||||
payload[plan_name] = plan_payload
|
||||
plan_matchers.append((plan_name, plan, self._discount_match_key(plan_name)))
|
||||
|
||||
unmatched_discounts: List[Dict[str, Any]] = []
|
||||
|
||||
for discount in discount_records:
|
||||
plan_name = self._match_discount_to_plan(discount, plan_matchers)
|
||||
if plan_name is None:
|
||||
unmatched_discounts.append(discount)
|
||||
continue
|
||||
|
||||
discount_payload = {
|
||||
"desc": discount.get("desc"),
|
||||
"value": discount.get("value"),
|
||||
"installment": discount.get("installment"),
|
||||
}
|
||||
payload[plan_name]["descontos"].append(discount_payload)
|
||||
|
||||
for plan in payload.values():
|
||||
total_descontos = sum(
|
||||
float(discount.get("value") or 0.0)
|
||||
for discount in plan["descontos"]
|
||||
)
|
||||
plan["total_descontos"] = self._round_money(total_descontos)
|
||||
valor_final = self._round_money(
|
||||
float(plan.get("valor_bruto") or 0.0) + total_descontos
|
||||
)
|
||||
plan["valor_final"] = valor_final
|
||||
|
||||
return payload, unmatched_discounts
|
||||
|
||||
def _build_final_json(self, dfs: Dict[str, pd.DataFrame]) -> Dict[str, Any]:
|
||||
out: Dict[str, Any] = {}
|
||||
|
||||
plano_payload, descontos_restantes = self._build_plan_payload(
|
||||
dfs.get("Plano"),
|
||||
dfs.get("Descontos"),
|
||||
)
|
||||
|
||||
for sec in self.SECTIONS:
|
||||
if sec == "DANFE-COM":
|
||||
continue
|
||||
|
||||
if sec == "Plano" and plano_payload:
|
||||
out["Planos"] = plano_payload
|
||||
continue
|
||||
|
||||
if sec == "Descontos" and plano_payload:
|
||||
if descontos_restantes:
|
||||
out[sec] = descontos_restantes
|
||||
continue
|
||||
|
||||
if sec in dfs:
|
||||
section_records, strategic_records = self._split_strategic_records(
|
||||
dfs[sec],
|
||||
sec,
|
||||
)
|
||||
all_records = section_records + strategic_records
|
||||
if all_records:
|
||||
out[sec] = self._annotate_class(all_records, sec)
|
||||
|
||||
return out
|
||||
305
app/domain/contas/parsers/invoice_to_text.py
Normal file
305
app/domain/contas/parsers/invoice_to_text.py
Normal file
@@ -0,0 +1,305 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Transformacao da fatura normalizada (dict JSON) -> formato textual em blocos.
|
||||
|
||||
Formato economico em tokens (~72% menor que o JSON indentado), pensado para enviar
|
||||
a uma LLM. API publica: `to_text(data)` e a flag `USE_TEXT_FORMAT`.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
from typing import Any
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Liga o formato textual da fatura no prompt. Default conceitual: False;
|
||||
# mantido True nesta fase de rollout. Override por env, sem edicao de codigo.
|
||||
USE_TEXT_FORMAT = os.getenv("TIM_INVOICE_DETAIL_AS_TEXT", "true").lower() == "true"
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# secao -> (acao, classe). O cabecalho usa o proprio nome da secao (mantido como no JSON).
|
||||
SEC_META = {
|
||||
"SVA Detalhe Total": ("cancelar", "avulso"),
|
||||
"Itens Eventuais": ("cancelar", "avulso"),
|
||||
"Serviços Bundle Inclusos": ("falar sobre", "bundle"),
|
||||
}
|
||||
|
||||
# Secoes que rendem a acao por ITEM (sem "| ação=... | classe" no header).
|
||||
# Item estrategico (estrategico=True) usa "falar sobre"; avulso, "cancelar".
|
||||
PER_ITEM_ACTION_SECTIONS = {
|
||||
"SVA Detalhe Total",
|
||||
"Itens Eventuais",
|
||||
"Mensalidades Adicionais",
|
||||
}
|
||||
|
||||
# Secoes cujo header recebe o rotulo "| cobrados a parte" (servicos avulsos
|
||||
# contratados separadamente). A acao continua por item.
|
||||
COBRADOS_A_PARTE_SECTIONS = {"SVA Detalhe Total", "Itens Eventuais"}
|
||||
|
||||
# secao do JSON -> rotulo no cabecalho do prompt. "SVA Detalhe Total" e "Itens
|
||||
# Eventuais" sao a MESMA categoria (avulsos cobrados a parte) e saem sob um unico
|
||||
# cabecalho "Itens Eventuais". "Serviços Bundle Inclusos" vira "Serviços Inclusos
|
||||
# no Plano": a palavra "bundle" e tecnica/interna e nao deve chegar ao prompt (nem
|
||||
# ao LLM). As chaves do JSON normalizado NAO mudam nos dois casos.
|
||||
SECTION_LABELS = {
|
||||
"SVA Detalhe Total": "Itens Eventuais",
|
||||
"Serviços Bundle Inclusos": "Benefícios do Plano",
|
||||
}
|
||||
|
||||
# classe interna -> rotulo exibido no cabecalho (ver SEC_META). Mesma logica do
|
||||
# SECTION_LABELS: so a APRESENTACAO muda, a classe interna ("bundle") segue igual
|
||||
# em todo o resto do backend (bill_processor, invoice_resolver, tools, etc.).
|
||||
CLASSE_LABELS = {"bundle": "incluso"}
|
||||
|
||||
_RX_OUTROS = re.compile(r"^\s*([^:()]+?)\s*:\s*\((.+)\)\s*$")
|
||||
|
||||
|
||||
def _fmt_money(value: Any) -> str:
|
||||
if isinstance(value, (int, float)):
|
||||
return f"{value:.2f}"
|
||||
return str(value)
|
||||
|
||||
|
||||
def _snake(desc: Any) -> str:
|
||||
return re.sub(r"\s+", "_", str(desc).strip().lower())
|
||||
|
||||
|
||||
def is_period_range(period: Any) -> bool:
|
||||
"""True quando ``period`` é uma FAIXA (ciclo da fatura, ex.: "14/10 a 13/11"),
|
||||
não uma data única de cobrança. Compartilhado com o resolver, que usa o mesmo
|
||||
critério para decidir se uma entry expõe ``charge_date`` (data única) — assim o
|
||||
que o resolver enxerga como cobrança datada casa exatamente o ``data=`` do render
|
||||
lean do classificador."""
|
||||
text = str(period)
|
||||
return " a " in text or "~" in text
|
||||
|
||||
|
||||
def _item_acao(item: dict[str, Any], section: str) -> str | None:
|
||||
"""Verbo de acao por item: estrategico -> 'falar sobre'; senao, o default da secao."""
|
||||
if item.get("estrategico"):
|
||||
return "falar sobre"
|
||||
acao, _ = SEC_META.get(section, (None, None))
|
||||
return acao
|
||||
|
||||
|
||||
def _render_item(item: dict[str, Any], section: str) -> str:
|
||||
"""<nome solto> | campo=valor | ... (com tratamento especial p/ Outros Valores)."""
|
||||
desc = str(item.get("desc", "")).strip()
|
||||
value = item.get("value")
|
||||
|
||||
if section == "Outros Valores":
|
||||
match = _RX_OUTROS.match(desc)
|
||||
if match:
|
||||
parts = [match.group(1).strip().lower()]
|
||||
if value is not None and value != "Incluído":
|
||||
parts.append(f"valor={_fmt_money(value)}")
|
||||
parts.append(f'ref="{match.group(2).strip()}"')
|
||||
return " | ".join(parts)
|
||||
|
||||
parts = [desc]
|
||||
if value is not None and value != "Incluído":
|
||||
parts.append(f"valor={_fmt_money(value)}")
|
||||
period = item.get("period")
|
||||
if period and not is_period_range(period):
|
||||
parts.append(f"data={period}")
|
||||
if item.get("franchise"):
|
||||
parts.append(f"franquia={item['franchise']}")
|
||||
if item.get("consumption"):
|
||||
parts.append(f"consumo={item['consumption']}")
|
||||
if item.get("installment"):
|
||||
parts.append(f"parcela={item['installment']}")
|
||||
return " | ".join(parts)
|
||||
|
||||
|
||||
# Seções omitidas no modo enxuto (intent classifier): o classificador só precisa
|
||||
# dos NOMES dos itens por linha/seção, não de totais, planos nem juros/multas.
|
||||
_LEAN_SKIP_SECTIONS = frozenset({"Fatura Resumo", "Planos", "Outros Valores"})
|
||||
|
||||
|
||||
def to_text(data: dict[str, Any], *, lean: bool = False) -> str:
|
||||
"""Converte o JSON normalizado de uma fatura no formato textual em blocos.
|
||||
|
||||
``lean=True`` (formato do intent classifier): mantém a estrutura LINHA/seção,
|
||||
o NOME dos itens e o ``valor``/``data`` por item — descarta "Fatura Resumo",
|
||||
"Planos", "Outros Valores" e os demais campos por-item (ação, type, franquia,
|
||||
consumo). O ``valor``/``data`` distinguem cobranças duplicadas do mesmo nome na
|
||||
mesma linha (desambiguação de cobrança). O orquestrador usa ``lean=False``
|
||||
(formato completo, com ações)."""
|
||||
lines: list[str] = []
|
||||
msisdn_count = 0
|
||||
|
||||
# FATURA (achatada) — omitida no modo enxuto.
|
||||
resumo = data.get("Fatura Resumo")
|
||||
if not lean and isinstance(resumo, list):
|
||||
lines.append("Fatura Resumo")
|
||||
for item in resumo:
|
||||
desc = item.get("desc", "?")
|
||||
if "period" in item:
|
||||
lines.append(f" {_snake(desc)}={item['period']}")
|
||||
elif "emissao" in item:
|
||||
lines.append(f" {_snake(desc)}={item['emissao']}")
|
||||
elif "value" in item:
|
||||
lines.append(f" {_snake(desc)}={_fmt_money(item['value'])}")
|
||||
lines.append("")
|
||||
|
||||
# LINHAS (MSISDN)
|
||||
for key, block in data.items():
|
||||
if key in ("Fatura Resumo", "vocalized_msisdn"):
|
||||
continue
|
||||
if not isinstance(block, dict):
|
||||
continue
|
||||
|
||||
msisdn_count += 1
|
||||
lines.append(f"LINHA {key}")
|
||||
|
||||
# Planos — omitido no modo enxuto.
|
||||
planos = block.get("Planos")
|
||||
if not lean and isinstance(planos, dict) and planos:
|
||||
lines.append("Planos")
|
||||
for nome, plano in planos.items():
|
||||
bits = [f"nome={nome}"]
|
||||
if plano.get("period"):
|
||||
bits.append(f"período={plano['period']}")
|
||||
if plano.get("days") is not None:
|
||||
bits.append(f"dias={plano['days']}")
|
||||
bits.append(f"valor_final={_fmt_money(plano.get('valor_final'))}")
|
||||
if plano.get("valor_bruto") not in (None, plano.get("valor_final")):
|
||||
bits.append(f"valor_bruto={_fmt_money(plano.get('valor_bruto'))}")
|
||||
if plano.get("total_descontos"):
|
||||
bits.append(f"total_desc={_fmt_money(plano.get('total_descontos'))}")
|
||||
if plano.get("is_controle"):
|
||||
bits.append("controle=sim")
|
||||
lines.append(" " + " | ".join(bits))
|
||||
descontos = plano.get("descontos") or []
|
||||
if descontos:
|
||||
lines.append(" descontos:")
|
||||
for desconto in descontos:
|
||||
dbits = [
|
||||
f"nome={desconto.get('desc')}",
|
||||
f"valor={_fmt_money(desconto.get('value'))}",
|
||||
]
|
||||
if desconto.get("installment"):
|
||||
dbits.append(f"parcela={desconto['installment']}")
|
||||
lines.append(" " + " | ".join(dbits))
|
||||
|
||||
# Seções agrupadas pelo RÓTULO do cabeçalho (``SECTION_LABELS``): as que
|
||||
# compartilham rótulo saem sob um único cabeçalho, na posição da primeira
|
||||
# delas. Cada item guarda a seção de ORIGEM, que é quem decide a ação/classe
|
||||
# por item — o rótulo é só apresentação.
|
||||
groups: dict[str, tuple[str, list[tuple[str, dict[str, Any]]]]] = {}
|
||||
for section, items in block.items():
|
||||
if section == "Planos" or not isinstance(items, list):
|
||||
continue
|
||||
if lean and section in _LEAN_SKIP_SECTIONS:
|
||||
continue
|
||||
label = SECTION_LABELS.get(section, section)
|
||||
_, entries = groups.setdefault(label, (section, []))
|
||||
entries.extend((section, item) for item in items)
|
||||
|
||||
for label, (first_section, entries) in groups.items():
|
||||
acao, classe = SEC_META.get(first_section, (None, None))
|
||||
header = label
|
||||
# No modo enxuto o cabeçalho é só o nome da seção (sem ação/classe).
|
||||
if not lean and first_section in COBRADOS_A_PARTE_SECTIONS:
|
||||
header += " | cobrados a parte"
|
||||
elif acao and first_section not in PER_ITEM_ACTION_SECTIONS and not lean:
|
||||
header += f" | ação={acao} | {CLASSE_LABELS.get(classe, classe)}"
|
||||
lines.append(header)
|
||||
for section, item in entries:
|
||||
if lean:
|
||||
# Nome do item + valor + data da cobrança (sem ação/type/
|
||||
# franquia/consumo). O valor/data por item permitem o classifier
|
||||
# DISTINGUIR N cobranças do mesmo nome na mesma linha (mesmo
|
||||
# desc/msisdn, períodos diferentes) na desambiguação de cobrança
|
||||
# duplicada — o msisdn vem do cabeçalho ``LINHA``. Itens
|
||||
# ``Incluído`` (bundle) e períodos em faixa (ciclo da fatura, não
|
||||
# data de cobrança) seguem só com o nome, como antes.
|
||||
desc = str(item.get("desc", "")).strip()
|
||||
if not desc:
|
||||
continue
|
||||
parts = [desc]
|
||||
value = item.get("value")
|
||||
if value is not None and value != "Incluído":
|
||||
parts.append(f"valor={_fmt_money(value)}")
|
||||
period = item.get("period")
|
||||
if period and not is_period_range(period):
|
||||
parts.append(f"data={period}")
|
||||
lines.append(" " + " | ".join(parts))
|
||||
continue
|
||||
line = " " + _render_item(item, section)
|
||||
if section in PER_ITEM_ACTION_SECTIONS:
|
||||
item_acao = _item_acao(item, section)
|
||||
if item_acao:
|
||||
line += f" | ação={item_acao}"
|
||||
if item.get("estrategico"):
|
||||
line += " | type=estrategico"
|
||||
lines.append(line)
|
||||
lines.append("")
|
||||
|
||||
text = "\n".join(lines).rstrip()
|
||||
if msisdn_count > 1:
|
||||
text += "\n\nmultiplas_linhas = true"
|
||||
else:
|
||||
text += "\n\nmultiplas_linhas = false"
|
||||
return text + "\n"
|
||||
|
||||
|
||||
# ordem pedida pelo produto: avulso -> estrategico -> bundle
|
||||
# (difere da ordem informacional bundle/estrategico/avulso usada em backend.py)
|
||||
_VAS_PRODUCT_ORDER = ("avulso", "estrategico", "bundle")
|
||||
|
||||
|
||||
def get_vas_product_names(data: dict[str, Any]) -> str:
|
||||
"""Nomes dos VAS (avulso, estrategico, bundle — nessa ordem) da fatura
|
||||
normalizada, deduplicados e separados por vírgula. Ex.: "Focus Mensal,
|
||||
Tamboro Mensal". Ignora plano e demais seções não-VAS. Retorna "" se não houver."""
|
||||
buckets: dict[str, list[str]] = {c: [] for c in _VAS_PRODUCT_ORDER}
|
||||
seen: set[str] = set()
|
||||
for key, block in data.items():
|
||||
if key in ("Fatura Resumo", "vocalized_msisdn") or not isinstance(block, dict):
|
||||
continue
|
||||
for section, items in block.items():
|
||||
if section == "Planos" or not isinstance(items, list):
|
||||
continue
|
||||
for item in items:
|
||||
classe = item.get("classe")
|
||||
if not classe and item.get("estrategico"):
|
||||
classe = "estrategico"
|
||||
if classe not in buckets:
|
||||
continue
|
||||
name = str(item.get("desc", "")).strip()
|
||||
if not name or name in seen:
|
||||
continue
|
||||
seen.add(name)
|
||||
buckets[classe].append(name)
|
||||
ordered = [n for c in _VAS_PRODUCT_ORDER for n in buckets[c]]
|
||||
return ", ".join(ordered)
|
||||
|
||||
|
||||
def render_for_prompt(invoice_detail: Any, *, lean: bool = False) -> str | None:
|
||||
"""Renderiza ``invoice_detail`` (dict ou str JSON) no formato textual ``to_text``
|
||||
para uso no prompt. Fonte única compartilhada pelo orquestrador (system prompt)
|
||||
e pelo intent classifier — garante que ambos leiam a MESMA fatura, sem drift.
|
||||
|
||||
``lean=True`` produz o formato enxuto do intent classifier (nomes + valor/data
|
||||
por item, por linha/seção; ver :func:`to_text`); o orquestrador usa ``lean=False``.
|
||||
|
||||
Retorna ``None`` quando o formato textual está desligado (``USE_TEXT_FORMAT``
|
||||
false) ou em qualquer falha de parse/render — cabe ao chamador decidir o
|
||||
fallback (JSON cru no orquestrador; renderizador compacto no classifier).
|
||||
Nunca levanta exceção: o prompt não pode quebrar por causa da fatura."""
|
||||
if not (USE_TEXT_FORMAT and invoice_detail):
|
||||
return None
|
||||
try:
|
||||
parsed = (
|
||||
json.loads(invoice_detail)
|
||||
if isinstance(invoice_detail, str)
|
||||
else invoice_detail
|
||||
)
|
||||
if isinstance(parsed, dict) and parsed:
|
||||
return to_text(parsed, lean=lean)
|
||||
except Exception: # noqa: BLE001 — nunca quebrar o prompt
|
||||
logger.warning("invoice_to_text.render_for_prompt falhou", exc_info=True)
|
||||
return None
|
||||
217
app/domain/contas/pro_rata_rules.py
Normal file
217
app/domain/contas/pro_rata_rules.py
Normal file
@@ -0,0 +1,217 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import unicodedata as ud
|
||||
from datetime import date
|
||||
from decimal import Decimal, InvalidOperation, ROUND_HALF_UP
|
||||
from typing import Any
|
||||
|
||||
_CENT = Decimal('0.01')
|
||||
|
||||
|
||||
def decimal_from_any(value: Any) -> Decimal | None:
|
||||
if value is None or isinstance(value, bool):
|
||||
return None
|
||||
if isinstance(value, Decimal):
|
||||
return value
|
||||
if isinstance(value, (int, float)):
|
||||
return Decimal(str(value))
|
||||
text = str(value or '').strip().replace('R$', '').replace(' ', '')
|
||||
if not text:
|
||||
return None
|
||||
if ',' in text and '.' in text:
|
||||
text = text.replace('.', '').replace(',', '.')
|
||||
elif ',' in text:
|
||||
text = text.replace(',', '.')
|
||||
try:
|
||||
return Decimal(text)
|
||||
except (InvalidOperation, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
def money(value: Decimal) -> Decimal:
|
||||
return value.quantize(_CENT, rounding=ROUND_HALF_UP)
|
||||
|
||||
|
||||
def amount_text(value: Decimal) -> str:
|
||||
return f'{money(value):.2f}'
|
||||
|
||||
|
||||
def normalize_match_text(value: Any) -> str:
|
||||
text = re.sub(r'\s*\([^)]*\)', '', str(value or '')).strip()
|
||||
text = ud.normalize('NFKD', text)
|
||||
text = ''.join(ch for ch in text if not ud.combining(ch)).casefold()
|
||||
return re.sub(r'\s+', ' ', re.sub(r'[^a-z0-9]+', ' ', text)).strip()
|
||||
|
||||
|
||||
def same_plan_name(left: Any, right: Any) -> bool:
|
||||
a, b = normalize_match_text(left), normalize_match_text(right)
|
||||
return bool(a and b and (a == b or a in b or b in a))
|
||||
|
||||
|
||||
def resolve_plano_controle(planos: list[dict[str, Any]]) -> tuple[dict[str, Any], dict[str, Any]] | None:
|
||||
controls = [p for p in planos if isinstance(p, dict) and bool(p.get('is_controle'))]
|
||||
if len(controls) != 1:
|
||||
return None
|
||||
control = controls[0]
|
||||
other = next((p for p in planos if p is not control), None)
|
||||
return (control, other) if isinstance(other, dict) else None
|
||||
|
||||
|
||||
def resolve_liquid_value(plano: dict[str, Any]) -> Decimal | None:
|
||||
for key in ('valor_final','valorFinal','valor_liquido','valorLiquido','net_value','netValue','subtotal','value_final'):
|
||||
val = decimal_from_any(plano.get(key))
|
||||
if val is not None:
|
||||
return val
|
||||
gross = next((decimal_from_any(plano.get(k)) for k in ('valor_bruto','valorBruto','gross_value','grossValue','valor_bruto_plano','valorBrutoPlano','preco_unit') if decimal_from_any(plano.get(k)) is not None), None)
|
||||
discounts = next((decimal_from_any(plano.get(k)) for k in ('total_descontos','totalDescontos','discount_total') if decimal_from_any(plano.get(k)) is not None), None)
|
||||
return gross + discounts if gross is not None and discounts is not None else None
|
||||
|
||||
|
||||
def _parse_emission_year(invoice: dict[str, Any], emission: str='') -> int | None:
|
||||
for text in [emission] + [str(x.get('emissao') or '') for x in invoice.get('Fatura Resumo', []) if isinstance(x, dict)]:
|
||||
m = re.search(r'\b\d{2}/\d{2}/(?P<y>\d{4})\b', text)
|
||||
if m:
|
||||
return int(m.group('y'))
|
||||
return None
|
||||
|
||||
|
||||
def _period_text(invoice: dict[str, Any], period: str='') -> str:
|
||||
if period:
|
||||
return period
|
||||
for item in invoice.get('Fatura Resumo', []) or []:
|
||||
if isinstance(item, dict) and normalize_match_text(item.get('desc')) == 'periodo':
|
||||
return str(item.get('period') or '').strip()
|
||||
return ''
|
||||
|
||||
|
||||
def _parse_period(text: str, year: int) -> tuple[date,date] | None:
|
||||
m = re.search(r'(?P<sd>\d{2})/(?P<sm>\d{2})\s+a\s+(?P<ed>\d{2})/(?P<em>\d{2})', text)
|
||||
if not m:
|
||||
return None
|
||||
sd,sm,ed,em = map(int, (m['sd'],m['sm'],m['ed'],m['em']))
|
||||
sy = year - 1 if sm > em else year
|
||||
try:
|
||||
start,end = date(sy,sm,sd), date(year,em,ed)
|
||||
except ValueError:
|
||||
return None
|
||||
return (start,end) if start <= end else None
|
||||
|
||||
|
||||
def resolve_period_days(other: dict[str, Any], invoice: dict[str, Any], *, period: str='', emission: str='') -> tuple[int,int] | None:
|
||||
year = _parse_emission_year(invoice, emission)
|
||||
ptxt = _period_text(invoice, period)
|
||||
if year is None or not ptxt:
|
||||
return None
|
||||
pr = _parse_period(ptxt, year)
|
||||
if pr is None:
|
||||
return None
|
||||
cycle = (pr[1]-pr[0]).days + 1
|
||||
raw = other.get('days') if other.get('days') is not None else other.get('dias')
|
||||
try:
|
||||
days_other = int(Decimal(str(raw)).to_integral_value(rounding=ROUND_HALF_UP))
|
||||
except Exception:
|
||||
return None
|
||||
if days_other <= 0 or cycle <= 0:
|
||||
return None
|
||||
if days_other >= cycle:
|
||||
return cycle, 1
|
||||
return cycle, max(1, cycle-days_other)
|
||||
|
||||
|
||||
def find_danfe_plan_items(danfe: dict[str, Any], control: dict[str, Any]) -> list[dict[str, Any]]:
|
||||
plans = danfe.get('Planos')
|
||||
if not isinstance(plans, dict):
|
||||
return []
|
||||
desc = control.get('desc')
|
||||
for name, raw in plans.items():
|
||||
if same_plan_name(name, desc) and isinstance(raw, list):
|
||||
return [x for x in raw if isinstance(x, dict)]
|
||||
return []
|
||||
|
||||
|
||||
def build_contestation_items(danfe: dict[str, Any], control: dict[str, Any], refund: Decimal) -> tuple[list[dict[str, Any]], Decimal]:
|
||||
remaining = money(refund)
|
||||
out: list[dict[str, Any]] = []
|
||||
for item in find_danfe_plan_items(danfe, control):
|
||||
name = str(item.get('desc') or '').strip()
|
||||
claimed = decimal_from_any(item.get('valor_final') if item.get('valor_final') is not None else item.get('valorFinal'))
|
||||
if not name or claimed is None or claimed <= 0:
|
||||
continue
|
||||
claimed = money(claimed)
|
||||
validated = min(remaining, claimed)
|
||||
if validated <= 0:
|
||||
continue
|
||||
out.append({'itemName': name, 'itemType':'PRO_RATA', 'claimedAmount':float(claimed), 'validatedAmount':float(validated)})
|
||||
remaining = money(remaining-validated)
|
||||
if remaining <= 0:
|
||||
break
|
||||
return out, remaining
|
||||
|
||||
|
||||
def human_validation_text(control: dict[str, Any], liquid: Decimal, cycle: int, control_days: int, used: Decimal, refund: Decimal, items: list[dict[str,Any]]) -> str:
|
||||
name = re.sub(r'\s*\([^)]*\)', '', str(control.get('desc') or '')).strip() or 'Plano Controle'
|
||||
parts=[]
|
||||
for idx,item in enumerate(items,1):
|
||||
claimed=decimal_from_any(item.get('claimedAmount')) or Decimal('0')
|
||||
validated=decimal_from_any(item.get('validatedAmount')) or Decimal('0')
|
||||
parts.append(f"{idx}. {item.get('itemName','')}: valor DANFE R$ {money(claimed):.2f}; valor a abater R$ {money(validated):.2f}")
|
||||
return (
|
||||
f'Validacao humana pro-rata:Plano Controle identificado: {name}. '
|
||||
f'Base liquida do plano: R$ {money(liquid):.2f}. Calculo: ciclo de {cycle} dias, uso considerado de {control_days} dias; '
|
||||
f'valor usado R$ {money(used):.2f}; valor a devolver R$ {money(refund):.2f}. '
|
||||
f"Itens selecionados no DANFE, na ordem de abatimento: {'; '.join(parts) if parts else 'nenhum item gerado'}. "
|
||||
)
|
||||
|
||||
|
||||
def calculate_refund(*, planos: list[dict[str,Any]], invoice_detail: dict[str,Any], invoice_period: str='', invoice_emissao: str='') -> dict[str,Any]:
|
||||
if len(planos) != 2:
|
||||
raise ValueError('pro_rata exige exatamente dois planos para calcular devolucao.')
|
||||
resolved = resolve_plano_controle(planos)
|
||||
if resolved is None:
|
||||
raise ValueError('Nao foi possivel identificar exatamente um Plano Controle.')
|
||||
control, other = resolved
|
||||
liquid = resolve_liquid_value(control)
|
||||
if liquid is None or liquid <= 0:
|
||||
raise ValueError('Valor liquido do Plano Controle ausente ou invalido.')
|
||||
period_days = resolve_period_days(other, invoice_detail, period=invoice_period, emission=invoice_emissao)
|
||||
if period_days is None:
|
||||
raise ValueError('Periodo da fatura ou dos planos ausente ou invalido para calcular pro-rata.')
|
||||
cycle, control_days = period_days
|
||||
liquid = money(liquid)
|
||||
used = (liquid / Decimal(cycle)) * Decimal(control_days)
|
||||
refund = money(liquid-used)
|
||||
danfe = invoice_detail.get('DANFE-COM')
|
||||
if not isinstance(danfe, dict) or not danfe:
|
||||
raise ValueError('DANFE-COM nao encontrado na fatura recuperada.')
|
||||
items, remaining = build_contestation_items(danfe, control, refund)
|
||||
if remaining > 0:
|
||||
raise ValueError(f'Itens do DANFE insuficientes para cobrir devolucao de R$ {money(remaining):.2f}.')
|
||||
total = money(sum((decimal_from_any(x.get('validatedAmount')) or Decimal('0') for x in items), Decimal('0')))
|
||||
return {
|
||||
'items': items,
|
||||
'invoice_amount_open': amount_text(total),
|
||||
'invoice_amount': amount_text(total),
|
||||
'texto_validacao_humana': human_validation_text(control, liquid, cycle, control_days, used, refund, items),
|
||||
'valor_liquido': amount_text(liquid), 'dias_ciclo': cycle, 'dias_controle': control_days,
|
||||
'valor_usado': amount_text(money(used)), 'valor_devolver': amount_text(refund),
|
||||
}
|
||||
|
||||
|
||||
def payment_message(devolucao: dict[str,Any]) -> str:
|
||||
items = devolucao.get('items') if isinstance(devolucao.get('items'), list) else []
|
||||
total=Decimal('0'); plan=''
|
||||
for item in items:
|
||||
if not isinstance(item,dict): continue
|
||||
if not plan: plan=str(item.get('itemName') or item.get('item_name') or '').strip()
|
||||
total += decimal_from_any(item.get('validatedAmount') if item.get('validatedAmount') is not None else item.get('validated_amount')) or Decimal('0')
|
||||
amount=f'{money(total):.2f}'.replace('.',',')
|
||||
ptxt=f' {plan}' if plan else ''
|
||||
proto=str(devolucao.get('protocolo_id') or '').strip()
|
||||
proto_txt=f' Seu numero de protocolo e {proto}.' if proto else ''
|
||||
due=str(devolucao.get('data_credito_proxima_fatura') or '').strip()
|
||||
due_txt=f' na fatura com vencimento em {due}, considerando o seu ciclo de faturamento' if due else ' em uma proxima fatura'
|
||||
if str(devolucao.get('format_text') or '').strip()=='sms':
|
||||
barcode_txt='com o codigo de barras atualizado' if str(devolucao.get('barcode') or '').strip() else 'com as orientacoes para pagamento'
|
||||
return f'Realizei a contestacao da fatura considerando o valor proporcional do Plano Controle{ptxt}. O valor contestado, de R$ {amount}, foi retirado da sua fatura. Enviamos uma mensagem {barcode_txt}, com prazo de 4 dias para pagamento.{proto_txt}'.strip()
|
||||
return f'Realizei a contestacao considerando o valor proporcional do Plano Controle{ptxt}. O valor contestado, de R$ {amount}, ficou registrado como credito{due_txt}.{proto_txt}'.strip()
|
||||
169
app/domain/contas/protocol_triplets.py
Normal file
169
app/domain/contas/protocol_triplets.py
Normal file
@@ -0,0 +1,169 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
from dataclasses import dataclass
|
||||
from functools import lru_cache
|
||||
from typing import Any
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_TRIPLETS_ENV_VAR = "TIM_PROTOCOL_TRIPLETS_JSON"
|
||||
_STAGES = ("open", "close")
|
||||
_FIELDS = ("reason1", "reason2", "reason3")
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class ProtocolTriplet:
|
||||
reason1: str
|
||||
reason2: str
|
||||
reason3: str
|
||||
|
||||
|
||||
_DEFAULT_CATALOG: dict[str, dict[str, ProtocolTriplet]] = {
|
||||
"atendimento_geral": {
|
||||
"open": ProtocolTriplet("Informação", "Conta", "Valor"),
|
||||
"close": ProtocolTriplet("Informação", "Conta", "Serviço"),
|
||||
},
|
||||
"cancelamento_vas_avulso": {
|
||||
"open": ProtocolTriplet(
|
||||
"Solicitação", "Serviço VAS", "Ativação/Desativação"
|
||||
),
|
||||
"close": ProtocolTriplet(
|
||||
"Solicitação", "Serviço VAS", "Ativação/Desativação"
|
||||
),
|
||||
},
|
||||
"contestacao": {
|
||||
"open": ProtocolTriplet("Informação", "Conta", "Serviço"),
|
||||
"close": ProtocolTriplet("Reclamação", "Conta", "Valor"),
|
||||
},
|
||||
"vas_estrategico": {
|
||||
"open": ProtocolTriplet("Informação", "Conta", "Serviço"),
|
||||
"close": ProtocolTriplet("Informação", "Conta", "Serviço"),
|
||||
},
|
||||
"invoice_explanation_aceite_fechado": {
|
||||
"open": ProtocolTriplet("Informação", "Conta", "Serviço"),
|
||||
"close": ProtocolTriplet("Informação", "Conta", "Serviço"),
|
||||
},
|
||||
"finalizacao_informacional_fechada": {
|
||||
"open": ProtocolTriplet("Informação", "Conta", "Serviço"),
|
||||
"close": ProtocolTriplet("Informação", "Conta", "Esclarecimento"),
|
||||
},
|
||||
"valor_divergente": {
|
||||
"open": ProtocolTriplet("Informação", "Conta", "Valor"),
|
||||
"close": ProtocolTriplet("Informação", "Conta", "Valor"),
|
||||
},
|
||||
"pro_rata": {
|
||||
"open": ProtocolTriplet("Informação", "Conta", "Valor"),
|
||||
"close": ProtocolTriplet("Informação", "Conta", "Valor"),
|
||||
},
|
||||
"pro_rata_mensalidade": {
|
||||
"open": ProtocolTriplet("Informação", "Conta", "Mensalidade"),
|
||||
"close": ProtocolTriplet("Informação", "Conta", "Mensalidade"),
|
||||
},
|
||||
"pro_rata_reclamacao": {
|
||||
"open": ProtocolTriplet("Reclamação", "Conta", "Valor"),
|
||||
"close": ProtocolTriplet("Reclamação", "Conta", "Valor"),
|
||||
},
|
||||
"termino_desconto": {
|
||||
"open": ProtocolTriplet("Informação", "Conta", "Serviço"),
|
||||
"close": ProtocolTriplet("Informação", "Conta", "Serviço"),
|
||||
},
|
||||
"conta_certa_manual": {
|
||||
"open": ProtocolTriplet(
|
||||
"Processo Interno", "Conta", "Conta Certa - Venc {dia_vencimento}"
|
||||
),
|
||||
"close": ProtocolTriplet(
|
||||
"Processo Interno", "Conta", "Conta Certa - Venc {dia_vencimento}"
|
||||
),
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
class _SafeFormatDict(dict[str, Any]):
|
||||
def __missing__(self, key: str) -> str:
|
||||
return ""
|
||||
|
||||
|
||||
def _normalize_text(value: Any) -> str:
|
||||
return str(value or "").strip()
|
||||
|
||||
|
||||
def _override_triplet(base: ProtocolTriplet, override: dict[str, Any]) -> ProtocolTriplet:
|
||||
return ProtocolTriplet(
|
||||
reason1=_normalize_text(override.get("reason1")) or base.reason1,
|
||||
reason2=_normalize_text(override.get("reason2")) or base.reason2,
|
||||
reason3=_normalize_text(override.get("reason3")) or base.reason3,
|
||||
)
|
||||
|
||||
|
||||
@lru_cache(maxsize=1)
|
||||
def _catalog_with_overrides() -> dict[str, dict[str, ProtocolTriplet]]:
|
||||
catalog = {
|
||||
scenario: dict(stages)
|
||||
for scenario, stages in _DEFAULT_CATALOG.items()
|
||||
}
|
||||
raw = os.getenv(_TRIPLETS_ENV_VAR, "").strip()
|
||||
if not raw:
|
||||
return catalog
|
||||
try:
|
||||
payload = json.loads(raw)
|
||||
except json.JSONDecodeError:
|
||||
logger.warning("triplets.override.invalid_json env=%s", _TRIPLETS_ENV_VAR)
|
||||
return catalog
|
||||
if not isinstance(payload, dict):
|
||||
logger.warning("triplets.override.invalid_root env=%s", _TRIPLETS_ENV_VAR)
|
||||
return catalog
|
||||
|
||||
for scenario, stage_payload in payload.items():
|
||||
scenario_key = _normalize_text(scenario).lower()
|
||||
if not scenario_key or not isinstance(stage_payload, dict):
|
||||
continue
|
||||
current = catalog.get(scenario_key, {})
|
||||
for stage in _STAGES:
|
||||
override = stage_payload.get(stage)
|
||||
if not isinstance(override, dict):
|
||||
continue
|
||||
if stage in current:
|
||||
current[stage] = _override_triplet(current[stage], override)
|
||||
continue
|
||||
if any(_normalize_text(override.get(field)) for field in _FIELDS):
|
||||
current[stage] = ProtocolTriplet(
|
||||
reason1=_normalize_text(override.get("reason1")),
|
||||
reason2=_normalize_text(override.get("reason2")),
|
||||
reason3=_normalize_text(override.get("reason3")),
|
||||
)
|
||||
if current:
|
||||
catalog[scenario_key] = current
|
||||
return catalog
|
||||
|
||||
|
||||
def resolve_protocol_triplet(
|
||||
scenario: str,
|
||||
*,
|
||||
stage: str,
|
||||
context: dict[str, Any] | None = None,
|
||||
fallback_scenario: str = "atendimento_geral",
|
||||
) -> ProtocolTriplet:
|
||||
stage_key = _normalize_text(stage).lower()
|
||||
if stage_key not in _STAGES:
|
||||
stage_key = "open"
|
||||
scenario_key = _normalize_text(scenario).lower() or fallback_scenario
|
||||
fallback_key = _normalize_text(fallback_scenario).lower() or "atendimento_geral"
|
||||
|
||||
catalog = _catalog_with_overrides()
|
||||
stages = catalog.get(scenario_key) or catalog.get(fallback_key) or {}
|
||||
raw_triplet = stages.get(stage_key)
|
||||
if raw_triplet is None:
|
||||
fallback_stages = catalog.get("atendimento_geral", {})
|
||||
raw_triplet = fallback_stages.get(stage_key) or ProtocolTriplet("", "", "")
|
||||
|
||||
fmt_values = _SafeFormatDict(
|
||||
{key: _normalize_text(value) for key, value in (context or {}).items()}
|
||||
)
|
||||
return ProtocolTriplet(
|
||||
reason1=raw_triplet.reason1.format_map(fmt_values).strip(),
|
||||
reason2=raw_triplet.reason2.format_map(fmt_values).strip(),
|
||||
reason3=raw_triplet.reason3.format_map(fmt_values).strip(),
|
||||
)
|
||||
81
app/domain/contas/rct_policy.py
Normal file
81
app/domain/contas/rct_policy.py
Normal file
@@ -0,0 +1,81 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from enum import StrEnum
|
||||
|
||||
from .ic_tags import RCTTag
|
||||
|
||||
|
||||
class RCTOperation(StrEnum):
|
||||
CANCELA_VAS = "cancela_vas"
|
||||
SGR_CODBAR = "sgr_codbar"
|
||||
CONTESTACAO = "contestacao"
|
||||
DEF_VALOR_AJUSTE = "def_valor_ajuste"
|
||||
REG_ATEND_VAS_AVULSO = "reg_atend_vas_avulso"
|
||||
REG_ATEND_VAS_ESTRAT = "reg_atend_vas_estrat"
|
||||
PDF_FATURA = "pdf_fatura"
|
||||
BASE_CONHECIMENTO = "base_conhecimento"
|
||||
REG_CHAMADO_BO_RT15 = "reg_chamado_bo_rt15"
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class RCTPolicy:
|
||||
success: tuple[str, ...] = ()
|
||||
failure: tuple[str, ...] = ()
|
||||
|
||||
|
||||
def _codes(*values) -> tuple[str, ...]:
|
||||
return tuple(str(getattr(v, "value", v)) for v in values)
|
||||
|
||||
|
||||
RCT_POLICIES: dict[RCTOperation, RCTPolicy] = {
|
||||
RCTOperation.CANCELA_VAS: RCTPolicy(
|
||||
success=_codes(RCTTag.CANCELA_VAS_OK, RCTTag.CANCELA_VAS_2X_OK, RCTTag.CANCELA_VAS_3X_OK),
|
||||
failure=_codes(RCTTag.CANCELA_VAS_FAIL, RCTTag.CANCELA_VAS_2X_FAIL, RCTTag.CANCELA_VAS_3X_FAIL),
|
||||
),
|
||||
RCTOperation.SGR_CODBAR: RCTPolicy(
|
||||
success=_codes(RCTTag.SGR_CODBAR_OK, RCTTag.SGR_CODBAR_2X_OK, RCTTag.SGR_CODBAR_3X_OK),
|
||||
failure=_codes(RCTTag.SGR_CODBAR_FAIL, RCTTag.SGR_CODBAR_2X_FAIL, RCTTag.SGR_CODBAR_3X_FAIL),
|
||||
),
|
||||
RCTOperation.CONTESTACAO: RCTPolicy(
|
||||
success=_codes(RCTTag.CONTESTACAO_OK, RCTTag.CONTESTACAO_2X_OK, RCTTag.CONTESTACAO_3X_OK),
|
||||
failure=_codes(RCTTag.CONTESTACAO_FAIL, RCTTag.CONTESTACAO_2X_FAIL, RCTTag.CONTESTACAO_3X_FAIL),
|
||||
),
|
||||
RCTOperation.DEF_VALOR_AJUSTE: RCTPolicy(
|
||||
success=_codes(RCTTag.DEF_VALOR_AJUSTE_OK, RCTTag.DEF_VALOR_AJUSTE_2X_OK, RCTTag.DEF_VALOR_AJUSTE_3X_OK),
|
||||
failure=_codes(RCTTag.DEF_VALOR_AJUSTE_FAIL, RCTTag.DEF_VALOR_AJUSTE_2X_FAIL, RCTTag.DEF_VALOR_AJUSTE_3X_FAIL),
|
||||
),
|
||||
RCTOperation.REG_ATEND_VAS_AVULSO: RCTPolicy(
|
||||
success=_codes(RCTTag.REG_ATEND_VAS_AVULSO_OK, RCTTag.REG_ATEND_VAS_AVULSO_2X_OK, RCTTag.REG_ATEND_VAS_AVULSO_3X_OK),
|
||||
failure=_codes(RCTTag.REG_ATEND_VAS_AVULSO_FAIL, RCTTag.REG_ATEND_VAS_AVULSO_2X_FAIL, RCTTag.REG_ATEND_VAS_AVULSO_3X_FAIL),
|
||||
),
|
||||
RCTOperation.REG_ATEND_VAS_ESTRAT: RCTPolicy(
|
||||
success=_codes(RCTTag.REG_ATEND_VAS_ESTRAT_OK, RCTTag.REG_ATEND_VAS_ESTRAT_2X_OK, RCTTag.REG_ATEND_VAS_ESTRAT_3X_OK),
|
||||
failure=_codes(RCTTag.REG_ATEND_VAS_ESTRAT_FAIL, RCTTag.REG_ATEND_VAS_ESTRAT_2X_FAIL, RCTTag.REG_ATEND_VAS_ESTRAT_3X_FAIL),
|
||||
),
|
||||
RCTOperation.PDF_FATURA: RCTPolicy(
|
||||
success=_codes(RCTTag.PDF_FATURA_OK, RCTTag.PDF_FATURA_2X_OK, RCTTag.PDF_FATURA_3X_OK),
|
||||
failure=_codes(RCTTag.PDF_FATURA_FAIL, RCTTag.PDF_FATURA_2X_FAIL, RCTTag.PDF_FATURA_3X_FAIL),
|
||||
),
|
||||
RCTOperation.BASE_CONHECIMENTO: RCTPolicy(
|
||||
success=_codes(RCTTag.BASE_CONHECIMENTO_OK, RCTTag.BASE_CONHECIMENTO_2X_OK, RCTTag.BASE_CONHECIMENTO_3X_OK),
|
||||
failure=_codes(RCTTag.BASE_CONHECIMENTO_FAIL, RCTTag.BASE_CONHECIMENTO_2X_FAIL, RCTTag.BASE_CONHECIMENTO_3X_FAIL),
|
||||
),
|
||||
RCTOperation.REG_CHAMADO_BO_RT15: RCTPolicy(
|
||||
success=_codes(RCTTag.REG_CHAMADO_BO_RT15_OK, RCTTag.REG_CHAMADO_BO_RT15_2X_OK, RCTTag.REG_CHAMADO_BO_RT15_3X_OK),
|
||||
failure=_codes(RCTTag.REG_CHAMADO_BO_RT15_FAIL, RCTTag.REG_CHAMADO_BO_RT15_2X_FAIL, RCTTag.REG_CHAMADO_BO_RT15_3X_FAIL),
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
def rct_tags_for_attempt(operation: RCTOperation | str | None, attempt: int, *, success: bool) -> tuple[str, ...]:
|
||||
if operation is None or attempt < 1:
|
||||
return ()
|
||||
try:
|
||||
op = operation if isinstance(operation, RCTOperation) else RCTOperation(str(operation))
|
||||
except ValueError:
|
||||
return ()
|
||||
policy = RCT_POLICIES.get(op, RCTPolicy())
|
||||
group = policy.success if success else policy.failure
|
||||
idx = attempt - 1
|
||||
return (group[idx],) if idx < len(group) and group[idx] else ()
|
||||
264
app/domain/contas/service.py
Normal file
264
app/domain/contas/service.py
Normal file
@@ -0,0 +1,264 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from .client import TimApiClient
|
||||
|
||||
|
||||
class ContasDomainService:
|
||||
"""Business services for Contas.
|
||||
|
||||
Important: this is *not* an agent runtime or workflow engine. Conversation,
|
||||
confirmation, clarification, checkpoints and branching remain in LangGraph/
|
||||
agent_framework_oci. These methods implement only TIM business operations.
|
||||
"""
|
||||
|
||||
def __init__(self, client: TimApiClient | None = None) -> None:
|
||||
self.client = client or TimApiClient()
|
||||
|
||||
@staticmethod
|
||||
def _products(payload: Any) -> list[dict[str, Any]]:
|
||||
if not isinstance(payload, dict):
|
||||
return []
|
||||
value = payload.get("products") or payload.get("services") or []
|
||||
return [x for x in value if isinstance(x, dict)]
|
||||
|
||||
@staticmethod
|
||||
def _match_service(products: list[dict[str, Any]], subject: str) -> dict[str, Any] | None:
|
||||
needle = (subject or "").strip().lower()
|
||||
if not needle:
|
||||
return None
|
||||
exact = [p for p in products if str(p.get("name") or p.get("description") or "").strip().lower() == needle]
|
||||
if exact:
|
||||
return exact[0]
|
||||
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
|
||||
|
||||
def consultar_faturas(self, *, msisdn: str, **_: Any) -> Any:
|
||||
return self.client.consultar_faturas(msisdn)
|
||||
|
||||
def invoice_explanation(self, *, msisdn: str, **args: Any) -> dict[str, Any]:
|
||||
# Reuse framework-prefetched evidence when available; the domain never owns
|
||||
# a second cache/session implementation. Explanation itself is produced by
|
||||
# the framework LLM.
|
||||
complete = args.get("complete_invoices_payload")
|
||||
if not isinstance(complete, dict):
|
||||
complete = self.client.consultar_faturas(msisdn)
|
||||
billing = args.get("billing_analysis")
|
||||
if not isinstance(billing, dict):
|
||||
billing = self.client.billing_analysis(msisdn, invoice_id=args.get("invoice_id"), customer_id=args.get("customer_id"))
|
||||
return {
|
||||
"complete_invoices": complete,
|
||||
"billing_analysis": billing,
|
||||
"instruction": "Use estes dados como evidência para explicar composição/variação da fatura. Não invente cobranças ausentes.",
|
||||
}
|
||||
|
||||
def consultar_vas(self, *, msisdn: str, **_: Any) -> Any:
|
||||
return self.client.consultar_vas(msisdn)
|
||||
|
||||
def consultar_historico_vas(self, *, msisdn: str, **_: Any) -> Any:
|
||||
return self.client.historico_vas(msisdn)
|
||||
|
||||
@staticmethod
|
||||
def _history_products(payload: Any) -> list[dict[str, Any]]:
|
||||
if not isinstance(payload, dict):
|
||||
return []
|
||||
services = payload.get("services") or payload.get("products") or []
|
||||
out: list[dict[str, Any]] = []
|
||||
for item in services if isinstance(services, list) else []:
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
row = dict(item)
|
||||
row.setdefault("name", row.get("description") or row.get("billDescription") or "")
|
||||
row.setdefault("cspId", row.get("csp_id"))
|
||||
row.setdefault("appId", row.get("app_id"))
|
||||
if "can" not in row:
|
||||
row["can"] = {"cancel": bool(row.get("canCancel", False))}
|
||||
out.append(row)
|
||||
return out
|
||||
|
||||
@staticmethod
|
||||
def _service_can_cancel(service: dict[str, Any]) -> bool:
|
||||
can = service.get("can") if isinstance(service.get("can"), dict) else {}
|
||||
details = service.get("details") if isinstance(service.get("details"), dict) else {}
|
||||
details_can = details.get("can") if isinstance(details.get("can"), dict) else {}
|
||||
values = [
|
||||
can.get("cancel"),
|
||||
details_can.get("cancel"),
|
||||
service.get("canCancel"),
|
||||
service.get("can_cancel"),
|
||||
]
|
||||
explicit = [value for value in values if value is not None]
|
||||
return bool(explicit[0]) if explicit else True
|
||||
|
||||
def cancelar_vas_avulso(self, *, msisdn: str, subject: str, protocol: str = "", **_: Any) -> dict[str, Any]:
|
||||
current = self.client.consultar_vas(msisdn)
|
||||
active_products = self._products(current)
|
||||
service = self._match_service(active_products, subject)
|
||||
source = "active"
|
||||
history = None
|
||||
if not service:
|
||||
history = self.client.historico_vas(msisdn)
|
||||
history_products = self._history_products(history)
|
||||
service = self._match_service(history_products, subject)
|
||||
source = "history" if service else "none"
|
||||
if not service:
|
||||
return {
|
||||
"success": False,
|
||||
"error": f"Serviço '{subject}' não encontrado na consulta VAS nem no histórico",
|
||||
"reason": "service_not_found",
|
||||
"services": active_products,
|
||||
"history": history,
|
||||
"evidence_source": source,
|
||||
}
|
||||
if not self._service_can_cancel(service):
|
||||
return {
|
||||
"success": False,
|
||||
"error": "Serviço já se encontra cancelado, não sendo possível realizar recancelamento.",
|
||||
"reason": "already_inactive_or_blocked",
|
||||
"service": service,
|
||||
"evidence_source": source,
|
||||
}
|
||||
try:
|
||||
block = self.client.bloquear_vas(msisdn, service)
|
||||
except Exception as exc:
|
||||
return {
|
||||
"success": False,
|
||||
"service": service,
|
||||
"error": str(exc),
|
||||
"reason": "block_vas_failed",
|
||||
"evidence_source": source,
|
||||
"eligible_for_contestation": True,
|
||||
}
|
||||
try:
|
||||
cancel = self.client.cancelar_vas(msisdn, service, protocol=protocol)
|
||||
except Exception as exc:
|
||||
return {
|
||||
"success": False,
|
||||
"service": service,
|
||||
"block": block,
|
||||
"error": str(exc),
|
||||
"reason": "cancel_vas_failed",
|
||||
"evidence_source": source,
|
||||
"eligible_for_contestation": True,
|
||||
}
|
||||
return {
|
||||
"success": True,
|
||||
"service": service,
|
||||
"block": block,
|
||||
"cancellation": cancel,
|
||||
"evidence_source": source,
|
||||
"eligible_for_contestation": True,
|
||||
}
|
||||
|
||||
def tratar_vas_estrategico(self, *, msisdn: str, subject: str, accepted_explanation: bool | None = None, **_: Any) -> dict[str, Any]:
|
||||
current = self.client.consultar_vas(msisdn)
|
||||
service = self._match_service(self._products(current), subject)
|
||||
return {
|
||||
"success": bool(service),
|
||||
"service": service,
|
||||
"accepted_explanation": accepted_explanation,
|
||||
"guidance": "VAS estratégico/bundle usa orientação conversacional do agente; não executar cancelamento automático sem tool transacional específica e confirmação do framework.",
|
||||
}
|
||||
|
||||
def contestar_cobranca(self, *, msisdn: str, subject: str, valor: Any, motivo: str = "", **args: Any) -> dict[str, Any]:
|
||||
# Domain orchestration only: all conversational confirmation has already happened in framework runtime.
|
||||
invoices = self.client.consultar_faturas(msisdn)
|
||||
contract = self.client.contrato(msisdn)
|
||||
profile = self.client.profile_full(msisdn)
|
||||
protocol_payload = {
|
||||
"msisdn": msisdn,
|
||||
"source": os_source(args),
|
||||
"reason1": "CONTESTACAO",
|
||||
"reason2": subject,
|
||||
"reason3": motivo,
|
||||
"status": "OPENED",
|
||||
"requestStatus": "Aberto",
|
||||
}
|
||||
protocol = self.client.abrir_protocolo(protocol_payload)
|
||||
protocol_number = str(protocol.get("interactionProtocol") or protocol.get("protocolNumber") or args.get("protocol") or "") if isinstance(protocol, dict) else ""
|
||||
customer = (((invoices or {}).get("billingProfile") or {}).get("customer") or {}) if isinstance(invoices, dict) else {}
|
||||
contest_payload = {
|
||||
"msisdn": msisdn,
|
||||
"sr": protocol_number,
|
||||
"socialSecNo": customer.get("document") or args.get("social_sec_no") or "",
|
||||
"customerId": customer.get("customerId") or customer.get("id") or args.get("customer_id") or "",
|
||||
"invoiceNumber": args.get("invoice_id") or "",
|
||||
"userId": "AIAGENTCR",
|
||||
"items": [{"itemName": subject, "itemType": "VAS_AVULSO", "claimedAmount": str(valor), "validatedAmount": str(valor)}],
|
||||
"description": motivo,
|
||||
}
|
||||
contestation = self.client.contestar(contest_payload)
|
||||
tracking = self.client.tracking({"msisdn": msisdn, "protocolNumber": protocol_number, "activityType": "Contestação", "activityStatus": "Aberto"})
|
||||
return {"success": True, "protocol": protocol, "contestation": contestation, "tracking": tracking, "invoices": invoices, "contract": contract, "profile": profile}
|
||||
|
||||
def consultar_status_solicitacao(self, *, msisdn: str = "", protocol: str = "", **_: Any) -> Any:
|
||||
return self.client.status_sr({"msisdn": msisdn, "protocolNumber": protocol, "status": "CONSULTA", "channel": "AIAGENTCR"})
|
||||
|
||||
def enviar_sms(self, *, msisdn: str, message: str, **_: Any) -> Any:
|
||||
return self.client.sms(msisdn, message)
|
||||
|
||||
def buscar_fatura_detalhada(
|
||||
self,
|
||||
*,
|
||||
msisdn: str,
|
||||
invoice_id: str,
|
||||
customer_id: str = "",
|
||||
include_danfe: bool = False,
|
||||
output: str = "",
|
||||
**_: Any,
|
||||
) -> Any:
|
||||
return self.client.bill_pdf(
|
||||
msisdn, invoice_id, customer_id, include_danfe=include_danfe, output=output
|
||||
)
|
||||
|
||||
def perfil_fatura(self, *, msisdn: str, **_: Any) -> Any:
|
||||
return self.client.profile_bill(msisdn)
|
||||
|
||||
def info_linha(self, *, msisdn: str, **_: Any) -> Any:
|
||||
return self.client.line_info(msisdn)
|
||||
|
||||
def recuperar_fatura_pdf(self, *, msisdn: str, invoice_id: str, customer_id: str = "", **_: Any) -> Any:
|
||||
return self.client.secure_pdf(msisdn, invoice_id, customer_id)
|
||||
|
||||
@staticmethod
|
||||
def _normalize_final_status(status: str) -> str:
|
||||
valid = {
|
||||
"resolvido", "nao_resolvido", "resolvido_outros_assuntos",
|
||||
"outros_assuntos", "erro_falha_sistema", "erro_no_match", "erro_no_input",
|
||||
}
|
||||
aliases = {
|
||||
"0": "resolvido", "1": "nao_resolvido",
|
||||
"2": "resolvido_outros_assuntos", "3": "outros_assuntos",
|
||||
"final": "resolvido",
|
||||
}
|
||||
value = str(status or "").strip().lower()
|
||||
value = aliases.get(value, value)
|
||||
return value if value in valid else "erro_falha_sistema"
|
||||
|
||||
@staticmethod
|
||||
def _normalize_final_summary(summary: str) -> str:
|
||||
value = str(summary or "").strip()
|
||||
return "Encerramento realizado pelo agente." + (f" {value}" if value else "")
|
||||
|
||||
def finalizar_atendimento(self, *, status: str = "resolvido", summary: str = "", **args: Any) -> dict[str, Any]:
|
||||
normalized_status = self._normalize_final_status(status)
|
||||
normalized_summary = self._normalize_final_summary(summary)
|
||||
protocol = args.get("protocol") or args.get("ura_call_id") or ""
|
||||
response: dict[str, Any] = {
|
||||
"success": True,
|
||||
"status": normalized_status,
|
||||
"summary": normalized_summary,
|
||||
}
|
||||
if protocol:
|
||||
response["service_request_status"] = self.client.status_sr({
|
||||
"protocolNumber": protocol,
|
||||
"status": "Fechado",
|
||||
"channel": "AIAGENTCR",
|
||||
"notes": normalized_summary,
|
||||
})
|
||||
return response
|
||||
|
||||
|
||||
def os_source(args: dict[str, Any]) -> str:
|
||||
return str(args.get("channel") or "AIAGENTCR")
|
||||
118
app/domain/contas/string_metrics.py
Normal file
118
app/domain/contas/string_metrics.py
Normal file
@@ -0,0 +1,118 @@
|
||||
"""Pure-Python string metrics used when the optional ``jellyfish`` wheel is absent.
|
||||
|
||||
The production project may still use jellyfish as an accelerator. These
|
||||
implementations keep the business matcher deterministic and testable in
|
||||
restricted/offline environments (including CPython 3.13 builders without
|
||||
access to PyPI).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import unicodedata
|
||||
|
||||
|
||||
def _norm(value: str) -> str:
|
||||
value = unicodedata.normalize("NFKD", str(value).lower())
|
||||
value = "".join(ch for ch in value if not unicodedata.combining(ch))
|
||||
return re.sub(r"[^a-z0-9]+", "", value)
|
||||
|
||||
|
||||
def levenshtein_distance(a: str, b: str) -> int:
|
||||
a, b = str(a), str(b)
|
||||
if a == b:
|
||||
return 0
|
||||
if not a:
|
||||
return len(b)
|
||||
if not b:
|
||||
return len(a)
|
||||
if len(a) > len(b):
|
||||
a, b = b, a
|
||||
previous = list(range(len(a) + 1))
|
||||
for i, cb in enumerate(b, 1):
|
||||
current = [i]
|
||||
for j, ca in enumerate(a, 1):
|
||||
current.append(min(
|
||||
current[-1] + 1,
|
||||
previous[j] + 1,
|
||||
previous[j - 1] + (ca != cb),
|
||||
))
|
||||
previous = current
|
||||
return previous[-1]
|
||||
|
||||
|
||||
def jaro_similarity(a: str, b: str) -> float:
|
||||
a, b = str(a), str(b)
|
||||
if a == b:
|
||||
return 1.0
|
||||
if not a or not b:
|
||||
return 0.0
|
||||
match_distance = max(len(a), len(b)) // 2 - 1
|
||||
match_distance = max(match_distance, 0)
|
||||
a_match = [False] * len(a)
|
||||
b_match = [False] * len(b)
|
||||
matches = 0
|
||||
for i, ca in enumerate(a):
|
||||
lo = max(0, i - match_distance)
|
||||
hi = min(i + match_distance + 1, len(b))
|
||||
for j in range(lo, hi):
|
||||
if b_match[j] or ca != b[j]:
|
||||
continue
|
||||
a_match[i] = True
|
||||
b_match[j] = True
|
||||
matches += 1
|
||||
break
|
||||
if matches == 0:
|
||||
return 0.0
|
||||
a_chars = [a[i] for i in range(len(a)) if a_match[i]]
|
||||
b_chars = [b[j] for j in range(len(b)) if b_match[j]]
|
||||
transpositions = sum(x != y for x, y in zip(a_chars, b_chars)) / 2.0
|
||||
m = float(matches)
|
||||
return (m / len(a) + m / len(b) + (m - transpositions) / m) / 3.0
|
||||
|
||||
|
||||
def jaro_winkler_similarity(a: str, b: str, *, scaling: float = 0.1) -> float:
|
||||
jaro = jaro_similarity(a, b)
|
||||
prefix = 0
|
||||
for ca, cb in zip(str(a), str(b)):
|
||||
if ca != cb or prefix == 4:
|
||||
break
|
||||
prefix += 1
|
||||
return jaro + prefix * scaling * (1.0 - jaro)
|
||||
|
||||
|
||||
def metaphone(value: str) -> str:
|
||||
"""Small deterministic phonetic key tuned for Portuguese ASR item names.
|
||||
|
||||
It intentionally mirrors the role (not the implementation) of Jellyfish's
|
||||
Metaphone: collapse spelling variants so the matcher can rank transcript
|
||||
errors. The key is conservative and is only one signal alongside
|
||||
Jaro-Winkler/token similarity.
|
||||
"""
|
||||
s = _norm(value)
|
||||
if not s:
|
||||
return ""
|
||||
replacements = (
|
||||
("sch", "x"), ("sh", "x"), ("ch", "x"), ("ph", "f"),
|
||||
("th", "t"), ("nh", "n"), ("lh", "l"), ("qu", "k"),
|
||||
("gu", "g"), ("ck", "k"),
|
||||
)
|
||||
for old, new in replacements:
|
||||
s = s.replace(old, new)
|
||||
trans = str.maketrans({
|
||||
"c": "k", "q": "k", "k": "k",
|
||||
"g": "j", "j": "j",
|
||||
"v": "f", "f": "f",
|
||||
"z": "s", "s": "s", "x": "x",
|
||||
"d": "t", "t": "t",
|
||||
"b": "p", "p": "p",
|
||||
"y": "i", "w": "u",
|
||||
})
|
||||
s = s.translate(trans)
|
||||
first = s[0]
|
||||
tail = "".join(ch for ch in s[1:] if ch not in "aeiou")
|
||||
code = first + tail
|
||||
code = re.sub(r"(.)\1+", r"\1", code)
|
||||
return code.upper()
|
||||
|
||||
|
||||
__all__ = ["jaro_winkler_similarity", "levenshtein_distance", "metaphone"]
|
||||
371
app/domain/contas/vas_cancellation_message.py
Normal file
371
app/domain/contas/vas_cancellation_message.py
Normal file
@@ -0,0 +1,371 @@
|
||||
"""Composição determinística da resposta ao cliente após cancelamento de VAS avulso.
|
||||
|
||||
FONTE ÚNICA das regras da fala ao cliente neste fluxo. Antes, o texto era gerado por
|
||||
uma chamada LLM (capability ``fluxo_vas_cancelamento_resposta_cliente``, hoje
|
||||
REMOVIDA) que apenas renderizava prosa a partir do payload. Como cada ramo é função
|
||||
pura de campos já conhecidos (``sms_sent``, ``contested_items``,
|
||||
``contestation_error_description`` etc.), a composição vive aqui: mesma saída
|
||||
canônica, sem prompt, sem LLM, sem não-determinismo. O runtime entrega o texto
|
||||
verbatim ao cliente.
|
||||
|
||||
Regras (mantenha esta seção como o spec ao alterar comportamento):
|
||||
- Ordem de montagem: (a) resultado [regras 1.1, 2–5] → (b) SMS [regra 6] →
|
||||
(c) protocolo [regra 7].
|
||||
- Regra 1.1: itens já contestados + itens contestados nesta solicitação (partes
|
||||
independentes, concordância por lista); destino do valor por ``sms_sent``.
|
||||
- Regra 2 (``sms_sent=false``): crédito na próxima fatura.
|
||||
- Regra 3 (``sms_sent=true``): a confirmação canônica de cancelamento, retirada
|
||||
do valor e envio do novo código por SMS prevalece mesmo sem itens novos.
|
||||
- Regra 5: itens que não puderam ser contestados.
|
||||
- Regra 6: SMS 6.1 (ok) / 6.2 (falha) mutuamente exclusivas por ``sms_not_send_error``.
|
||||
- Regra 7: protocolo(s) em forma canônica, sempre ao final.
|
||||
- Regra 8: erro terminal → copia ``contestation_error_description`` literal + protocolo;
|
||||
para item já contestado, informa antes os cancelamentos efetivados.
|
||||
- No-match de cancelamento (``nao_encontrados`` não vazio): serviço não localizado
|
||||
na plataforma por divergência de nome → cancelamento não executado, mas o valor
|
||||
foi contestado. Estrutura própria (``_compose_no_match``): (a) valor total
|
||||
creditado/retirado [+ SMS] → (b) cancelamentos efetivados (``cancelados``, caso
|
||||
misto) → (c) no-match + orientação App Meu TIM (opção Gerenciar Benefícios) → protocolo.
|
||||
- Residual: sucesso sem itens detalhados → confirmação genérica neutra + protocolo.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Mapping, Sequence
|
||||
from typing import Any
|
||||
|
||||
|
||||
def _clean(value: Any) -> str:
|
||||
return str(value or "").strip()
|
||||
|
||||
|
||||
def _item_name(item: Any) -> str:
|
||||
if isinstance(item, Mapping):
|
||||
return _clean(
|
||||
item.get("itemName")
|
||||
or item.get("item_name")
|
||||
or item.get("name")
|
||||
or item.get("servico")
|
||||
or item.get("service")
|
||||
)
|
||||
return _clean(item)
|
||||
|
||||
|
||||
def _names(items: Any) -> list[str]:
|
||||
if not isinstance(items, Sequence) or isinstance(items, (str, bytes)):
|
||||
return []
|
||||
return [name for name in (_item_name(item) for item in items) if name]
|
||||
|
||||
|
||||
def _join_e(names: Sequence[str]) -> str:
|
||||
"""Junção natural pt-BR: "A"; "A e B"; "A, B e C"."""
|
||||
names = list(names)
|
||||
if not names:
|
||||
return ""
|
||||
if len(names) == 1:
|
||||
return names[0]
|
||||
return f"{', '.join(names[:-1])} e {names[-1]}"
|
||||
|
||||
|
||||
def _is_already_disputed_error(description: str) -> bool:
|
||||
normalized = description.casefold()
|
||||
return any(
|
||||
marker in normalized
|
||||
for marker in (
|
||||
"já contestado",
|
||||
"já foi contestado",
|
||||
"já foram contestados",
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def _cancellation_success_sentence(canceled: Sequence[str]) -> str:
|
||||
if len(canceled) == 1:
|
||||
return f"O cancelamento do item {canceled[0]} foi concluído com sucesso."
|
||||
return (
|
||||
f"O cancelamento dos itens {_join_e(canceled)} foi concluído com sucesso."
|
||||
)
|
||||
|
||||
|
||||
def _protocol_sentence(payload: Mapping[str, Any]) -> str:
|
||||
"""Regra 7 — protocolo em forma canônica (ex.: ``PRT475D8F1C1F``)."""
|
||||
protocols = [_clean(p) for p in (payload.get("contestacao_protocols") or [])]
|
||||
protocols = [p for p in protocols if p]
|
||||
single = _clean(payload.get("contestacao_protocol"))
|
||||
if not protocols and single:
|
||||
protocols = [single]
|
||||
if not protocols:
|
||||
return ""
|
||||
if len(protocols) == 1:
|
||||
return f"Seu número de protocolo é {protocols[0]}."
|
||||
return f"Seus números de protocolo são {_join_e(protocols)}."
|
||||
|
||||
|
||||
def _sms_sentence(payload: Mapping[str, Any]) -> str:
|
||||
"""Regra 6 — mensagens 6.1/6.2 mutuamente exclusivas por ``sms_not_send_error``."""
|
||||
if bool(payload.get("sms_not_send_error")):
|
||||
return (
|
||||
"Identificamos uma instabilidade no envio do SMS com o novo código "
|
||||
"para pagamento. Você pode consultar o código atualizado e o prazo "
|
||||
"de pagamento diretamente no app Meu TIM."
|
||||
)
|
||||
return (
|
||||
"Enviamos um SMS com o novo código de barras e o valor atualizado. "
|
||||
"O prazo para pagamento é de 4 dias."
|
||||
)
|
||||
|
||||
|
||||
def _compose_no_match(
|
||||
payload: Mapping[str, Any],
|
||||
*,
|
||||
amount: str,
|
||||
sms_sent: bool,
|
||||
contested: list[str],
|
||||
not_contested: list[str],
|
||||
canceled: list[str],
|
||||
no_match: list[str],
|
||||
protocol: str,
|
||||
) -> str:
|
||||
"""No-match de cancelamento: serviço(s) não localizado(s) na plataforma por
|
||||
divergência de nome → o cancelamento não foi executado pelo canal, mas o valor
|
||||
foi contestado (ajuste em fatura). Estrutura: (a) valor [+ SMS] → (b) lista de
|
||||
cancelamentos com sucesso (se houver) → (c) no-match + orientação App Meu TIM →
|
||||
(d) protocolo. A frase de encerramento é anexada pelo runtime, não aqui.
|
||||
"""
|
||||
parts: list[str] = []
|
||||
|
||||
# (a) Destino do valor contestado — só quando houve contestação de fato.
|
||||
if contested or amount not in ("", "0,00"):
|
||||
if sms_sent:
|
||||
parts.append(f"O valor total de R$ {amount} foi retirado da sua fatura.")
|
||||
parts.append(_sms_sentence(payload))
|
||||
else:
|
||||
parts.append(
|
||||
f"O valor total de R$ {amount} ficou registrado como crédito para "
|
||||
"sua próxima fatura."
|
||||
)
|
||||
|
||||
# (b) Cancelamentos efetivados (caso misto).
|
||||
if canceled:
|
||||
if len(canceled) == 1:
|
||||
parts.append(
|
||||
f"O cancelamento do serviço {canceled[0]} foi realizado com sucesso."
|
||||
)
|
||||
else:
|
||||
parts.append(
|
||||
f"O cancelamento dos serviços {_join_e(canceled)} foram realizados "
|
||||
"com sucesso."
|
||||
)
|
||||
|
||||
# (c) No-match + orientação para o App Meu TIM. A forma da frase difere entre
|
||||
# o caso misto (há lista de sucesso antes) e o caso tudo-no-match.
|
||||
if canceled:
|
||||
if len(no_match) == 1:
|
||||
parts.append(
|
||||
f"Não consegui realizar por aqui o cancelamento do serviço "
|
||||
f"{no_match[0]}. Você pode cancelá-lo pelo App Meu TIM, na opção "
|
||||
"Gerenciar Benefícios."
|
||||
)
|
||||
else:
|
||||
parts.append(
|
||||
f"Não consegui realizar por aqui o cancelamento dos serviços "
|
||||
f"{_join_e(no_match)}. Você pode cancelá-los pelo App Meu TIM, na "
|
||||
"opção Gerenciar Benefícios."
|
||||
)
|
||||
else:
|
||||
if len(no_match) == 1:
|
||||
parts.append(
|
||||
f"Sobre o cancelamento do serviço {no_match[0]}, não consegui "
|
||||
"realizar por aqui. Você pode cancelar pelo App Meu TIM, na opção "
|
||||
"Gerenciar Benefícios."
|
||||
)
|
||||
else:
|
||||
parts.append(
|
||||
f"Sobre o cancelamento dos serviços {_join_e(no_match)}, não "
|
||||
"consegui realizar por aqui. Você pode cancelar pelo App Meu TIM, "
|
||||
"na opção Gerenciar Benefícios."
|
||||
)
|
||||
|
||||
# Regra 5 — itens que não puderam ser contestados (raro combinado com no-match).
|
||||
if not_contested:
|
||||
parts.append(
|
||||
f"Não foi possível contestar o item {', '.join(not_contested)}."
|
||||
)
|
||||
|
||||
if protocol:
|
||||
parts.append(protocol)
|
||||
|
||||
return " ".join(part.strip() for part in parts if part.strip()).strip()
|
||||
|
||||
|
||||
def compose_vas_cancellation_message(payload: Mapping[str, Any]) -> str:
|
||||
"""Monta a resposta ao cliente a partir do payload estruturado.
|
||||
|
||||
Retorna ``""`` apenas quando não há sucesso nem nenhuma regra aplicável
|
||||
(ex.: erro sem descrição). Nesse caso o backend aplica sua mensagem de erro
|
||||
genérica — não há chamada LLM em nenhum caminho.
|
||||
"""
|
||||
if not isinstance(payload, Mapping):
|
||||
return ""
|
||||
|
||||
error_desc = _clean(payload.get("contestation_error_description"))
|
||||
protocol = _protocol_sentence(payload)
|
||||
canceled = _names(payload.get("cancelados"))
|
||||
|
||||
# Regra 8 — contestação recusada pela API: preserva os cancelamentos que já
|
||||
# ocorreram, mas deixa claro que uma nova contestação não pôde ser registrada.
|
||||
if error_desc:
|
||||
parts: list[str] = []
|
||||
if canceled and _is_already_disputed_error(error_desc):
|
||||
parts.append(_cancellation_success_sentence(canceled))
|
||||
if not error_desc.endswith((".", "!", "?")) and protocol:
|
||||
error_desc = f"{error_desc}."
|
||||
parts.append(error_desc)
|
||||
if protocol:
|
||||
parts.append(protocol)
|
||||
return " ".join(parts)
|
||||
|
||||
amount = _clean(payload.get("contested_invoice_amount_open"))
|
||||
sms_sent = bool(payload.get("sms_sent"))
|
||||
contested = _names(payload.get("contested_items"))
|
||||
not_contested = _names(payload.get("not_contested_items"))
|
||||
already = [_clean(x) for x in (payload.get("itens_ja_contestados") or [])]
|
||||
already = [x for x in already if x]
|
||||
no_match = _names(payload.get("nao_encontrados"))
|
||||
|
||||
# No-match de cancelamento (nome divergente na plataforma): o cancelamento não
|
||||
# foi executado, mas o valor foi contestado. Assume a estrutura própria
|
||||
# (lidera pelo valor + orienta App Meu TIM), distinta das regras 1.1/2–5.
|
||||
if no_match:
|
||||
return _compose_no_match(
|
||||
payload,
|
||||
amount=amount,
|
||||
sms_sent=sms_sent,
|
||||
contested=contested,
|
||||
not_contested=not_contested,
|
||||
canceled=canceled,
|
||||
no_match=no_match,
|
||||
protocol=protocol,
|
||||
)
|
||||
|
||||
# Regra 3 — o envio do novo boleto por SMS é o sinal definitivo sobre o
|
||||
# destino do ajuste. Ele prevalece sobre ``contested_items`` vazio (por
|
||||
# exemplo, quando a API não devolve os itens detalhados) e nunca pode cair
|
||||
# no texto de crédito para a próxima fatura.
|
||||
if sms_sent and not bool(payload.get("sms_not_send_error")):
|
||||
parts = [
|
||||
"O cancelamento foi concluído com sucesso. O valor contestado foi "
|
||||
"retirado da sua fatura.",
|
||||
_sms_sentence(payload),
|
||||
]
|
||||
if not_contested:
|
||||
parts.append(
|
||||
f"Não foi possível contestar o item {', '.join(not_contested)}."
|
||||
)
|
||||
if protocol:
|
||||
parts.append(protocol)
|
||||
return " ".join(part.strip() for part in parts if part.strip()).strip()
|
||||
|
||||
# Sem nenhum item detalhado (contestado, não contestado ou já contestado):
|
||||
# não há como aplicar as regras 1.1/2–5. Se o cancelamento teve sucesso,
|
||||
# devolve uma confirmação genérica neutra (não promete crédito nem boleto,
|
||||
# que dependem dos itens); o protocolo entra pela regra 7 (ou pelo append do
|
||||
# backend, quando só há protocolo de cancelamento). Sem sucesso, devolve ""
|
||||
# para o backend aplicar sua mensagem de erro genérica.
|
||||
if not (contested or not_contested or already):
|
||||
if not bool(payload.get("success")):
|
||||
return ""
|
||||
generico = "O cancelamento foi concluído com sucesso."
|
||||
return f"{generico} {protocol}".strip() if protocol else generico
|
||||
|
||||
def _ja_contestados_notice() -> str:
|
||||
# Parte 1 da regra 1.1 (concordância pela própria lista).
|
||||
if len(already) == 1:
|
||||
return (
|
||||
f"Identifiquei que o item {already[0]} já havia sido contestado "
|
||||
"anteriormente e, por esse motivo, não é possível registrar uma "
|
||||
"nova contestação para ele."
|
||||
)
|
||||
return (
|
||||
f"Identifiquei que os itens {_join_e(already)} já haviam sido "
|
||||
"contestados anteriormente e, por esse motivo, não é possível "
|
||||
"registrar uma nova contestação para eles."
|
||||
)
|
||||
|
||||
parts: list[str] = []
|
||||
emit_sms = False
|
||||
|
||||
if already and contested:
|
||||
# Regra 1.1 — misto: parte por lista própria (concordância independente).
|
||||
parts.append(_ja_contestados_notice())
|
||||
if len(contested) == 1:
|
||||
parts.append(
|
||||
f"Já a contestação do item {contested[0]} no valor de R$ {amount} "
|
||||
"foi concluída com sucesso."
|
||||
)
|
||||
else:
|
||||
parts.append(
|
||||
f"Já a contestação dos itens {_join_e(contested)} no valor total "
|
||||
f"de R$ {amount} foi concluída com sucesso."
|
||||
)
|
||||
if sms_sent:
|
||||
parts.append("O valor contestado já foi retirado da sua fatura.")
|
||||
emit_sms = True
|
||||
else:
|
||||
parts.append(
|
||||
"O valor contestado ficou registrado como crédito para sua "
|
||||
"próxima fatura."
|
||||
)
|
||||
# Regras 2 e 3 NÃO se aplicam neste caso.
|
||||
elif already and not contested:
|
||||
# Gap fora das regras 1.1/2/3/4 (só itens já contestados, sem SMS): nada de
|
||||
# novo foi contestado, então só o aviso "já contestado" — não afirma
|
||||
# cancelamento/crédito que não ocorreu.
|
||||
parts.append(_ja_contestados_notice())
|
||||
elif sms_sent:
|
||||
# Regra 3 — valor retirado da fatura (novo boleto por SMS).
|
||||
if len(contested) == 1:
|
||||
parts.append(
|
||||
f"O cancelamento do item {contested[0]} no valor de R$ {amount} "
|
||||
"foi concluído com sucesso. O valor contestado já foi retirado da "
|
||||
"sua fatura."
|
||||
)
|
||||
else:
|
||||
parts.append(
|
||||
"O cancelamento dos itens foi concluído com sucesso. O valor total "
|
||||
f"de R$ {amount} foi retirado da sua fatura."
|
||||
)
|
||||
emit_sms = True
|
||||
else:
|
||||
# Regra 2 — crédito na próxima fatura (sms_sent=false).
|
||||
if len(contested) == 1:
|
||||
parts.append(
|
||||
f"O cancelamento do item {contested[0]} no valor de R$ {amount} "
|
||||
"foi concluído com sucesso. O valor contestado ficou registrado "
|
||||
"como crédito para sua próxima fatura."
|
||||
)
|
||||
else:
|
||||
parts.append(
|
||||
"O cancelamento dos itens foi feito com sucesso. O crédito no valor "
|
||||
f"total de R$ {amount} ficou registrado como crédito para sua "
|
||||
"próxima fatura."
|
||||
)
|
||||
|
||||
if not parts:
|
||||
return ""
|
||||
|
||||
# Regra 5 — itens que não puderam ser contestados (parte (a) do resultado).
|
||||
if not_contested:
|
||||
parts.append(
|
||||
f"Não foi possível contestar o item {', '.join(not_contested)}."
|
||||
)
|
||||
|
||||
# (b) Notificação de SMS.
|
||||
if emit_sms:
|
||||
parts.append(_sms_sentence(payload))
|
||||
|
||||
# (c) Protocolo, sempre.
|
||||
if protocol:
|
||||
parts.append(protocol)
|
||||
|
||||
return " ".join(part.strip() for part in parts if part.strip()).strip()
|
||||
918
app/domain/contas/vas_variation.py
Normal file
918
app/domain/contas/vas_variation.py
Normal file
@@ -0,0 +1,918 @@
|
||||
"""vas_variation: quais VAS variaram entre a fatura passada e a atual.
|
||||
|
||||
Responde, deterministicamente: *que cobranças de VAS entraram (ou subiram de valor)
|
||||
na fatura atual em relação à passada?* Dois consumidores dependem dessa resposta, e é
|
||||
de propósito que ela viva num lugar só — duas implementações dariam respostas
|
||||
diferentes para a mesma fatura. Eles pedem RECORTES DE CLASSE diferentes, porque a
|
||||
pergunta de negócio de cada um é diferente:
|
||||
|
||||
- **Retenção de VAS no pedido de humano** (SPEC §9) → :func:`varied_avulso_items`: só
|
||||
**avulso**, porque o agente promete *cancelar* o que ofereceu, e só avulso é
|
||||
cancelável (SPEC §11). Estratégico/bundle nunca disparam retenção.
|
||||
- **Ramo NÃO do ``invoice_explanation``** (SPEC §9 "Explicação da variação recusada")
|
||||
→ :func:`varied_vas_charges`: **avulso ou estratégico**, lidos direto dos grupos da
|
||||
análise. Aqui a pergunta não é "o que eu cancelo?", é "existe algo que eu resolva?" —
|
||||
e o estratégico o agente resolve pela tool ``vas_estrategico``. Sem nenhum dos dois
|
||||
por trás da variação não há o que o bot resolva, e o atendimento finaliza
|
||||
``nao_resolvido`` em vez de virar improviso do orquestrador.
|
||||
|
||||
**Só a retenção cruza com o PDF.** O ``type`` do grupo de análise já é a classe, então
|
||||
o ramo NÃO não precisa do ``build_snapshot``: ele só conta o que variou nos dois grupos
|
||||
de VAS. A retenção precisa, porque promete cancelar e tem de saber QUAL linha — e paga
|
||||
por isso o preço de conciliar nome e valor entre dois pipelines.
|
||||
|
||||
``varied_current_charges`` responde só pela variação (o grupo de análise lido é
|
||||
parâmetro); :func:`varied_avulso_items` cruza com a classificação da fatura.
|
||||
|
||||
**Por que ler o payload CRU e não o snapshot do resolver.**
|
||||
``InvoiceResolver._iter_msisdn_buckets`` passa por ``_billing_analysis_sections``,
|
||||
que FUNDE ``currentInvoice`` e ``invoiceVariation`` num único bucket por ``desc``.
|
||||
Isso destrói a partição passada/atual — a informação de que este módulo depende — e
|
||||
faz o snapshot conter itens que existiam SÓ na fatura passada. Por isso aqui as duas
|
||||
listas são lidas cruas do ``invoice_detail``.
|
||||
|
||||
**A regra.** Dentro dos grupos de variação pedidos — ``servicos_contratados_de_parceiros``
|
||||
é o VAS AVULSO e o bucket irmão ``streaming`` é o ESTRATÉGICO (SPEC §12.A) —, cada item
|
||||
carrega o campo ``invoice`` = vencimento da fatura de onde veio. Particiona-se por esse
|
||||
vencimento (o menor é a fatura passada, o maior a atual) e tira-se a diferença de
|
||||
**multiset** por ``(nome normalizado, valor)`` — a multiplicidade importa, porque o mesmo
|
||||
serviço cobrado 2× no ciclo são duas cobranças reais. Só a direção *entrou/subiu* é
|
||||
devolvida: variação causada por REMOÇÃO não tem o que cancelar (decisão de produto).
|
||||
|
||||
Medida (no recorte avulso) contra 88 pares de fatura reais com as faturas normalizadas
|
||||
como verdade: 88/88 exatas, precisão e recall 1.000. O desempate de data única (abaixo)
|
||||
é essencial — sem ele a direção erra e a precisão cai para 0.377.
|
||||
|
||||
**Duas formas de payload.** O runtime entrega ``value`` como string (``'19.9'``) e
|
||||
``invoice`` em ISO-8601 (``'2026-04-07T00:00:00.000Z'``); outra serialização do
|
||||
mesmo pipeline usa float e ``dd/mm/yyyy``. As duas são aceitas.
|
||||
|
||||
**Falha fechada.** Qualquer ausência ou inconsistência que impeça estabelecer a
|
||||
partição devolve tupla vazia — e sem cobrança variada a Policy não oferece retenção
|
||||
(o pedido de humano volta ao single-strike). Nunca promete cancelar o que não sabe
|
||||
que variou.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
from collections import Counter
|
||||
from dataclasses import dataclass
|
||||
from decimal import Decimal
|
||||
from typing import Any, Iterable, Iterator, Mapping, Sequence
|
||||
|
||||
from .invoice_resolver import STRATEGIC_NAMES, InvoiceResolver
|
||||
from .invoice_models import ResolvedInvoiceItem
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Segunda passada por PREFIXO DE TOKENS (``_match_qualified_names``, incidente
|
||||
# e081812886a: variação "FIT ME App" × fatura "FIT ME App Premium Mensal").
|
||||
# DESLIGADA por padrão: o corpus real prova que o mesmo prefixo tanto qualifica o
|
||||
# mesmo produto quanto separa produtos DIFERENTES — ``Fluid Light`` × ``Fluid
|
||||
# Premium`` na mesma fatura com preços distintos, e ``VOD + Canais Abertos`` ×
|
||||
# ``VOD +Canais`` com valor IDÊNTICO (19.90). Como a forma é a mesma nos dois
|
||||
# casos, nenhuma regra sintática os separa, e casar significaria cancelar o
|
||||
# serviço errado sem confirmação (esta rota autoriza a fila direto). Religar só
|
||||
# quando houver identificador de produto compartilhado entre as duas fontes — a
|
||||
# interseção de campos hoje é apenas ``{desc, value}``.
|
||||
# ``TIM_VAS_QUALIFIED_NAME_MATCH=true`` reativa (só para experimento/shadow).
|
||||
_VAS_QUALIFIED_NAME_MATCH = (
|
||||
os.getenv("TIM_VAS_QUALIFIED_NAME_MATCH", "false").lower() == "true"
|
||||
)
|
||||
|
||||
# Buckets de VAS na análise de variação/fatura atual: ``servicos_contratados_de_parceiros``
|
||||
# é o AVULSO e ``streaming`` o ESTRATÉGICO (SPEC §12.A). A retenção lê só o avulso (é o
|
||||
# único cancelável); o ramo NÃO do invoice_explanation lê os dois.
|
||||
_AVULSO_ANALYSIS_TYPE = "servicos_contratados_de_parceiros"
|
||||
_STRATEGIC_ANALYSIS_TYPE = "streaming"
|
||||
_VAS_ANALYSIS_TYPES = (_AVULSO_ANALYSIS_TYPE, _STRATEGIC_ANALYSIS_TYPE)
|
||||
|
||||
# Chaves de análise no ``invoice_detail``, como o backend as entrega (camelCase).
|
||||
_VARIATION_KEY = "invoiceVariation"
|
||||
_CURRENT_KEY = "currentInvoice"
|
||||
|
||||
_ISO_DATE = re.compile(r"^(\d{4})-(\d{2})-(\d{2})")
|
||||
_BR_DATE = re.compile(r"^(\d{2})/(\d{2})/(\d{4})")
|
||||
|
||||
# ----- data da COBRANÇA (≠ vencimento da fatura) ------------------------------
|
||||
# ``_due_date_key``/``_BR_DATE`` acima leem o campo ``invoice`` (VENCIMENTO) e
|
||||
# particionam fatura-passada × atual — são load-bearing e ficam intocados. O par
|
||||
# abaixo resolve outra pergunta: QUAL cobrança, dentro da fatura, é esta. Precisa
|
||||
# de duas formas que o ``_BR_DATE`` rejeita (ano de 2 dígitos), porque a
|
||||
# explicação diz "no dia 29/06/26" e o PDF grava ``period`` como "01/11/25".
|
||||
_CHARGE_DATE_ISO = re.compile(r"^(\d{4})-(\d{2})-(\d{2})")
|
||||
_CHARGE_DATE_BR = re.compile(r"^(\d{2})/(\d{2})/(\d{2,4})\b")
|
||||
# Sentinela que o backend emite quando não sabe a data. Parseia como data válida
|
||||
# e viraria desempate FALSO se passasse adiante.
|
||||
_CHARGE_DATE_SENTINEL = ("0000", "12", "31")
|
||||
|
||||
|
||||
def _charge_date_key(raw: Any) -> tuple[str, str, str] | None:
|
||||
"""Chave canônica ``(ano, mês, dia)`` de uma data de COBRANÇA.
|
||||
|
||||
Aceita ISO (``2025-11-01T00:00:00.000Z``), ``dd/mm/aa`` (``25/06/26``) e
|
||||
``dd/mm/yyyy``. Ano de 2 dígitos vira ``20xx`` — as faturas em questão são
|
||||
todas deste século e o campo não carrega o século.
|
||||
|
||||
Devolve ``None`` para ausente, ilegível, data impossível e para o SENTINELA
|
||||
``0000-12-31``: aqui a igualdade de data é EVIDÊNCIA para desempatar
|
||||
cancelamento, então "não sei" tem de ser indistinguível de "não tem" — nunca
|
||||
tolerância por prefixo (isso é similaridade, e similaridade não autoriza
|
||||
cancelar)."""
|
||||
text = str(raw or "").strip()
|
||||
if not text:
|
||||
return None
|
||||
iso = _CHARGE_DATE_ISO.match(text)
|
||||
if iso:
|
||||
key = (iso.group(1), iso.group(2), iso.group(3))
|
||||
else:
|
||||
br = _CHARGE_DATE_BR.match(text)
|
||||
if not br:
|
||||
return None
|
||||
year = br.group(3)
|
||||
key = (year if len(year) == 4 else f"20{year}", br.group(2), br.group(1))
|
||||
if key == _CHARGE_DATE_SENTINEL:
|
||||
return None
|
||||
try:
|
||||
if not (1 <= int(key[1]) <= 12 and 1 <= int(key[2]) <= 31):
|
||||
return None
|
||||
except ValueError:
|
||||
return None
|
||||
return key
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class _ExplainedCharge:
|
||||
"""Uma cobrança DATADA extraída do texto cru do ``invoiceExplanation``.
|
||||
|
||||
É a única fonte de data de cobrança que serve: o campo ``date`` estruturado
|
||||
do ``invoiceVariation`` vem sentinela/ausente em produção."""
|
||||
|
||||
desc: str
|
||||
value: Decimal
|
||||
date_key: tuple[str, str, str]
|
||||
|
||||
|
||||
# Só a forma COM data explícita. "Foram cobrados os seguintes serviços de
|
||||
# terceiros: X" e "* X variou em R$ Y" não têm dia e, por desenho, não casam:
|
||||
# sem data não há evidência para desempatar, e o fluxo falha FECHADO.
|
||||
_EXPLAINED_CHARGE_RE = re.compile(
|
||||
r"^(?:Cobran[çc]a\s+)?(?P<desc>.+?)\s+no\s+valor\s+de\s+R\$\s*"
|
||||
r"(?P<value>[\d.,]+)\s+no\s+dia\s+(?P<date>\d{2}/\d{2}/\d{2,4})",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
|
||||
|
||||
def _explained_charges(text: Any) -> list[_ExplainedCharge]:
|
||||
"""Extrai as cobranças datadas do ``invoiceExplanation`` CRU.
|
||||
|
||||
``text`` tem de ser o ``invoice_explanation_base`` (resposta original do
|
||||
backend), NUNCA a reescrita do LLM: a reescrita mexe em pontuação e formato
|
||||
monetário, e identidade determinística não pode depender disso.
|
||||
|
||||
Devolve LISTA, não conjunto — dois bullets podem ter o mesmo (valor, data), e
|
||||
a multiplicidade é o que impede um hint de ser reusado por duas cobranças
|
||||
independentes."""
|
||||
out: list[_ExplainedCharge] = []
|
||||
for line in str(text or "").splitlines():
|
||||
stripped = line.strip().lstrip("*-•").strip()
|
||||
if not stripped:
|
||||
continue
|
||||
match = _EXPLAINED_CHARGE_RE.match(stripped)
|
||||
if match is None:
|
||||
continue
|
||||
date_key = _charge_date_key(match.group("date"))
|
||||
if date_key is None:
|
||||
continue
|
||||
raw_value = match.group("value")
|
||||
# "16,99" e "1.234,56": vírgula é decimal, ponto é milhar.
|
||||
if "," in raw_value:
|
||||
raw_value = raw_value.replace(".", "").replace(",", ".")
|
||||
value = InvoiceResolver._parse_money(raw_value)
|
||||
if value is None:
|
||||
continue
|
||||
out.append(
|
||||
_ExplainedCharge(
|
||||
desc=match.group("desc").strip(),
|
||||
value=value,
|
||||
date_key=date_key,
|
||||
)
|
||||
)
|
||||
return out
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class VariedCharge:
|
||||
"""Uma cobrança de VAS avulso que entrou ou subiu na fatura atual.
|
||||
|
||||
``desc`` é o texto CRU da fatura (nunca normalizado) — serve a telemetria e a
|
||||
qualquer fala futura. O casamento com itens do resolver é sempre por
|
||||
:func:`charge_match_key`, nunca por ``desc`` direto."""
|
||||
|
||||
desc: str
|
||||
value: Decimal
|
||||
|
||||
|
||||
def charge_match_key(desc: Any, value: Any) -> tuple[str, Decimal] | None:
|
||||
"""Chave de identidade de uma cobrança: ``(nome normalizado, valor)``.
|
||||
|
||||
Definição ÚNICA, usada dos dois lados do casamento (as cobranças variadas
|
||||
daqui e os ``ResolvedInvoiceItem`` do snapshot). Reusa os helpers canônicos do
|
||||
:class:`InvoiceResolver` de propósito: um normalizador próprio poderia divergir
|
||||
do que casa ``canonical_name``, e aí a fila de cancelamento sairia errada.
|
||||
|
||||
``None`` quando falta nome ou o valor não é parseável — cobrança sem identidade
|
||||
não entra no diff."""
|
||||
name = str(desc or "").strip()
|
||||
if not name:
|
||||
return None
|
||||
parsed = InvoiceResolver._parse_money(value)
|
||||
if parsed is None:
|
||||
return None
|
||||
return (InvoiceResolver._normalize_match_text(name), parsed)
|
||||
|
||||
|
||||
def varied_current_charges(
|
||||
invoice_detail: Mapping[str, Any] | None,
|
||||
*,
|
||||
analysis_types: Sequence[str] = (_AVULSO_ANALYSIS_TYPE,),
|
||||
) -> tuple[VariedCharge, ...]:
|
||||
"""Cobranças de VAS que ENTRARAM ou SUBIRAM na fatura atual.
|
||||
|
||||
``analysis_types`` escolhe os grupos de análise lidos — só o avulso (default, o
|
||||
recorte da retenção) ou ``_VAS_ANALYSIS_TYPES`` (avulso + estratégico). A partição
|
||||
passada × atual é feita sobre a UNIÃO dos grupos pedidos: os itens dos dois carregam
|
||||
o vencimento das MESMAS duas faturas, e olhar a união é o que salva o caso em que um
|
||||
dos grupos existe só na fatura atual (sozinho ele cairia no desempate de data única).
|
||||
|
||||
Tupla vazia quando não há dados de variação utilizáveis (sem ``invoiceVariation``,
|
||||
sem os grupos pedidos, vencimento ilegível, ou variação causada apenas por remoção)
|
||||
— falha fechada, ver docstring do módulo."""
|
||||
try:
|
||||
items = list(_iter_vas_items(invoice_detail, _VARIATION_KEY, analysis_types))
|
||||
except Exception: # pragma: no cover - defensivo, payload arbitrário
|
||||
logger.debug("vas_variation.read_failed", exc_info=True)
|
||||
return ()
|
||||
if not items:
|
||||
return ()
|
||||
|
||||
by_due_date: dict[tuple[str, str, str], list[Mapping[str, Any]]] = {}
|
||||
for item in items:
|
||||
due = _due_date_key(item.get("invoice"))
|
||||
if due is None:
|
||||
# Item sem vencimento legível não pode ser atribuído a nenhuma das duas
|
||||
# faturas. Incluí-lo em qualquer um dos lados corromperia o diff — e um
|
||||
# diff errado cancela o serviço errado. Aborta.
|
||||
logger.debug("vas_variation.unattributable_item")
|
||||
return ()
|
||||
by_due_date.setdefault(due, []).append(item)
|
||||
|
||||
due_dates = sorted(by_due_date)
|
||||
if len(due_dates) >= 2:
|
||||
past_items = by_due_date[due_dates[0]]
|
||||
current_items = by_due_date[due_dates[-1]]
|
||||
else:
|
||||
current_items, past_items = _split_single_due_date(
|
||||
by_due_date[due_dates[0]], invoice_detail
|
||||
)
|
||||
|
||||
current = _charge_counter(current_items)
|
||||
past = _charge_counter(past_items)
|
||||
added = current - past
|
||||
if not added:
|
||||
return ()
|
||||
|
||||
raw_descs = _first_raw_descs(current_items)
|
||||
return tuple(
|
||||
VariedCharge(desc=raw_descs.get(key, key[0]), value=key[1])
|
||||
for key in added.elements()
|
||||
)
|
||||
|
||||
|
||||
def variation_source(
|
||||
invoice_variation: Mapping[str, Any] | None,
|
||||
invoice_detail: Mapping[str, Any] | None,
|
||||
) -> Mapping[str, Any]:
|
||||
"""Qual dos dois payloads carrega a ANÁLISE de variação.
|
||||
|
||||
Primária: ``invoice_variation`` — gravada pelo prefetch a partir de
|
||||
``InvoiceExplanationOutput.detalhes`` (corpo do ``billingAnalysis``). NÃO vem do
|
||||
``invoice_detail``, que é o PDF parseado e não carrega a partição fatura-passada ×
|
||||
atual.
|
||||
|
||||
Fallback para ``invoice_detail``: em entradas onde ele JÁ é o payload de análise (o
|
||||
resolver suporta esse formato — ver ``_billing_analysis_sections``), a variação
|
||||
está lá. Definição ÚNICA para os dois consumidores (runtime e workflow), para os
|
||||
dois lerem a mesma coisa."""
|
||||
if isinstance(invoice_variation, Mapping) and (
|
||||
invoice_variation.get(_VARIATION_KEY) or invoice_variation.get(_CURRENT_KEY)
|
||||
):
|
||||
return invoice_variation
|
||||
return invoice_detail if isinstance(invoice_detail, Mapping) else {}
|
||||
|
||||
|
||||
def varied_avulso_items(
|
||||
*,
|
||||
invoice_variation: Mapping[str, Any] | None,
|
||||
invoice_detail: Mapping[str, Any] | None,
|
||||
resolver: InvoiceResolver,
|
||||
by_charge: bool,
|
||||
explanation_text: str | None = None,
|
||||
) -> list[ResolvedInvoiceItem] | None:
|
||||
"""Itens ``avulso`` da fatura cujas cobranças VARIARAM na atual.
|
||||
|
||||
Responde a pergunta "a variação foi causada por VAS avulso?" e, quando sim, com
|
||||
QUAIS cobranças — o escopo da retenção no pedido de humano (SPEC §9), que promete
|
||||
cancelar o que ofereceu. Para o recorte mais largo (avulso **ou** estratégico), do
|
||||
ramo NÃO do ``invoice_explanation``, ver :func:`varied_vas_charges`.
|
||||
|
||||
Cruza duas fontes, cada uma com o que só ela tem:
|
||||
|
||||
- ``invoice_variation`` → a ANÁLISE (``invoiceVariation``), única com a partição
|
||||
fatura-passada × atual, logo a única que sabe o que VARIOU;
|
||||
- ``invoice_detail`` → o PDF parseado, única com a classificação canônica
|
||||
(``classe``/``estrategico`` carimbados pelo parser e honrados por ``_classify``),
|
||||
logo a única que sabe o que é CANCELÁVEL.
|
||||
|
||||
O cruzamento é por :func:`charge_match_key` (nome normalizado + valor), a mesma
|
||||
chave dos dois lados. Se os valores divergirem entre as fontes o casamento falha e
|
||||
a lista sai vazia → falha fechada.
|
||||
|
||||
**Só AVULSO conta** (SPEC §9/§11): bundle/estratégico nunca entram. O ``item_type``
|
||||
do resolver não basta como único filtro — na seção "Serviços de valor adicionado"
|
||||
ele decide por ``contestable``, então um serviço que a API bucketou como avulso sai
|
||||
``avulso`` mesmo sendo estratégico. Visto em fatura real: ``YouTube Premium Mensal``
|
||||
no bucket avulso da variação vira ``avulso``/``cancelar_vas_avulso`` e, pelo grupo
|
||||
``Streamings`` da mesma fatura, ``estrategico`` — duas classes para o MESMO serviço.
|
||||
Por isso dois guards, e a NOSSA classificação vence a da API:
|
||||
|
||||
1. nome na lista fixa ``STRATEGIC_NAMES`` (SPEC §12.A) → nunca avulso;
|
||||
2. o mesmo nome classificado ``estrategico``/``bundle`` em qualquer seção desta
|
||||
fatura → o "avulso" é mis-bucket da API; descarta.
|
||||
|
||||
``None`` em falha de leitura/resolução; lista (possivelmente vazia) caso
|
||||
contrário — o chamador decide o que "não sei" significa no fluxo dele."""
|
||||
if not invoice_detail:
|
||||
return None
|
||||
try:
|
||||
varied = varied_current_charges(
|
||||
variation_source(invoice_variation, invoice_detail)
|
||||
)
|
||||
if not varied:
|
||||
return []
|
||||
snapshot = resolver.build_snapshot(invoice_detail, by_charge=by_charge)
|
||||
except Exception:
|
||||
logger.debug("vas_variation.avulso_lookup_failed", exc_info=True)
|
||||
return None
|
||||
wanted = Counter(
|
||||
key
|
||||
for key in (charge_match_key(c.desc, c.value) for c in varied)
|
||||
if key is not None
|
||||
)
|
||||
if not wanted:
|
||||
return []
|
||||
# Guard 2: nomes que ESTA fatura classifica como não-cancelável em alguma seção.
|
||||
# Normalizado pela mesma chave para casar grafias divergentes entre os dois campos
|
||||
# de análise.
|
||||
blocked_names = {
|
||||
charge_match_key(it.canonical_name, 0)
|
||||
for it in snapshot
|
||||
if it.item_type in {"estrategico", "bundle"}
|
||||
}
|
||||
items: list[ResolvedInvoiceItem] = []
|
||||
# Datas de cobrança do ``invoiceExplanation`` cru. Parse LAZY: sem explicação,
|
||||
# ou sem nenhuma ambiguidade a desempatar, nada é parseado e o comportamento é
|
||||
# IDÊNTICO ao de antes desta mudança.
|
||||
hints = _DateHints(explanation_text)
|
||||
# Iteração CHARGE-driven (não item-driven): para cada cobrança que variou,
|
||||
# coleta TODOS os itens do snapshot que a atendem e só então decide. O loop
|
||||
# item-driven anterior consumia a cobrança no PRIMEIRO item que casava, então
|
||||
# o vencedor era quem ``build_snapshot`` emitisse antes — com o mesmo
|
||||
# ``(nome, valor)`` em duas LINHAS, o cancelamento saía na linha decidida pela
|
||||
# ordem de iteração do dict, não por evidência.
|
||||
for key, missing in list(wanted.items()):
|
||||
if missing <= 0:
|
||||
continue
|
||||
eligible = [
|
||||
item
|
||||
for item in snapshot
|
||||
if item.item_type == "avulso"
|
||||
and not is_strategic_name(item.canonical_name) # guard 1
|
||||
and charge_match_key(item.canonical_name, 0) not in blocked_names # guard 2
|
||||
and charge_match_key(item.canonical_name, item.value) == key
|
||||
]
|
||||
if not eligible:
|
||||
continue
|
||||
# ORDEM DOS GUARDS — IDENTIDADE ANTES DE EXECUTABILIDADE.
|
||||
# Multiplicidade (SPEC §9): o mesmo serviço cobrado N× no ciclo são N
|
||||
# cobranças e cada uma entra na fila — casar todas é correto quando a
|
||||
# contagem BATE. Sobrando candidato, não se sabe QUAL variou: falha
|
||||
# FECHADA, nunca "escolhe o primeiro".
|
||||
#
|
||||
# Este teste vem ANTES do filtro de ``msisdn`` de propósito. Invertido,
|
||||
# um candidato descartado por falta de linha COROARIA o outro: com uma
|
||||
# cobrança variada e dois itens de mesma identidade — um com linha, outro
|
||||
# sem — a ambiguidade de IDENTIDADE continua de pé, e a inexecutabilidade
|
||||
# de um deles não é evidência de que o outro seja o que variou.
|
||||
if len(eligible) > missing:
|
||||
# Antes de descartar, tenta ESTREITAR pela data da cobrança (a única
|
||||
# evidência determinística que distingue linhas com o mesmo nome e
|
||||
# valor — titular × dependente). Só reduz o conjunto: o resultado é
|
||||
# sempre subconjunto de ``eligible``, e este caminho só é alcançado
|
||||
# onde hoje se descarta tudo. Nenhum casamento existente muda.
|
||||
narrowed, hint = _narrow_by_charge_date(eligible, missing, hints, key[1])
|
||||
if narrowed is None:
|
||||
logger.info(
|
||||
"vas_variation.varied_charge_unmatched reason=multiple_candidates"
|
||||
" date_hint=%s service=%s count=%s",
|
||||
hint,
|
||||
key[0],
|
||||
len(eligible),
|
||||
)
|
||||
# Já reportada com a razão CERTA: consome para não reaparecer no
|
||||
# resíduo de ``_log_unmatched_varied_charges`` como
|
||||
# ``name_absent``/``value_mismatch``. Uma cobrança descartada emite
|
||||
# exatamente um evento — telemetria dupla corrompe a taxa.
|
||||
wanted[key] = 0
|
||||
continue
|
||||
logger.info(
|
||||
"vas_variation.varied_charge_matched reason=charge_date"
|
||||
" service=%s count=%s candidates=%s",
|
||||
key[0],
|
||||
len(narrowed),
|
||||
len(eligible),
|
||||
)
|
||||
eligible = narrowed
|
||||
# Guard 3: resolvida a identidade, sobra a executabilidade. Sem ``msisdn``
|
||||
# não há linha de destino, e o campo vai CRU para os args de
|
||||
# ``cancelar_vas_avulso`` (``action_queue._to_tool_args``). Razão própria —
|
||||
# sem ela a cobrança sairia adiante como ``name_absent``/``value_mismatch``,
|
||||
# diagnóstico errado (o nome estava lá; faltava a linha).
|
||||
candidates = [item for item in eligible if item.msisdn]
|
||||
if len(candidates) < len(eligible):
|
||||
logger.info(
|
||||
"vas_variation.varied_charge_unmatched reason=missing_msisdn"
|
||||
" service=%s count=%s",
|
||||
key[0],
|
||||
len(eligible) - len(candidates),
|
||||
)
|
||||
# Consome as identidades RESOLVIDAS (não só as executáveis): a cobrança
|
||||
# descartada por falta de linha já foi reportada com a razão certa e não
|
||||
# pode reaparecer no resíduo como não-casada.
|
||||
wanted[key] -= len(eligible)
|
||||
items.extend(candidates)
|
||||
# Ordem do snapshot preservada na saída: a decisão é por cobrança, mas a fila
|
||||
# sai na ordem da fatura (determinismo de saída, independente da ordem em que
|
||||
# as cobranças variadas foram iteradas).
|
||||
order = {id(item): idx for idx, item in enumerate(snapshot)}
|
||||
items.sort(key=lambda it: order.get(id(it), 0))
|
||||
# Passe residual por (valor, data): resolve o caso em que os NOMES divergem
|
||||
# entre as fontes ("FIT ME App" na análise × "FIT ME App Premium Mensal" no
|
||||
# PDF). A ponte é determinística — valor ao centavo mais data da cobrança —,
|
||||
# sem afirmar que os nomes são equivalentes. Só atua sobre chaves que o loop
|
||||
# principal deixou por resolver.
|
||||
items.extend(
|
||||
_match_by_charge_date(wanted, snapshot, items, blocked_names, hints)
|
||||
)
|
||||
items.sort(key=lambda it: order.get(id(it), 0))
|
||||
if _VAS_QUALIFIED_NAME_MATCH:
|
||||
items.extend(_match_qualified_names(wanted, snapshot, items))
|
||||
_log_unmatched_varied_charges(wanted, snapshot)
|
||||
return items
|
||||
|
||||
|
||||
def varied_vas_charges(
|
||||
*,
|
||||
invoice_variation: Mapping[str, Any] | None,
|
||||
invoice_detail: Mapping[str, Any] | None,
|
||||
) -> tuple[VariedCharge, ...]:
|
||||
"""Cobranças de VAS que VARIARAM — a pergunta do ramo NÃO do
|
||||
``invoice_explanation`` (SPEC §9 "Explicação da variação recusada").
|
||||
|
||||
"A variação foi causada por VAS que o agente resolve?" Recorte mais largo que
|
||||
:func:`varied_avulso_items` porque o desfecho aqui não é cancelar, é *ter assunto*:
|
||||
o estratégico o agente trata pela tool ``vas_estrategico``, então uma variação
|
||||
causada por streaming volta ao orquestrador em vez de encerrar o atendimento.
|
||||
|
||||
**Não cruza com o PDF da fatura, de propósito.** O ``type`` do grupo de análise JÁ
|
||||
É a classe: ``servicos_contratados_de_parceiros`` é o avulso e ``streaming`` o
|
||||
estratégico (confirmado contra a API, 2026-08-10). O ``build_snapshot`` só
|
||||
reafirmaria uma classificação que já veio carimbada, ao custo de casar nome e valor
|
||||
entre dois pipelines que divergem em grafia e centavo — e cada divergência
|
||||
ENCERRAVA a ligação de quem tinha o serviço na fatura (incidentes e081812886a,
|
||||
"FIT ME App" × "FIT ME App Premium Mensal", e Neymar Jr.Experience, PDF ilegível).
|
||||
Aqui nada é cancelado, então identidade não precisa ser provada: basta existir
|
||||
assunto. ``bundle`` nunca chega nestes grupos, e cobrança de outra jornada
|
||||
mis-bucketada pela API (roaming visto no grupo de parceiros) é risco ACEITO — o
|
||||
preço é devolver o turno ao orquestrador, que é o comportamento histórico.
|
||||
|
||||
A retenção (:func:`varied_avulso_items`) segue cruzando com o PDF: lá se promete
|
||||
cancelar, e é preciso saber QUAL linha.
|
||||
|
||||
Tupla vazia quando nada variou nos dois grupos — inclui análise ausente, vencimento
|
||||
ilegível e variação causada apenas por REMOÇÃO (falha fechada de
|
||||
:func:`varied_current_charges`), e o ramo NÃO finaliza ``nao_resolvido``."""
|
||||
return varied_current_charges(
|
||||
variation_source(invoice_variation, invoice_detail),
|
||||
analysis_types=_VAS_ANALYSIS_TYPES,
|
||||
)
|
||||
|
||||
|
||||
class _DateHints:
|
||||
"""Cobranças datadas da explicação, com CARDINALIDADE preservada.
|
||||
|
||||
Não é ``set[(valor, data)]`` de propósito: dois bullets reais podem ter o
|
||||
mesmo valor e a mesma data, e um hint não pode ser reusado por duas cobranças
|
||||
independentes sem evidência de multiplicidade. Parse lazy — só na primeira
|
||||
consulta, que só acontece se algum caminho precisar desempatar."""
|
||||
|
||||
__slots__ = ("_text", "_charges", "_consumed")
|
||||
|
||||
def __init__(self, text: str | None) -> None:
|
||||
self._text = text
|
||||
self._charges: list[_ExplainedCharge] | None = None
|
||||
self._consumed: set[int] = set()
|
||||
|
||||
def _all(self) -> list[_ExplainedCharge]:
|
||||
if self._charges is None:
|
||||
self._charges = _explained_charges(self._text) if self._text else []
|
||||
return self._charges
|
||||
|
||||
def available(self, value: Decimal) -> list[int]:
|
||||
"""Índices dos hints deste valor ainda NÃO consumidos."""
|
||||
return [
|
||||
idx
|
||||
for idx, charge in enumerate(self._all())
|
||||
if idx not in self._consumed and charge.value == value
|
||||
]
|
||||
|
||||
def date_keys(self, idxs: Sequence[int]) -> set[tuple[str, str, str]]:
|
||||
charges = self._all()
|
||||
return {charges[idx].date_key for idx in idxs}
|
||||
|
||||
def take(
|
||||
self, idxs: Sequence[int], items: Sequence[ResolvedInvoiceItem]
|
||||
) -> bool:
|
||||
"""Consome UM hint por item, casando data 1:1. False se não fechar."""
|
||||
charges = self._all()
|
||||
remaining = list(idxs)
|
||||
used: list[int] = []
|
||||
for item in items:
|
||||
key = _charge_date_key(item.charge_date)
|
||||
for idx in remaining:
|
||||
if charges[idx].date_key == key:
|
||||
used.append(idx)
|
||||
remaining.remove(idx)
|
||||
break
|
||||
else:
|
||||
return False
|
||||
self._consumed.update(used)
|
||||
return True
|
||||
|
||||
|
||||
def _narrow_by_charge_date(
|
||||
eligible: "list[ResolvedInvoiceItem]",
|
||||
missing: int,
|
||||
hints: "_DateHints",
|
||||
value: Decimal,
|
||||
) -> "tuple[list[ResolvedInvoiceItem] | None, str]":
|
||||
"""Estreita candidatos ambíguos pela data da cobrança. Nunca amplia.
|
||||
|
||||
Devolve ``(None, motivo)`` quando a evidência não fecha — e aí o chamador
|
||||
falha FECHADA, exatamente como antes desta mudança."""
|
||||
idxs = hints.available(value)
|
||||
if not idxs:
|
||||
return None, "absent"
|
||||
# Candidato SEM data legível não pode ser eliminado: ausência de data não é
|
||||
# evidência negativa. Com um datado e um sem data, escolher o datado seria
|
||||
# decidir por eliminação — proibido.
|
||||
if any(_charge_date_key(item.charge_date) is None for item in eligible):
|
||||
return None, "undated"
|
||||
keys = hints.date_keys(idxs)
|
||||
narrowed = [
|
||||
item for item in eligible if _charge_date_key(item.charge_date) in keys
|
||||
]
|
||||
if len(narrowed) != missing:
|
||||
return None, "ambiguous"
|
||||
if not hints.take(idxs, narrowed):
|
||||
return None, "ambiguous"
|
||||
return narrowed, "matched"
|
||||
|
||||
|
||||
def _match_by_charge_date(
|
||||
wanted: "Counter[tuple[str, Decimal]]",
|
||||
snapshot: "list[ResolvedInvoiceItem]",
|
||||
already: "list[ResolvedInvoiceItem]",
|
||||
blocked_names: "set[tuple[str, Decimal] | None]",
|
||||
hints: "_DateHints",
|
||||
) -> "list[ResolvedInvoiceItem]":
|
||||
"""Passe residual: casa por (VALOR, DATA) quando o NOME diverge entre fontes.
|
||||
|
||||
A análise traz o nome curto e o PDF o comercial completo — a igualdade de
|
||||
nome falha, mas valor ao centavo + data da cobrança identificam a mesma
|
||||
cobrança sem nenhuma inferência sobre os nomes. Todos os guards do loop
|
||||
principal valem aqui, na mesma ordem: identidade primeiro, executabilidade
|
||||
(``msisdn``) depois, cardinalidade EXATA, e falha fechada em qualquer dúvida."""
|
||||
consumed = {id(item) for item in already}
|
||||
extra: list[ResolvedInvoiceItem] = []
|
||||
for key, missing in list(wanted.items()):
|
||||
if missing <= 0:
|
||||
continue
|
||||
idxs = hints.available(key[1])
|
||||
if not idxs:
|
||||
continue
|
||||
keys = hints.date_keys(idxs)
|
||||
eligible = [
|
||||
item
|
||||
for item in snapshot
|
||||
if item.item_type == "avulso"
|
||||
and id(item) not in consumed
|
||||
and not is_strategic_name(item.canonical_name) # guard 1
|
||||
and charge_match_key(item.canonical_name, 0) not in blocked_names # guard 2
|
||||
and item.value == key[1]
|
||||
and _charge_date_key(item.charge_date) in keys
|
||||
]
|
||||
if len(eligible) != missing:
|
||||
continue
|
||||
if not hints.take(idxs, eligible):
|
||||
continue
|
||||
candidates = [item for item in eligible if item.msisdn] # guard 3
|
||||
if len(candidates) < len(eligible):
|
||||
logger.info(
|
||||
"vas_variation.varied_charge_unmatched reason=missing_msisdn"
|
||||
" service=%s count=%s",
|
||||
key[0],
|
||||
len(eligible) - len(candidates),
|
||||
)
|
||||
wanted[key] = 0
|
||||
for item in eligible:
|
||||
consumed.add(id(item))
|
||||
extra.extend(candidates)
|
||||
if candidates:
|
||||
logger.info(
|
||||
"vas_variation.varied_charge_matched reason=charge_date"
|
||||
" service=%s count=%s candidates=%s",
|
||||
key[0],
|
||||
len(candidates),
|
||||
len(eligible),
|
||||
)
|
||||
return extra
|
||||
|
||||
|
||||
def _match_qualified_names(
|
||||
wanted: "Counter[tuple[str, Decimal]]",
|
||||
snapshot: "list[ResolvedInvoiceItem]",
|
||||
already: "list[ResolvedInvoiceItem]",
|
||||
) -> "list[ResolvedInvoiceItem]":
|
||||
"""Segunda passada: a análise de variação traz o nome CURTO do serviço e a
|
||||
fatura traz o nome COMPLETO (incidente e081812886a: ``FIT ME App`` na variação
|
||||
× ``FIT ME App Premium Mensal`` em ``Itens Eventuais``). A igualdade estrita de
|
||||
nome descartava o item e o pedido de atendente encerrava sem oferecer retenção.
|
||||
|
||||
Casa apenas por **prefixo de TOKENS** — os tokens da variação têm de ser o
|
||||
início exato dos tokens do item, nessa ordem. Não é substring solta ("me app"
|
||||
não casa) nem fuzzy. E só consome a cobrança com todas as travas:
|
||||
|
||||
* mesmo valor, ao centavo (a chave já carrega o valor parseado);
|
||||
* item ``avulso`` que passou pelos guards de estratégico/classe mista;
|
||||
* **exatamente um** candidato — ambiguidade falha FECHADA (SPEC §retenção:
|
||||
nunca prometer cancelamento do que não se sabe ter variado).
|
||||
"""
|
||||
if not wanted:
|
||||
return []
|
||||
consumed = {id(item) for item in already}
|
||||
extra: list[ResolvedInvoiceItem] = []
|
||||
for (wanted_name, wanted_value), missing in list(wanted.items()):
|
||||
if missing <= 0:
|
||||
continue
|
||||
wanted_tokens = wanted_name.split()
|
||||
if not wanted_tokens:
|
||||
continue
|
||||
candidates = [
|
||||
item
|
||||
for item in snapshot
|
||||
if item.item_type == "avulso"
|
||||
and item.msisdn # guard 3: mesma exigência da primeira passada
|
||||
and id(item) not in consumed
|
||||
and not is_strategic_name(item.canonical_name)
|
||||
and _is_token_prefix(
|
||||
wanted_tokens, charge_match_key(item.canonical_name, item.value)
|
||||
)
|
||||
and (charge_match_key(item.canonical_name, item.value) or (None, None))[1]
|
||||
== wanted_value
|
||||
]
|
||||
if len(candidates) != 1:
|
||||
if len(candidates) > 1:
|
||||
logger.info(
|
||||
"vas_variation.varied_charge_unmatched reason=multiple_candidates"
|
||||
" service=%s count=%s",
|
||||
wanted_name,
|
||||
len(candidates),
|
||||
)
|
||||
continue
|
||||
item = candidates[0]
|
||||
consumed.add(id(item))
|
||||
wanted[(wanted_name, wanted_value)] -= 1
|
||||
extra.append(item)
|
||||
logger.info(
|
||||
"vas_variation.varied_charge_matched reason=qualified_name service=%s",
|
||||
wanted_name,
|
||||
)
|
||||
return extra
|
||||
|
||||
|
||||
def _is_token_prefix(
|
||||
wanted_tokens: "list[str]", item_key: "tuple[str, Decimal] | None"
|
||||
) -> bool:
|
||||
"""Os tokens da variação são o PREFIXO exato dos tokens do item da fatura."""
|
||||
if item_key is None:
|
||||
return False
|
||||
item_tokens = item_key[0].split()
|
||||
if len(item_tokens) <= len(wanted_tokens):
|
||||
return False
|
||||
return item_tokens[: len(wanted_tokens)] == wanted_tokens
|
||||
|
||||
|
||||
def is_strategic_name(name: str) -> bool:
|
||||
"""True se o nome do serviço está na lista fixa de SVA Estratégico/Terceiros
|
||||
(:data:`STRATEGIC_NAMES`, SPEC §12.A).
|
||||
|
||||
Guard 1 de :func:`varied_avulso_items`: estratégico NÃO é cancelável, e a
|
||||
classificação por seção sozinha deixa passar estratégico que a API bucketou como
|
||||
avulso. Substring sobre o nome em caixa baixa — a lista guarda a marca ("netflix",
|
||||
"youtube"), não o nome comercial completo ("YouTube Premium Mensal")."""
|
||||
lowered = str(name or "").casefold()
|
||||
return any(brand in lowered for brand in STRATEGIC_NAMES)
|
||||
|
||||
|
||||
def _log_unmatched_varied_charges(
|
||||
wanted: Counter[tuple[str, Decimal]],
|
||||
snapshot: Sequence[ResolvedInvoiceItem],
|
||||
) -> None:
|
||||
"""Registra as cobranças que a API diz ter variado mas NÃO casaram na fatura
|
||||
atual. Elas ficam fora de propósito (só contamos o que casa nome E valor), mas o
|
||||
descarte silencioso esconderia a taxa real.
|
||||
|
||||
As duas fontes são pipelines distintos — a variação vem do ``billingAnalysis`` e a
|
||||
fatura atual do PDF parseado —, então grafia e arredondamento podem divergir.
|
||||
Separar ``value_mismatch`` (o serviço está na fatura, o valor não bate) de
|
||||
``name_absent`` (não achamos o serviço) é o que diria se vale afrouxar o casamento:
|
||||
afrouxar por nome faria a cobrança variada casar a cobrança ERRADA do mesmo serviço
|
||||
(ex.: 2 cobranças, só uma variou), que é a super-cancelação que este filtro existe
|
||||
para evitar."""
|
||||
unmatched = +wanted # descarta chaves já consumidas (contagem <= 0)
|
||||
if not unmatched:
|
||||
return
|
||||
snapshot_names = {charge_match_key(it.canonical_name, 0) for it in snapshot}
|
||||
for (name, _value), count in unmatched.items():
|
||||
reason = (
|
||||
"value_mismatch"
|
||||
if charge_match_key(name, 0) in snapshot_names
|
||||
else "name_absent"
|
||||
)
|
||||
logger.info(
|
||||
"vas_variation.varied_charge_unmatched reason=%s service=%s count=%s",
|
||||
reason,
|
||||
name,
|
||||
count,
|
||||
)
|
||||
|
||||
|
||||
def _split_single_due_date(
|
||||
items: Sequence[Mapping[str, Any]],
|
||||
invoice_detail: Mapping[str, Any] | None,
|
||||
) -> tuple[list[Mapping[str, Any]], list[Mapping[str, Any]]]:
|
||||
"""Bloco com um ÚNICO vencimento: não há partição para comparar.
|
||||
|
||||
Acontece quando uma das duas faturas não tinha nenhum VAS avulso (27 de 88 casos
|
||||
na base medida) — então o bloco é inteiramente de um lado, e o que falta saber é
|
||||
QUAL. Desempata contra ``currentInvoice``: se a maioria do bloco aparece lá, é o
|
||||
lado ATUAL (tudo entrou); senão é o PASSADO (tudo saiu, nada a cancelar). Devolve
|
||||
``(atuais, passadas)``.
|
||||
|
||||
Olha TODOS os grupos de ``currentInvoice``, não só o avulso: a pergunta aqui é
|
||||
"esta cobrança está na fatura atual?", e a CLASSE é problema do resolver. Os dois
|
||||
campos vêm de geradores distintos e discordam da classe do mesmo item (visto na
|
||||
base: um serviço no bucket avulso da variação e em ``streaming`` da fatura atual)
|
||||
— restringir ao bucket avulso perderia a cobrança e inverteria a direção."""
|
||||
current_charges = _charge_counter(
|
||||
_iter_all_items(invoice_detail, _CURRENT_KEY)
|
||||
)
|
||||
matched = 0
|
||||
for item in items:
|
||||
key = charge_match_key(item.get("desc"), item.get("value"))
|
||||
if key is not None and current_charges[key] > 0:
|
||||
current_charges[key] -= 1
|
||||
matched += 1
|
||||
if matched * 2 >= len(items):
|
||||
return list(items), []
|
||||
return [], list(items)
|
||||
|
||||
|
||||
def _iter_vas_items(
|
||||
invoice_detail: Mapping[str, Any] | None,
|
||||
analysis_key: str,
|
||||
analysis_types: Sequence[str],
|
||||
) -> Iterator[Mapping[str, Any]]:
|
||||
"""Itens dos grupos de VAS pedidos em ``analysis_key`` (``invoiceVariation`` ou
|
||||
``currentInvoice``), lidos crus."""
|
||||
yield from _iter_all_items(invoice_detail, analysis_key, only_types=analysis_types)
|
||||
|
||||
|
||||
def _iter_all_items(
|
||||
invoice_detail: Mapping[str, Any] | None,
|
||||
analysis_key: str,
|
||||
*,
|
||||
only_types: Sequence[str] | None = None,
|
||||
) -> Iterator[Mapping[str, Any]]:
|
||||
"""Itens de ``analysis_key``, opcionalmente restritos a certos ``type`` de grupo.
|
||||
Silencioso em qualquer forma inesperada."""
|
||||
for groups in _iter_analysis_lists(invoice_detail, analysis_key):
|
||||
for group in groups:
|
||||
if not isinstance(group, Mapping):
|
||||
continue
|
||||
if (
|
||||
only_types is not None
|
||||
and str(group.get("type") or "").strip() not in only_types
|
||||
):
|
||||
continue
|
||||
items = group.get("items")
|
||||
if not isinstance(items, list):
|
||||
continue
|
||||
for item in items:
|
||||
if isinstance(item, Mapping):
|
||||
yield item
|
||||
|
||||
|
||||
def _iter_analysis_lists(
|
||||
invoice_detail: Mapping[str, Any] | None, analysis_key: str
|
||||
) -> Iterator[list[Any]]:
|
||||
"""Rende cada lista ``analysis_key`` do payload: no top-level e dentro de cada
|
||||
bucket por msisdn — espelha os dois pontos de leitura de
|
||||
``InvoiceResolver._iter_msisdn_buckets``."""
|
||||
if not isinstance(invoice_detail, Mapping):
|
||||
return
|
||||
top = invoice_detail.get(analysis_key)
|
||||
if isinstance(top, list):
|
||||
yield top
|
||||
for value in invoice_detail.values():
|
||||
if not isinstance(value, Mapping):
|
||||
continue
|
||||
nested = value.get(analysis_key)
|
||||
if isinstance(nested, list):
|
||||
yield nested
|
||||
|
||||
|
||||
def _charge_counter(
|
||||
items: Iterable[Mapping[str, Any]],
|
||||
) -> Counter[tuple[str, Decimal]]:
|
||||
"""Multiset das cobranças por ``(nome normalizado, valor)``. Cobranças sem
|
||||
identidade são descartadas."""
|
||||
counter: Counter[tuple[str, Decimal]] = Counter()
|
||||
for item in items:
|
||||
key = charge_match_key(item.get("desc"), item.get("value"))
|
||||
if key is not None:
|
||||
counter[key] += 1
|
||||
return counter
|
||||
|
||||
|
||||
def _first_raw_descs(
|
||||
items: Iterable[Mapping[str, Any]],
|
||||
) -> dict[tuple[str, Decimal], str]:
|
||||
"""Mapa chave → primeiro ``desc`` cru visto, para devolver o nome como está na
|
||||
fatura em vez do normalizado."""
|
||||
raw: dict[tuple[str, Decimal], str] = {}
|
||||
for item in items:
|
||||
key = charge_match_key(item.get("desc"), item.get("value"))
|
||||
if key is not None and key not in raw:
|
||||
raw[key] = str(item.get("desc") or "").strip()
|
||||
return raw
|
||||
|
||||
|
||||
def _due_date_key(raw: Any) -> tuple[str, str, str] | None:
|
||||
"""Chave ordenável ``(ano, mês, dia)`` do vencimento (campo ``invoice`` do
|
||||
item). Aceita ISO-8601 (payload do runtime) e ``dd/mm/yyyy`` (serialização
|
||||
alternativa do mesmo pipeline). ``None`` quando não parseável."""
|
||||
text = str(raw or "").strip()
|
||||
if not text:
|
||||
return None
|
||||
iso = _ISO_DATE.match(text)
|
||||
if iso is not None:
|
||||
return (iso.group(1), iso.group(2), iso.group(3))
|
||||
br = _BR_DATE.match(text)
|
||||
if br is not None:
|
||||
return (br.group(3), br.group(2), br.group(1))
|
||||
return None
|
||||
|
||||
|
||||
__all__ = [
|
||||
"VariedCharge",
|
||||
"charge_match_key",
|
||||
"is_strategic_name",
|
||||
"variation_source",
|
||||
"varied_avulso_items",
|
||||
"varied_current_charges",
|
||||
"varied_vas_charges",
|
||||
]
|
||||
1740
app/domain/contas/workflow_actions.py
Normal file
1740
app/domain/contas/workflow_actions.py
Normal file
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user