919 lines
40 KiB
Python
919 lines
40 KiB
Python
"""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",
|
||
]
|