Projeto do Agent Contas ORACLE

This commit is contained in:
2026-08-19 09:35:50 -03:00
commit 950a2bcd33
1366 changed files with 177217 additions and 0 deletions

View 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",
]