1145 lines
44 KiB
Python
1145 lines
44 KiB
Python
"""Testes do InvoiceResolver: matching estruturado entre fala do cliente e
|
|
``invoice_detail``. Cobre as seções suportadas, dedupe, classificação por
|
|
seção, match bidirecional, parse monetário e os
|
|
casos de borda do shape do payload."""
|
|
from __future__ import annotations
|
|
|
|
from decimal import Decimal
|
|
from typing import Any
|
|
|
|
import pytest
|
|
|
|
from app.domain.contas.invoice_resolver import (
|
|
SECTION_DEFAULTS,
|
|
STRATEGIC_NAMES,
|
|
InvoiceResolver,
|
|
ItemMatcherError,
|
|
)
|
|
from app.domain.contas.invoice_models import (
|
|
MentionedItem,
|
|
ResolvedInvoiceItem,
|
|
)
|
|
|
|
|
|
# ----- fixtures ---------------------------------------------------------------
|
|
|
|
|
|
def _entry(desc: str, value: Any, msisdn: str = "11999999999") -> dict[str, Any]:
|
|
return {"desc": desc, "value": value, "msisdn": msisdn}
|
|
|
|
|
|
@pytest.fixture
|
|
def invoice_detail() -> dict[str, Any]:
|
|
"""``invoice_detail`` realista cobrindo as 4 seções suportadas e duas linhas
|
|
(titular + dependente). Inclui chaves de metadado para garantir que são
|
|
ignoradas pelo iterador."""
|
|
return {
|
|
"Fatura Resumo": {"total": 199.90},
|
|
"vocalized_msisdn": {"11999999999": "nove nove nove nove"},
|
|
"DANFE-COM": [],
|
|
"11999999999": {
|
|
"Plano": [_entry("TIM Black 80GB", 79.90)],
|
|
"SVA Detalhe Total": [
|
|
_entry("TIM Fashion Mensal", 10.00),
|
|
_entry("Aluguel de Filme 1", 9.90),
|
|
_entry("Aluguel de Filme 2", 9.90),
|
|
_entry("Galinha Pintadinha", 7.99),
|
|
],
|
|
"Serviços Contratados de Terceiros": [
|
|
_entry("Netflix Premium", 39.90),
|
|
],
|
|
"Serviços Bundle Inclusos": [
|
|
_entry("HBO Max", 0.00),
|
|
],
|
|
"Itens Eventuais": [
|
|
_entry("Pacote Internacional", 25.00),
|
|
],
|
|
},
|
|
"11988888888": {
|
|
"SVA Detalhe Total": [
|
|
_entry("Tamboro Mensal", 5.99, msisdn="11988888888"),
|
|
],
|
|
},
|
|
}
|
|
|
|
|
|
# ----- resolve_items (caminho feliz) -----------------------------------------
|
|
|
|
|
|
def test_resolve_avulso_em_sva_detalhe_total(invoice_detail) -> None:
|
|
items = InvoiceResolver().resolve_items(["Tim Fashion"], invoice_detail)
|
|
assert len(items) == 1
|
|
item = items[0]
|
|
assert item.canonical_name == "TIM Fashion Mensal"
|
|
assert item.tool_category == "cancelar_vas_avulso"
|
|
assert item.item_type == "avulso"
|
|
assert item.msisdn == "11999999999"
|
|
assert item.section == "SVA Detalhe Total"
|
|
assert item.value == Decimal("10.00")
|
|
|
|
|
|
def test_resolve_estrategico_via_secao_terceiros(invoice_detail) -> None:
|
|
"""Item em ``Serviços Contratados de Terceiros`` segue para VAS estratégico
|
|
pela seção da fatura."""
|
|
items = InvoiceResolver().resolve_items(["netflix"], invoice_detail)
|
|
assert len(items) == 1
|
|
item = items[0]
|
|
assert item.canonical_name == "Netflix Premium"
|
|
assert item.tool_category == "vas_estrategico"
|
|
assert item.item_type == "estrategico"
|
|
assert item.section == "Serviços Contratados de Terceiros"
|
|
|
|
|
|
def test_resolve_bundle_sempre_classifica_como_bundle(invoice_detail) -> None:
|
|
"""Bundle Inclusos sempre classifica como ``bundle`` por estar nessa seção."""
|
|
items = InvoiceResolver().resolve_items(["HBO"], invoice_detail)
|
|
assert len(items) == 1
|
|
item = items[0]
|
|
assert item.canonical_name == "HBO Max"
|
|
assert item.tool_category == "vas_estrategico"
|
|
assert item.item_type == "bundle" # NÃO "estrategico"
|
|
assert item.section == "Serviços Bundle Inclusos"
|
|
|
|
|
|
def test_resolve_avulso_em_itens_eventuais(invoice_detail) -> None:
|
|
items = InvoiceResolver().resolve_items(["Pacote Internacional"], invoice_detail)
|
|
assert len(items) == 1
|
|
assert items[0].tool_category == "cancelar_vas_avulso"
|
|
assert items[0].item_type == "avulso"
|
|
assert items[0].section == "Itens Eventuais"
|
|
|
|
|
|
# ----- múltiplos itens / múltiplos MSISDNs -----------------------------------
|
|
|
|
|
|
def test_resolve_multiplos_itens_em_categorias_diferentes(invoice_detail) -> None:
|
|
items = InvoiceResolver().resolve_items(
|
|
["tim fashion", "netflix", "HBO"], invoice_detail
|
|
)
|
|
by_name = {i.canonical_name: i for i in items}
|
|
assert set(by_name) == {"TIM Fashion Mensal", "Netflix Premium", "HBO Max"}
|
|
assert by_name["TIM Fashion Mensal"].tool_category == "cancelar_vas_avulso"
|
|
assert by_name["Netflix Premium"].tool_category == "vas_estrategico"
|
|
assert by_name["HBO Max"].tool_category == "vas_estrategico"
|
|
|
|
|
|
def test_resolve_itens_parecidos_nao_sao_deduplicados(invoice_detail) -> None:
|
|
"""'Aluguel de Filme' casa com '1', '2'. Como os ``canonical_name`` são
|
|
diferentes, ambos devem ficar — dedupe é apenas para colisão exata."""
|
|
items = InvoiceResolver().resolve_items(["Aluguel de Filme"], invoice_detail)
|
|
names = sorted(i.canonical_name for i in items)
|
|
assert names == ["Aluguel de Filme 1", "Aluguel de Filme 2"]
|
|
|
|
|
|
def test_match_exato_tem_precedencia_sobre_substring_de_outro_item() -> None:
|
|
"""Regressão CY0004: quando a fatura tem 'VOD + Canais' e 'VOD + Canais
|
|
Abertos' (um é substring do outro), o cliente que cita ambos por nome
|
|
canônico exato deve resolver cada um individualmente, sem ambiguidade.
|
|
Sem essa precedência, ambos virariam ambíguos e a fila perderia avulsos.
|
|
"""
|
|
detail = {
|
|
"11964200059": {
|
|
"SVA Detalhe Total": [
|
|
{"desc": "VOD + Canais Abertos", "value": 19.90, "msisdn": "11964200059"},
|
|
{"desc": "VOD + Canais", "value": 19.90, "msisdn": "11964200059"},
|
|
],
|
|
}
|
|
}
|
|
outcome = InvoiceResolver().resolve(
|
|
["VOD + Canais Abertos", "VOD + Canais"], detail
|
|
)
|
|
# Cada menção bate em EXATAMENTE um item, sem ambiguidade
|
|
assert [i.canonical_name for i in outcome.resolved] == [
|
|
"VOD + Canais Abertos",
|
|
"VOD + Canais",
|
|
]
|
|
assert outcome.ambiguous == []
|
|
assert outcome.not_found == []
|
|
|
|
|
|
def test_match_substring_continua_funcionando_quando_nao_ha_exato() -> None:
|
|
"""Quando o cliente cita só "VOD" (sem o nome canônico completo), o
|
|
matching cai em substring bidirecional e ambos os "VOD + Canais ..."
|
|
casam. Comportamento esperado de ambiguidade legítima — preserva o caso
|
|
"dois ayas" e similar."""
|
|
detail = {
|
|
"11964200059": {
|
|
"SVA Detalhe Total": [
|
|
{"desc": "VOD + Canais Abertos", "value": 19.90, "msisdn": "11964200059"},
|
|
{"desc": "VOD + Canais", "value": 19.90, "msisdn": "11964200059"},
|
|
],
|
|
}
|
|
}
|
|
outcome = InvoiceResolver().resolve(["VOD"], detail)
|
|
# Sem match exato, fallback substring acha os dois → ambíguo
|
|
assert outcome.resolved == []
|
|
assert len(outcome.ambiguous) == 1
|
|
assert {m.canonical_name for m in outcome.ambiguous[0].matches} == {
|
|
"VOD + Canais Abertos",
|
|
"VOD + Canais",
|
|
}
|
|
|
|
|
|
def test_match_exato_case_insensitive_e_trim() -> None:
|
|
"""Match exato ignora case e espaços nas pontas. Tolera variações
|
|
inofensivas do classifier sem inflar ambiguidade."""
|
|
detail = {
|
|
"11964200059": {
|
|
"SVA Detalhe Total": [
|
|
{"desc": "VOD + Canais Abertos", "value": 19.90, "msisdn": "11964200059"},
|
|
{"desc": "VOD + Canais", "value": 19.90, "msisdn": "11964200059"},
|
|
],
|
|
}
|
|
}
|
|
outcome = InvoiceResolver().resolve(
|
|
[" vod + canais abertos ", "VOD + CANAIS"], detail
|
|
)
|
|
assert [i.canonical_name for i in outcome.resolved] == [
|
|
"VOD + Canais Abertos",
|
|
"VOD + Canais",
|
|
]
|
|
assert outcome.ambiguous == []
|
|
|
|
|
|
def test_msisdn_vem_da_entrada_nao_da_linha_titular(invoice_detail) -> None:
|
|
"""'Tamboro' aparece só no MSISDN dependente — o item resolvido carrega o
|
|
MSISDN da entrada, NUNCA o do titular."""
|
|
items = InvoiceResolver().resolve_items(["Tamboro"], invoice_detail)
|
|
assert len(items) == 1
|
|
assert items[0].msisdn == "11988888888"
|
|
|
|
|
|
def test_msisdn_cai_no_bucket_quando_entrada_nao_traz(invoice_detail) -> None:
|
|
"""Se o ``msisdn`` da entrada vier vazio, usamos o bucket pai."""
|
|
invoice_detail["11999999999"]["SVA Detalhe Total"][0]["msisdn"] = ""
|
|
items = InvoiceResolver().resolve_items(["Tim Fashion"], invoice_detail)
|
|
assert items[0].msisdn == "11999999999"
|
|
|
|
|
|
# ----- dedupe -----------------------------------------------------------------
|
|
|
|
|
|
def test_dedupe_remove_entry_duplicada_na_mesma_secao(invoice_detail) -> None:
|
|
"""Mesmo ``desc`` + mesmo ``msisdn`` na mesma seção (duplicata real):
|
|
fica uma só."""
|
|
invoice_detail["11999999999"]["SVA Detalhe Total"].append(
|
|
_entry("TIM Fashion Mensal", 10.00)
|
|
)
|
|
items = InvoiceResolver().resolve_items(["Tim Fashion"], invoice_detail)
|
|
assert len(items) == 1
|
|
|
|
|
|
def test_dedupe_nao_colapsa_mesma_desc_em_msisdns_diferentes(invoice_detail) -> None:
|
|
"""Mesmo nome em linhas diferentes são itens distintos — não colapsar."""
|
|
invoice_detail["11988888888"]["SVA Detalhe Total"].append(
|
|
_entry("TIM Fashion Mensal", 10.00, msisdn="11988888888")
|
|
)
|
|
items = InvoiceResolver().resolve_items(["Tim Fashion"], invoice_detail)
|
|
assert len(items) == 2
|
|
assert {i.msisdn for i in items} == {"11999999999", "11988888888"}
|
|
|
|
|
|
# ----- defesa em profundidade -----------------------------------------------
|
|
|
|
|
|
def test_nome_estrategico_em_secao_avulso_permanece_avulso() -> None:
|
|
detail = {
|
|
"11999999999": {
|
|
"SVA Detalhe Total": [_entry("Netflix Standard", 25.00)],
|
|
}
|
|
}
|
|
items = InvoiceResolver().resolve_items(["netflix"], detail)
|
|
assert len(items) == 1
|
|
assert items[0].item_type == "avulso"
|
|
assert items[0].tool_category == "cancelar_vas_avulso"
|
|
|
|
|
|
def test_sva_detalhe_total_com_classe_estrategico_vai_para_vas_estrategico() -> None:
|
|
detail = {
|
|
"11999999999": {
|
|
"SVA Detalhe Total": [
|
|
{
|
|
"desc": "Netflix Standard",
|
|
"value": 25.00,
|
|
"classe": "estrategico",
|
|
"msisdn": "11999999999",
|
|
}
|
|
],
|
|
}
|
|
}
|
|
|
|
items = InvoiceResolver().resolve_items(["netflix"], detail)
|
|
|
|
assert len(items) == 1
|
|
assert items[0].item_type == "estrategico"
|
|
assert items[0].tool_category == "vas_estrategico"
|
|
assert items[0].section == "SVA Detalhe Total"
|
|
|
|
|
|
def test_youtube_em_itens_eventuais_permanece_avulso() -> None:
|
|
detail = {
|
|
"11999999999": {
|
|
"Itens Eventuais": [_entry("YouTube Premium Mensal", 24.90)],
|
|
}
|
|
}
|
|
items = InvoiceResolver().resolve_items(["youtube premium"], detail)
|
|
assert len(items) == 1
|
|
assert items[0].canonical_name == "YouTube Premium Mensal"
|
|
assert items[0].item_type == "avulso"
|
|
assert items[0].tool_category == "cancelar_vas_avulso"
|
|
|
|
|
|
def test_classify_diretamente_para_todos_os_paths() -> None:
|
|
r = InvoiceResolver()
|
|
assert r._classify("HBO Max", "Serviços Bundle Inclusos", "bundle") == "bundle"
|
|
assert r._classify("Netflix Standard", "SVA Detalhe Total", "avulso") == "avulso"
|
|
assert (
|
|
r._classify(
|
|
"Netflix Standard",
|
|
"SVA Detalhe Total",
|
|
"avulso",
|
|
{"classe": "estrategico"},
|
|
)
|
|
== "estrategico"
|
|
)
|
|
assert r._classify("Galinha Pintadinha", "SVA Detalhe Total", "avulso") == "avulso"
|
|
assert r._classify("YouTube Premium", "Itens Eventuais", "avulso") == "avulso"
|
|
assert (
|
|
r._classify(
|
|
"YouTube Premium",
|
|
"Serviços Contratados de Terceiros",
|
|
"estrategico",
|
|
)
|
|
== "estrategico"
|
|
)
|
|
|
|
|
|
# ----- _was_mentioned --------------------------------------------------------
|
|
|
|
|
|
def test_was_mentioned_bidirecional() -> None:
|
|
fn = InvoiceResolver._was_mentioned
|
|
assert fn("TIM Fashion Mensal", ["tim fashion"]) is True # desc contém mention
|
|
assert fn("Netflix", ["Netflix Premium"]) is True # mention contém desc
|
|
assert fn("Disney+", ["DISNEY+"]) is True # case-insensitive
|
|
assert fn(" TIM Fashion ", ["tim fashion"]) is True # trim
|
|
assert fn("Galinha Pintadinha", ["netflix"]) is False
|
|
assert fn("", ["x"]) is False
|
|
assert fn("desc", []) is False
|
|
assert fn("desc", ["", None]) is False # type: ignore[list-item]
|
|
|
|
|
|
# ----- _parse_money ----------------------------------------------------------
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"raw,expected",
|
|
[
|
|
(10.00, Decimal("10.00")),
|
|
("9.99", Decimal("9.99")),
|
|
(0, Decimal("0.00")),
|
|
(Decimal("39.9"), Decimal("39.90")),
|
|
(None, None),
|
|
("", None),
|
|
("não-numérico", None),
|
|
(object(), None),
|
|
],
|
|
)
|
|
def test_parse_money(raw: Any, expected: Decimal | None) -> None:
|
|
assert InvoiceResolver._parse_money(raw) == expected
|
|
|
|
|
|
# ----- shape do payload / robustez -----------------------------------------
|
|
|
|
|
|
def test_invoice_detail_vazio() -> None:
|
|
assert InvoiceResolver().resolve_items(["netflix"], {}) == []
|
|
|
|
|
|
def test_mentioned_items_vazio(invoice_detail) -> None:
|
|
assert InvoiceResolver().resolve_items([], invoice_detail) == []
|
|
|
|
|
|
def test_invoice_detail_nao_dict() -> None:
|
|
assert InvoiceResolver().resolve_items(["x"], None) == [] # type: ignore[arg-type]
|
|
|
|
|
|
def test_secoes_denylist_sao_ignoradas() -> None:
|
|
"""Seções de ``_NON_SERVICE_SECTIONS`` (plano/desconto/consumo) não são
|
|
serviços acionáveis — entradas sem flag tratável são ignoradas."""
|
|
detail = {
|
|
"11999999999": {
|
|
"Roaming Internacional": [_entry("Pacote Roaming", 100.00)],
|
|
"Outros Valores": [_entry("MULTAS", 5.00)],
|
|
}
|
|
}
|
|
assert InvoiceResolver().resolve_items(["Pacote", "MULTAS"], detail) == []
|
|
|
|
|
|
def test_secao_de_servico_fora_das_classes_vira_out_of_scope() -> None:
|
|
"""``Cobranças de Terceiros`` (fora de SECTION_DEFAULTS, não é denylist):
|
|
item encontrado mas não tratável → bucket ``out_of_scope``, não ``not_found``."""
|
|
detail = {
|
|
"11999999999": {
|
|
"Cobranças de Terceiros": [_entry("Seguro Celular Proteção Total", 53.34)],
|
|
}
|
|
}
|
|
outcome = InvoiceResolver().resolve(["Seguro Celular Proteção Total"], detail)
|
|
assert outcome.resolved == []
|
|
assert outcome.not_found == []
|
|
assert [m.canonical_name for r in outcome.out_of_scope for m in r.matches] == [
|
|
"Seguro Celular Proteção Total"
|
|
]
|
|
oos_item = outcome.out_of_scope[0].matches[0]
|
|
assert oos_item.item_type == "out_of_scope"
|
|
assert oos_item.tool_category is None
|
|
|
|
|
|
def test_linha_de_credito_nao_e_avulso() -> None:
|
|
""""CRÉDITO: PAGAMENTO" (ajuste financeiro, valor negativo) em "Itens Eventuais"
|
|
é carimbado ``classe=avulso`` pela seção, mas NÃO é serviço → o resolver o ignora
|
|
(não vira candidato/avulso). Um avulso real na mesma seção segue normal."""
|
|
detail = {
|
|
"11999999999": {
|
|
"Itens Eventuais": [
|
|
{
|
|
"desc": "CRÉDITO: PAGAMENTO",
|
|
"value": -49.77,
|
|
"msisdn": "11999999999",
|
|
"classe": "avulso",
|
|
},
|
|
{
|
|
"desc": "Tamboro Mensal",
|
|
"value": 14.99,
|
|
"msisdn": "11999999999",
|
|
"classe": "avulso",
|
|
},
|
|
]
|
|
}
|
|
}
|
|
outcome = InvoiceResolver().resolve(
|
|
["CRÉDITO: PAGAMENTO", "Tamboro Mensal"], detail
|
|
)
|
|
resolved = [(i.canonical_name, i.item_type) for i in outcome.resolved]
|
|
assert ("Tamboro Mensal", "avulso") in resolved
|
|
assert all("CRÉDITO" not in name for name, _ in resolved) # crédito não roteado
|
|
# crédito não casa nenhum candidato → not_found (nem avulso, nem out_of_scope)
|
|
assert "CRÉDITO: PAGAMENTO" in outcome.not_found
|
|
|
|
|
|
def test_mensalidades_adicionais_e_secao_mista() -> None:
|
|
""""Mensalidades Adicionais" é seção MISTA: estratégico vem da FLAG do parser
|
|
(lista fixa por nome), não da seção. Item flageado → ``estrategico``; item sem
|
|
flag (ex.: "Plugin 5G Plus") → ``out_of_scope`` — não é estratégico só por estar
|
|
na seção (SPEC §5)."""
|
|
detail = {
|
|
"11999999999": {
|
|
"Mensalidades Adicionais": [
|
|
{
|
|
"desc": "Apple Music SVA Mes",
|
|
"value": 5.0,
|
|
"msisdn": "11999999999",
|
|
"estrategico": True,
|
|
"classe": "estrategico",
|
|
},
|
|
{"desc": "Plugin 5G Plus", "value": 5.0, "msisdn": "11999999999"},
|
|
]
|
|
}
|
|
}
|
|
resolver = InvoiceResolver()
|
|
|
|
flagged = resolver.resolve(["Apple Music SVA Mes"], detail)
|
|
assert [(i.canonical_name, i.item_type) for i in flagged.resolved] == [
|
|
("Apple Music SVA Mes", "estrategico")
|
|
]
|
|
|
|
plugin = resolver.resolve(["Plugin 5G Plus"], detail)
|
|
assert plugin.resolved == []
|
|
assert plugin.not_found == []
|
|
assert [m.item_type for r in plugin.out_of_scope for m in r.matches] == [
|
|
"out_of_scope"
|
|
]
|
|
|
|
|
|
def test_metadata_keys_nao_explodem() -> None:
|
|
"""``Fatura Resumo`` é dict, ``DANFE-COM`` é lista, ``vocalized_msisdn`` é
|
|
dict — nenhum deles deve causar erro no iterador."""
|
|
detail = {
|
|
"Fatura Resumo": {"x": 1},
|
|
"DANFE-COM": [{"y": 2}],
|
|
"vocalized_msisdn": {"z": 3},
|
|
}
|
|
assert InvoiceResolver().resolve_items(["qualquer"], detail) == []
|
|
|
|
|
|
def test_entries_nao_list_ignorada() -> None:
|
|
detail = {"11999999999": {"SVA Detalhe Total": "não é lista"}}
|
|
assert InvoiceResolver().resolve_items(["x"], detail) == []
|
|
|
|
|
|
def test_entry_sem_desc_ignorada() -> None:
|
|
detail = {"11999999999": {"SVA Detalhe Total": [{"value": 10.00}, "string"]}}
|
|
assert InvoiceResolver().resolve_items(["x"], detail) == []
|
|
|
|
|
|
def test_sections_nao_dict_ignorada() -> None:
|
|
detail = {"11999999999": "não é dict de seções"}
|
|
assert InvoiceResolver().resolve_items(["x"], detail) == []
|
|
|
|
|
|
# ----- estruturais ----------------------------------------------------------
|
|
|
|
|
|
def test_strategic_names_alinhado_com_apendice_a() -> None:
|
|
"""A constante carrega exatamente os nomes validados do Apêndice A do
|
|
orquestrador (Apple Music, Deezer, Disney, Fuze, Forge, Globoplay, HBO,
|
|
Looke, Netflix, Paramount, TIM Cloud Gaming, YouTube). Mudança aqui é
|
|
decisão de produto — exige PR explícito."""
|
|
assert STRATEGIC_NAMES == frozenset(
|
|
{
|
|
"apple music",
|
|
"deezer",
|
|
"disney",
|
|
"fuze",
|
|
"forge",
|
|
"globoplay",
|
|
"hbo",
|
|
"looke",
|
|
"netflix",
|
|
"paramount",
|
|
"tim cloud gaming",
|
|
"youtube",
|
|
"amazon prime",
|
|
}
|
|
)
|
|
|
|
|
|
def test_section_defaults_cobre_as_secoes_suportadas() -> None:
|
|
# "Mensalidades Adicionais" (formato PDF) é seção MISTA e NÃO entra aqui:
|
|
# estratégico vem da flag do parser; item sem flag → out_of_scope.
|
|
assert set(SECTION_DEFAULTS) == {
|
|
"SVA Detalhe Total",
|
|
"Itens Eventuais",
|
|
"Serviços Contratados de Terceiros",
|
|
"Serviços Bundle Inclusos",
|
|
"Serviços de valor adicionado",
|
|
"Streamings",
|
|
}
|
|
|
|
|
|
def test_resolve_formato_billing_analysis_com_streaming_e_sva_avulso() -> None:
|
|
detail = {
|
|
"currentInvoice": [
|
|
{
|
|
"desc": "Serviços de valor adicionado",
|
|
"items": [
|
|
{
|
|
"contestable": True,
|
|
"desc": "Aluguel de Filme 3",
|
|
"type": "servicos_contratados_de_parceiros",
|
|
"value": "19.9",
|
|
}
|
|
],
|
|
},
|
|
{
|
|
"desc": "Streamings",
|
|
"items": [
|
|
{
|
|
"contestable": False,
|
|
"desc": "YouTube Premium Mensal",
|
|
"type": "streaming",
|
|
"value": "24.9",
|
|
}
|
|
],
|
|
},
|
|
]
|
|
}
|
|
|
|
items = InvoiceResolver().resolve_items(
|
|
["YouTube Premium Mensal", "Aluguel de Filme 3"],
|
|
detail,
|
|
)
|
|
|
|
assert [(item.canonical_name, item.item_type, item.tool_category) for item in items] == [
|
|
("YouTube Premium Mensal", "estrategico", "vas_estrategico"),
|
|
("Aluguel de Filme 3", "avulso", "cancelar_vas_avulso"),
|
|
]
|
|
|
|
|
|
def test_resolve_eh_idempotente(invoice_detail) -> None:
|
|
"""Função pura: mesma entrada → mesma saída em chamadas repetidas."""
|
|
r = InvoiceResolver()
|
|
a = r.resolve_items(["netflix", "tim fashion"], invoice_detail)
|
|
b = r.resolve_items(["netflix", "tim fashion"], invoice_detail)
|
|
assert [(i.canonical_name, i.msisdn) for i in a] == [
|
|
(i.canonical_name, i.msisdn) for i in b
|
|
]
|
|
|
|
|
|
# ----- ordem da menção (regra do produto 2026-06) ---------------------------
|
|
|
|
|
|
def test_preserva_ordem_da_mencao_categorias_diferentes(invoice_detail) -> None:
|
|
"""Cliente menciona ``tim fashion`` (avulso) ANTES de ``netflix``
|
|
(estratégico). O output deve refletir essa ordem — o builder vai usar isso
|
|
para ordenar os steps da ActionQueue."""
|
|
items = InvoiceResolver().resolve_items(
|
|
["tim fashion", "netflix"], invoice_detail
|
|
)
|
|
names = [i.canonical_name for i in items]
|
|
assert names == ["TIM Fashion Mensal", "Netflix Premium"]
|
|
|
|
|
|
def test_inverter_a_mencao_inverte_o_output(invoice_detail) -> None:
|
|
"""Mesma combinação de itens, ordem invertida na fala → output invertido."""
|
|
items = InvoiceResolver().resolve_items(
|
|
["netflix", "tim fashion"], invoice_detail
|
|
)
|
|
names = [i.canonical_name for i in items]
|
|
assert names == ["Netflix Premium", "TIM Fashion Mensal"]
|
|
|
|
|
|
def test_mencao_invalida_eh_ignorada_sem_quebrar_ordem(invoice_detail) -> None:
|
|
"""Itens vazios/None na lista de menções não devem deslocar a ordem dos
|
|
matches válidos."""
|
|
items = InvoiceResolver().resolve_items(
|
|
["", "tim fashion", None, "netflix"], # type: ignore[list-item]
|
|
invoice_detail,
|
|
)
|
|
names = [i.canonical_name for i in items]
|
|
assert names == ["TIM Fashion Mensal", "Netflix Premium"]
|
|
|
|
|
|
# ----- resolve(): ambiguidade + matcher LLM (híbrido) ------------------------
|
|
|
|
|
|
class _FakeMatcher:
|
|
"""``ItemMatcherLLM`` fake: mapeia ``mention.lower()`` → lista de descs;
|
|
casa UMA menção por chamada (o resolver dispara várias em paralelo);
|
|
registra chamadas e pode levantar para exercitar o caminho de erro.
|
|
``calls.append`` é thread-safe sob o GIL — seguro com o ThreadPoolExecutor."""
|
|
|
|
def __init__(
|
|
self,
|
|
mapping: dict[str, list[str]] | None = None,
|
|
*,
|
|
raises: bool = False,
|
|
) -> None:
|
|
self.mapping = mapping or {}
|
|
self.raises = raises
|
|
self.calls: list[tuple[str, list[str]]] = []
|
|
|
|
def match(
|
|
self, mention: str, candidates: list[str], *, callbacks: Any = None
|
|
) -> list[str]:
|
|
self.calls.append((mention, list(candidates)))
|
|
if self.raises:
|
|
raise ItemMatcherError("boom")
|
|
return list(self.mapping.get(mention.lower().strip(), []))
|
|
|
|
|
|
def test_resolve_mencao_unica_vai_para_resolved(invoice_detail) -> None:
|
|
outcome = InvoiceResolver().resolve(["netflix"], invoice_detail)
|
|
assert [i.canonical_name for i in outcome.resolved] == ["Netflix Premium"]
|
|
assert outcome.ambiguous == []
|
|
assert outcome.not_found == []
|
|
|
|
|
|
def test_resolve_nomes_distintos_que_casam_sao_ambiguos(invoice_detail) -> None:
|
|
"""'Aluguel de Filme' casa com '1' e '2' (nomes distintos) → ambíguo: vai
|
|
para ``ambiguous``, não para ``resolved``."""
|
|
outcome = InvoiceResolver().resolve(["Aluguel de Filme"], invoice_detail)
|
|
assert outcome.resolved == []
|
|
assert len(outcome.ambiguous) == 1
|
|
res = outcome.ambiguous[0]
|
|
assert res.mention == "Aluguel de Filme"
|
|
assert res.is_ambiguous is True
|
|
assert {m.canonical_name for m in res.matches} == {
|
|
"Aluguel de Filme 1",
|
|
"Aluguel de Filme 2",
|
|
}
|
|
|
|
|
|
def test_resolve_mesmo_nome_em_linhas_diferentes_eh_ambiguo(invoice_detail) -> None:
|
|
"""Mesma desc em msisdns diferentes (os 'dois ayas') → ambíguo por linha."""
|
|
invoice_detail["11988888888"]["SVA Detalhe Total"].append(
|
|
_entry("TIM Fashion Mensal", 10.00, msisdn="11988888888")
|
|
)
|
|
outcome = InvoiceResolver().resolve(["Tim Fashion"], invoice_detail)
|
|
assert outcome.resolved == []
|
|
assert len(outcome.ambiguous) == 1
|
|
assert {m.msisdn for m in outcome.ambiguous[0].matches} == {
|
|
"11999999999",
|
|
"11988888888",
|
|
}
|
|
|
|
|
|
# ----- resolve(): menção COM msisdn (MentionedItem) desambigua a linha --------
|
|
|
|
|
|
def _tim_fashion_em_duas_linhas(invoice_detail) -> dict[str, Any]:
|
|
"""Coloca 'TIM Fashion Mensal' nas DUAS linhas (11999999999 e 11988888888) — o
|
|
mesmo cenário nome-em-2-linhas do teste anterior, reusado pelos casos de msisdn."""
|
|
invoice_detail["11988888888"]["SVA Detalhe Total"].append(
|
|
_entry("TIM Fashion Mensal", 10.00, msisdn="11988888888")
|
|
)
|
|
return invoice_detail
|
|
|
|
|
|
def test_resolve_com_msisdn_desambigua_para_uma_linha(invoice_detail) -> None:
|
|
"""Menção COM msisdn (MentionedItem) restringe à linha → 1 resolved, não ambíguo
|
|
(é o conserto do bug do trace: '8119' escolhe a linha, resolve p/ 1 item)."""
|
|
detail = _tim_fashion_em_duas_linhas(invoice_detail)
|
|
outcome = InvoiceResolver().resolve(
|
|
[MentionedItem(desc="Tim Fashion", msisdn="11988888888")], detail
|
|
)
|
|
assert outcome.ambiguous == []
|
|
assert len(outcome.resolved) == 1
|
|
assert outcome.resolved[0].msisdn == "11988888888"
|
|
|
|
|
|
def test_resolve_com_msisdn_sufixo_desambigua(invoice_detail) -> None:
|
|
"""O msisdn-dica pode vir só com os últimos dígitos ('8888') → casa por sufixo."""
|
|
detail = _tim_fashion_em_duas_linhas(invoice_detail)
|
|
outcome = InvoiceResolver().resolve(
|
|
[MentionedItem(desc="Tim Fashion", msisdn="8888")], detail
|
|
)
|
|
assert outcome.ambiguous == []
|
|
assert len(outcome.resolved) == 1
|
|
assert outcome.resolved[0].msisdn == "11988888888"
|
|
|
|
|
|
def test_resolve_msisdn_que_nao_casa_linha_nao_filtra(invoice_detail) -> None:
|
|
"""msisdn-dica inválido (não casa NENHUMA linha real) → NÃO filtra: volta ao
|
|
comportamento sem linha (ambíguo). A dica nunca zera o resultado."""
|
|
detail = _tim_fashion_em_duas_linhas(invoice_detail)
|
|
outcome = InvoiceResolver().resolve(
|
|
[MentionedItem(desc="Tim Fashion", msisdn="0000")], detail
|
|
)
|
|
assert outcome.resolved == []
|
|
assert len(outcome.ambiguous) == 1
|
|
assert {m.msisdn for m in outcome.ambiguous[0].matches} == {
|
|
"11999999999",
|
|
"11988888888",
|
|
}
|
|
|
|
|
|
def test_resolve_mentioneditem_sem_msisdn_segue_como_string(invoice_detail) -> None:
|
|
"""Back-compat: MentionedItem SEM msisdn (e a str pura) seguem ambíguos como antes."""
|
|
detail = _tim_fashion_em_duas_linhas(invoice_detail)
|
|
outcome = InvoiceResolver().resolve(
|
|
[MentionedItem(desc="Tim Fashion")], detail
|
|
)
|
|
assert outcome.resolved == []
|
|
assert len(outcome.ambiguous) == 1
|
|
|
|
|
|
# ----- resolve(): cobrança duplicada na MESMA linha (Fase 3) ------------------
|
|
|
|
|
|
def _dup_charges_detail(
|
|
v1: float = 14.99, v2: float = 19.99, p1: str = "01/11/25", p2: str = "02/11/25"
|
|
) -> dict[str, Any]:
|
|
"""Duas cobranças de "Tamboro Mensal" na MESMA linha/seção, distintas só pela
|
|
``period`` (data única). Espelha o fixture invoice_pdf_include_danfe_false."""
|
|
return {
|
|
"1199999999": {
|
|
"SVA Detalhe Total": [
|
|
{"desc": "Tamboro Mensal", "value": v1, "msisdn": "1199999999", "period": p1},
|
|
{"desc": "Tamboro Mensal", "value": v2, "msisdn": "1199999999", "period": p2},
|
|
],
|
|
},
|
|
}
|
|
|
|
|
|
def test_build_item_charge_date_apenas_para_data_unica() -> None:
|
|
"""``charge_date`` = ``period`` só quando é data única; faixa (ciclo) ou ausente
|
|
→ ``None``."""
|
|
detail = {
|
|
"1199999999": {
|
|
"SVA Detalhe Total": [
|
|
{"desc": "A", "value": 1.0, "msisdn": "1199999999", "period": "01/11/25"},
|
|
{"desc": "B", "value": 1.0, "msisdn": "1199999999", "period": "14/10 a 13/11"},
|
|
{"desc": "C", "value": 1.0, "msisdn": "1199999999"},
|
|
]
|
|
}
|
|
}
|
|
by_name = {i.canonical_name: i.charge_date for i in InvoiceResolver().build_snapshot(detail)}
|
|
assert by_name["A"] == "01/11/25"
|
|
assert by_name["B"] is None
|
|
assert by_name["C"] is None
|
|
|
|
|
|
def test_resolve_cobranca_duplicada_mesma_linha_eh_ambiguo() -> None:
|
|
outcome = InvoiceResolver().resolve(
|
|
[MentionedItem(desc="Tamboro Mensal")], _dup_charges_detail()
|
|
)
|
|
assert outcome.resolved == []
|
|
assert len(outcome.ambiguous) == 1
|
|
res = outcome.ambiguous[0]
|
|
assert res.is_ambiguous is True
|
|
assert {m.charge_date for m in res.matches} == {"01/11/25", "02/11/25"}
|
|
assert {m.msisdn for m in res.matches} == {"1199999999"}
|
|
|
|
|
|
def test_resolve_com_date_desambigua_para_uma_cobranca() -> None:
|
|
outcome = InvoiceResolver().resolve(
|
|
[MentionedItem(desc="Tamboro Mensal", msisdn="1199999999", date="01/11/25")],
|
|
_dup_charges_detail(),
|
|
)
|
|
assert outcome.ambiguous == []
|
|
assert len(outcome.resolved) == 1
|
|
assert outcome.resolved[0].charge_date == "01/11/25"
|
|
|
|
|
|
def test_resolve_date_prefixo_sem_ano_desambigua() -> None:
|
|
"""A dica de data pode vir sem o ano ("01/11") → casa por prefixo."""
|
|
outcome = InvoiceResolver().resolve(
|
|
[MentionedItem(desc="Tamboro Mensal", msisdn="1199999999", date="01/11")],
|
|
_dup_charges_detail(),
|
|
)
|
|
assert len(outcome.resolved) == 1
|
|
assert outcome.resolved[0].charge_date == "01/11/25"
|
|
|
|
|
|
def test_resolve_date_que_nao_casa_nao_filtra() -> None:
|
|
"""Dica de data inválida (nenhuma cobrança real) → NÃO filtra: segue ambíguo
|
|
(a dica nunca zera o resultado; espelha o msisdn-dica inválido)."""
|
|
outcome = InvoiceResolver().resolve(
|
|
[MentionedItem(desc="Tamboro Mensal", msisdn="1199999999", date="31/12/25")],
|
|
_dup_charges_detail(),
|
|
)
|
|
assert outcome.resolved == []
|
|
assert len(outcome.ambiguous) == 1
|
|
|
|
|
|
def test_resolve_ambas_cobrancas_dois_objetos_mantem_duas() -> None:
|
|
""""As duas" = 2 objetos datados → 2 cobranças distintas no resolved (o dedupe
|
|
final por cobrança NÃO as colapsa)."""
|
|
outcome = InvoiceResolver().resolve(
|
|
[
|
|
MentionedItem(desc="Tamboro Mensal", msisdn="1199999999", date="01/11/25"),
|
|
MentionedItem(desc="Tamboro Mensal", msisdn="1199999999", date="02/11/25"),
|
|
],
|
|
_dup_charges_detail(),
|
|
)
|
|
assert outcome.ambiguous == []
|
|
assert len(outcome.resolved) == 2
|
|
assert {i.charge_date for i in outcome.resolved} == {"01/11/25", "02/11/25"}
|
|
|
|
|
|
def test_dedupe_by_charge_flag() -> None:
|
|
"""``_dedupe_exact_matches``: default colapsa 2 cobranças de mesmo
|
|
(name,msisdn,section); ``by_charge=True`` mantém as datas distintas."""
|
|
r = InvoiceResolver()
|
|
a = ResolvedInvoiceItem(
|
|
"X", "cancelar_vas_avulso", "avulso", "111", None, "SVA Detalhe Total", "01/11/25"
|
|
)
|
|
b = ResolvedInvoiceItem(
|
|
"X", "cancelar_vas_avulso", "avulso", "111", None, "SVA Detalhe Total", "02/11/25"
|
|
)
|
|
assert len(r._dedupe_exact_matches([a, b])) == 1
|
|
assert len(r._dedupe_exact_matches([a, b], by_charge=True)) == 2
|
|
|
|
|
|
def test_build_snapshot_colapsa_cobrancas_duplicadas() -> None:
|
|
"""Regressão: build_snapshot mantém a chave de 3 (name,msisdn,section) → 2
|
|
cobranças do mesmo serviço/linha colapsam em 1 (anáfora plural não infla)."""
|
|
snap = InvoiceResolver().build_snapshot(_dup_charges_detail())
|
|
tamboros = [i for i in snap if i.canonical_name == "Tamboro Mensal"]
|
|
assert len(tamboros) == 1
|
|
|
|
|
|
def test_build_snapshot_by_charge_mantem_cobrancas_duplicadas() -> None:
|
|
"""``by_charge=True``: as 2 cobranças datadas do mesmo serviço/linha SOBREVIVEM
|
|
(usado ao cancelar TODOS os avulsos — cada cobrança vira um item)."""
|
|
snap = InvoiceResolver().build_snapshot(_dup_charges_detail(), by_charge=True)
|
|
tamboros = [i for i in snap if i.canonical_name == "Tamboro Mensal"]
|
|
assert len(tamboros) == 2
|
|
assert {i.charge_date for i in tamboros} == {"01/11/25", "02/11/25"}
|
|
|
|
|
|
def test_same_charge_tolerancia_de_prefixo() -> None:
|
|
sc = InvoiceResolver._same_charge
|
|
assert sc("01/11/25", "01/11") is True
|
|
assert sc("01/11/25", "01/11/25") is True
|
|
assert sc("02/11/25", "01/11/25") is False
|
|
assert sc(None, "01/11") is False # item sem data nunca casa dica de data
|
|
assert sc("01/11/25", "") is True # sem dica → não filtra
|
|
|
|
|
|
def test_resolve_sem_matcher_mencao_nao_encontrada_vai_para_not_found(
|
|
invoice_detail,
|
|
) -> None:
|
|
outcome = InvoiceResolver().resolve(["Spotify"], invoice_detail)
|
|
assert outcome.resolved == []
|
|
assert outcome.ambiguous == []
|
|
assert outcome.not_found == ["Spotify"]
|
|
|
|
|
|
def test_resolve_matcher_nao_acionado_quando_deterministico_casa(
|
|
invoice_detail,
|
|
) -> None:
|
|
"""Happy-path: substring já casa → o matcher LLM NÃO é chamado."""
|
|
matcher = _FakeMatcher()
|
|
outcome = InvoiceResolver(matcher=matcher).resolve(["netflix"], invoice_detail)
|
|
assert matcher.calls == []
|
|
assert [i.canonical_name for i in outcome.resolved] == ["Netflix Premium"]
|
|
|
|
|
|
def test_resolve_matcher_casa_no_miss_de_grafia(invoice_detail) -> None:
|
|
"""'Netflics' não casa por substring → o matcher é acionado e resolve para
|
|
'Netflix Premium'."""
|
|
matcher = _FakeMatcher(mapping={"netflics": ["Netflix Premium"]})
|
|
outcome = InvoiceResolver(matcher=matcher).resolve(["Netflics"], invoice_detail)
|
|
assert len(matcher.calls) == 1
|
|
assert "Netflix Premium" in matcher.calls[0][1] # desc candidato presente
|
|
assert [i.canonical_name for i in outcome.resolved] == ["Netflix Premium"]
|
|
assert outcome.not_found == []
|
|
|
|
|
|
def test_resolve_multiplas_mencoes_grafia_em_paralelo(invoice_detail) -> None:
|
|
"""Várias menções com grafia errada (todas furam o substring) → uma chamada
|
|
ao matcher LLM POR menção, disparadas em paralelo por threads (cobre o trace
|
|
2253d235…). Cada uma resolve para seu item."""
|
|
matcher = _FakeMatcher(
|
|
mapping={
|
|
"netflics": ["Netflix Premium"],
|
|
"spotfy": ["Spotify Família"],
|
|
}
|
|
)
|
|
invoice_detail["11999999999"]["SVA Detalhe Total"].append(
|
|
_entry("Spotify Família", 19.90, msisdn="11999999999")
|
|
)
|
|
outcome = InvoiceResolver(matcher=matcher).resolve(
|
|
["Netflics", "Spotfy"], invoice_detail
|
|
)
|
|
assert len(matcher.calls) == 2
|
|
assert {c[0] for c in matcher.calls} == {"Netflics", "Spotfy"}
|
|
assert {i.canonical_name for i in outcome.resolved} == {
|
|
"Netflix Premium",
|
|
"Spotify Família",
|
|
}
|
|
assert outcome.not_found == []
|
|
|
|
|
|
def test_resolve_matcher_multiplos_vira_ambiguo(invoice_detail) -> None:
|
|
matcher = _FakeMatcher(
|
|
mapping={"filmes": ["Aluguel de Filme 1", "Aluguel de Filme 2"]}
|
|
)
|
|
outcome = InvoiceResolver(matcher=matcher).resolve(["filmes"], invoice_detail)
|
|
assert outcome.resolved == []
|
|
assert len(outcome.ambiguous) == 1
|
|
assert {m.canonical_name for m in outcome.ambiguous[0].matches} == {
|
|
"Aluguel de Filme 1",
|
|
"Aluguel de Filme 2",
|
|
}
|
|
|
|
|
|
def test_resolve_matcher_desc_fora_da_lista_eh_ignorado(invoice_detail) -> None:
|
|
"""Defensivo: o matcher devolve um desc que não está nos candidatos →
|
|
ignorado (não inventa item) → menção fica em not_found."""
|
|
matcher = _FakeMatcher(mapping={"xpto": ["Serviço Inexistente"]})
|
|
outcome = InvoiceResolver(matcher=matcher).resolve(["xpto"], invoice_detail)
|
|
assert outcome.resolved == []
|
|
assert outcome.not_found == ["xpto"]
|
|
|
|
|
|
def test_resolve_matcher_erro_degrada_para_not_found(invoice_detail) -> None:
|
|
matcher = _FakeMatcher(raises=True)
|
|
outcome = InvoiceResolver(matcher=matcher).resolve(["Netflics"], invoice_detail)
|
|
assert outcome.resolved == []
|
|
assert outcome.not_found == ["Netflics"]
|
|
|
|
|
|
def test_resolve_invoice_vazio_e_mencoes_vazias() -> None:
|
|
assert InvoiceResolver().resolve(["x"], {}).resolved == []
|
|
assert InvoiceResolver().resolve([], {"11999999999": {}}).not_found == []
|
|
|
|
|
|
# ----- gate de reconhecimento (best_similarity antes do matcher LLM) ----------
|
|
|
|
|
|
class _GatingMatcher(_FakeMatcher):
|
|
"""``_FakeMatcher`` + ``best_similarity`` controlável, para exercitar o gate
|
|
de reconhecimento (o ``_FakeMatcher`` base NÃO expõe ``best_similarity`` →
|
|
gate degrada para legado e o LLM é sempre chamado)."""
|
|
|
|
def __init__(self, *, similarity: float, mapping=None, raises: bool = False) -> None:
|
|
super().__init__(mapping=mapping, raises=raises)
|
|
self._similarity = similarity
|
|
|
|
def best_similarity(self, mention: str, candidates: list[str]) -> float:
|
|
return self._similarity
|
|
|
|
|
|
def test_gate_bloqueia_mencao_ruido_e_nao_chama_o_llm(invoice_detail) -> None:
|
|
"""best_similarity <= 0.6 (transcrição/ruído): o matcher LLM NÃO é chamado e
|
|
a menção fica em not_found (o runtime pede para repetir)."""
|
|
matcher = _GatingMatcher(similarity=0.5, mapping={"youtube": ["Netflix Premium"]})
|
|
outcome = InvoiceResolver(matcher=matcher).resolve(["youtube"], invoice_detail)
|
|
assert matcher.calls == [] # LLM poupado
|
|
assert outcome.resolved == []
|
|
assert outcome.not_found == ["youtube"]
|
|
|
|
|
|
def test_gate_piso_estrito_reprova_em_06(invoice_detail) -> None:
|
|
"""Estrito: exatamente 0.6 reprova (ruído de Jaro-Winkler bate 0.600)."""
|
|
matcher = _GatingMatcher(similarity=0.6, mapping={"youtube": ["Netflix Premium"]})
|
|
outcome = InvoiceResolver(matcher=matcher).resolve(["youtube"], invoice_detail)
|
|
assert matcher.calls == []
|
|
assert outcome.not_found == ["youtube"]
|
|
|
|
|
|
def test_gate_libera_mencao_reconhecida_e_chama_o_llm(invoice_detail) -> None:
|
|
"""best_similarity > 0.6 (typo/pronúncia plausível): o LLM é acionado e o
|
|
match resolve normalmente."""
|
|
matcher = _GatingMatcher(similarity=0.92, mapping={"netflics": ["Netflix Premium"]})
|
|
outcome = InvoiceResolver(matcher=matcher).resolve(["Netflics"], invoice_detail)
|
|
assert len(matcher.calls) == 1 # LLM chamado
|
|
assert [i.canonical_name for i in outcome.resolved] == ["Netflix Premium"]
|
|
|
|
|
|
def test_gate_ausente_degrada_para_legado(invoice_detail) -> None:
|
|
"""Matcher sem best_similarity (fake legado): gate não bloqueia, LLM é
|
|
chamado mesmo para menção de baixa similaridade."""
|
|
matcher = _FakeMatcher(mapping={"qualquer": ["Netflix Premium"]})
|
|
outcome = InvoiceResolver(matcher=matcher).resolve(["qualquer"], invoice_detail)
|
|
assert len(matcher.calls) == 1
|
|
assert [i.canonical_name for i in outcome.resolved] == ["Netflix Premium"]
|
|
|
|
|
|
# ----- resolve_each (motor por menção, reusado pela expansão de RAG) ----------
|
|
|
|
|
|
def test_resolve_each_devolve_uma_resolucao_por_mencao(invoice_detail) -> None:
|
|
"""resolve_each devolve UMA ItemResolution por menção válida, 1:1 e na ordem,
|
|
sem classificar/dedupar entre menções: 1 match, N matches (distintos) e 0
|
|
match coexistem na lista de saída."""
|
|
resolutions = InvoiceResolver().resolve_each(
|
|
["Tim Fashion", "Aluguel de Filme", "Spotify"], invoice_detail
|
|
)
|
|
assert [r.mention for r in resolutions] == [
|
|
"Tim Fashion",
|
|
"Aluguel de Filme",
|
|
"Spotify",
|
|
]
|
|
# 1 match → não ambíguo
|
|
assert [m.canonical_name for m in resolutions[0].matches] == ["TIM Fashion Mensal"]
|
|
assert resolutions[0].is_ambiguous is False
|
|
# N matches distintos → ambíguo
|
|
assert sorted(m.canonical_name for m in resolutions[1].matches) == [
|
|
"Aluguel de Filme 1",
|
|
"Aluguel de Filme 2",
|
|
]
|
|
assert resolutions[1].is_ambiguous is True
|
|
# 0 match
|
|
assert resolutions[2].matches == []
|
|
|
|
|
|
def test_resolve_each_pula_mencoes_vazias_e_invalidas(invoice_detail) -> None:
|
|
"""Menções em branco/não-string são descartadas; a saída fica 1:1 com as
|
|
válidas, na ordem."""
|
|
resolutions = InvoiceResolver().resolve_each(
|
|
["", " ", "netflix"], invoice_detail
|
|
)
|
|
assert [r.mention for r in resolutions] == ["netflix"]
|
|
|
|
|
|
# ----- build_snapshot (snapshot completo da fatura) --------------------------
|
|
|
|
|
|
def test_build_snapshot_enumera_todos_os_itens_classificados(invoice_detail) -> None:
|
|
"""Cobre a fixture realista: 4 avulsos no SVA Detalhe Total do titular + 1
|
|
estratégico + 1 bundle + 1 avulso em Itens Eventuais + 1 avulso no
|
|
dependente = 8 itens. O ``Plano`` é ignorado por não estar em
|
|
SECTION_DEFAULTS."""
|
|
snapshot = InvoiceResolver().build_snapshot(invoice_detail)
|
|
assert len(snapshot) == 8
|
|
by_name = {item.canonical_name: item for item in snapshot}
|
|
assert by_name["TIM Fashion Mensal"].tool_category == "cancelar_vas_avulso"
|
|
assert by_name["Netflix Premium"].item_type == "estrategico"
|
|
assert by_name["HBO Max"].item_type == "bundle"
|
|
assert by_name["Tamboro Mensal"].msisdn == "11988888888"
|
|
|
|
|
|
def test_build_snapshot_invoice_vazio_retorna_tupla_vazia() -> None:
|
|
assert InvoiceResolver().build_snapshot({}) == ()
|
|
|
|
|
|
def test_build_snapshot_payload_invalido_retorna_tupla_vazia() -> None:
|
|
"""Tipo errado entra silenciosamente como vazio (mesmo padrão de
|
|
``resolve``)."""
|
|
assert InvoiceResolver().build_snapshot(None) == () # type: ignore[arg-type]
|
|
assert InvoiceResolver().build_snapshot("string") == () # type: ignore[arg-type]
|
|
|
|
|
|
def test_build_snapshot_classificacao_canonica_cobre_familia_multi_msisdn(
|
|
invoice_detail,
|
|
) -> None:
|
|
"""A fixture tem titular + 1 dependente. O snapshot agrega ambos, sem
|
|
distinguir por bucket. O consumer (classificador) filtra por msisdn ativo
|
|
no render."""
|
|
snapshot = InvoiceResolver().build_snapshot(invoice_detail)
|
|
msisdns = {item.msisdn for item in snapshot}
|
|
assert msisdns == {"11999999999", "11988888888"}
|
|
|
|
|
|
# ----- Bug #Gamedom: recognition_score (piso do resgate de funil) ------------
|
|
|
|
|
|
def _invoice_gamedom() -> dict:
|
|
return {
|
|
"11986811082": {
|
|
"Serviços de valor adicionado": [
|
|
{"desc": "Gamedom Mensal", "value": 14.99, "msisdn": "11986811082"},
|
|
{"desc": "Aluguel de Filme 2", "value": 9.90, "msisdn": "11986811082"},
|
|
]
|
|
}
|
|
}
|
|
|
|
|
|
def test_recognition_score_pontua_fala_mal_transcrita_do_caso_real() -> None:
|
|
""""Game is done" (URA) contra "Gamedom Mensal": cruza o piso de resgate com
|
|
folga e a margem para o 2º candidato afasta ambiguidade."""
|
|
from app.domain.contas.invoice_resolver import (
|
|
_FUNNEL_RESCUE_MARGIN,
|
|
_FUNNEL_RESCUE_THRESHOLD,
|
|
)
|
|
from app.domain.contas.item_matcher import SimilarityItemMatcher
|
|
|
|
resolver = InvoiceResolver(matcher=SimilarityItemMatcher())
|
|
|
|
score = resolver.recognition_score("Game is done", _invoice_gamedom())
|
|
|
|
assert score is not None
|
|
assert score.desc == "Gamedom Mensal"
|
|
assert score.best >= _FUNNEL_RESCUE_THRESHOLD
|
|
assert score.margin >= _FUNNEL_RESCUE_MARGIN
|
|
|
|
|
|
def test_recognition_score_reprova_ruido_e_fala_curta() -> None:
|
|
from app.domain.contas.invoice_resolver import (
|
|
_FUNNEL_RESCUE_THRESHOLD,
|
|
)
|
|
from app.domain.contas.item_matcher import SimilarityItemMatcher
|
|
|
|
resolver = InvoiceResolver(matcher=SimilarityItemMatcher())
|
|
|
|
for fala in ("Deine", "blarg zorp flim flam ploft", "tem jogo hoje?"):
|
|
score = resolver.recognition_score(fala, _invoice_gamedom())
|
|
assert score is not None
|
|
assert score.best < _FUNNEL_RESCUE_THRESHOLD, fala
|
|
|
|
|
|
def test_recognition_score_fail_closed_sem_matcher_ou_sem_candidatos() -> None:
|
|
"""Assimetria deliberada vs ``_passes_recognition_gate`` (que degrada para
|
|
True): o resgate CRIA autorização, então sem sinal devolve None."""
|
|
from app.domain.contas.item_matcher import SimilarityItemMatcher
|
|
|
|
assert InvoiceResolver().recognition_score("Game is done", _invoice_gamedom()) is None
|
|
|
|
resolver = InvoiceResolver(matcher=SimilarityItemMatcher())
|
|
assert resolver.recognition_score("", _invoice_gamedom()) is None
|
|
assert resolver.recognition_score("Game is done", {}) is None
|