Projeto do Agent Contas ORACLE
This commit is contained in:
16
app/domain/contas/parsers/__init__.py
Normal file
16
app/domain/contas/parsers/__init__.py
Normal file
@@ -0,0 +1,16 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
from typing import Any
|
||||
|
||||
from .bill_parser import TimBillParser
|
||||
from .bill_processor import PDFBillingProcessor
|
||||
|
||||
def parse_tim_bill_pdf(pdf_content: bytes, *, include_danfe: bool = False) -> dict[str, Any]:
|
||||
pdf_bytes = io.BytesIO(pdf_content)
|
||||
parser = TimBillParser()
|
||||
dfs = parser.parse_pdf(pdf_bytes)
|
||||
processor = PDFBillingProcessor(include_danfe=include_danfe)
|
||||
return processor.normalize(dfs)
|
||||
|
||||
__all__ = ["parse_tim_bill_pdf", "TimBillParser", "PDFBillingProcessor"]
|
||||
533
app/domain/contas/parsers/bill_parser.py
Normal file
533
app/domain/contas/parsers/bill_parser.py
Normal file
@@ -0,0 +1,533 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
import re
|
||||
import unicodedata as ud
|
||||
from pathlib import Path
|
||||
from typing import Dict, Union, Any
|
||||
|
||||
import pandas as pd
|
||||
import pdfplumber
|
||||
|
||||
###############################################################################
|
||||
# Normalização #
|
||||
###############################################################################
|
||||
_DASH_CHARS = "\u2010\u2011\u2012\u2013\u2014\u2212"
|
||||
_NBSP_CHARS = "\u00A0\u202F\u2007"
|
||||
_dash_trans = str.maketrans({c: "-" for c in _DASH_CHARS})
|
||||
_nbsp_trans = str.maketrans({c: " " for c in _NBSP_CHARS})
|
||||
|
||||
|
||||
def _normalize_line(s: str) -> str:
|
||||
s = s.translate(_dash_trans).translate(_nbsp_trans)
|
||||
s = ud.normalize("NFKC", s)
|
||||
return re.sub(r"\s{2,}", " ", s.strip())
|
||||
|
||||
|
||||
def _parse_money(value: str) -> float | None:
|
||||
value = value.strip()
|
||||
if value == "-":
|
||||
return None
|
||||
return float(value.replace(".", "").replace(",", "."))
|
||||
|
||||
###############################################################################
|
||||
# Regex – Cabeçalhos #
|
||||
###############################################################################
|
||||
RX_MSISDN_HEADER = re.compile(r"Vantagens que seu plano oferece:\s*(?P<msisdn>\d{2}\s\d{5}-\d{4})", re.I)
|
||||
RX_MSISDN_SEU_NUM = re.compile(r"SEU\s+NÚMERO\s+TIM\s+(?P<msisdn>\d{2}\s\d{5}-\d{4})", re.I)
|
||||
RX_MSISDN_DETALHE = re.compile(r"Detalhamento de Serviços\s+N[°º]\s*(?P<msisdn>\d{2}\s\d{5}-\d{4})", re.I)
|
||||
|
||||
SECTION_HEADERS: Dict[str, str] = {
|
||||
"IGNORE Ilimitados": r"^Detalhamento de Serviços Ilimitados",
|
||||
"IGNORE Detalhamento": r"^Detalhamento de Serviç[io]s\b",
|
||||
"Fatura Resumo": r"^FATURA\s+RESUMO",
|
||||
"DANFE-COM": r"^DANFE-COM\b",
|
||||
"Plano": r"^Plano\b",
|
||||
"Mensalidades Adicionais": r"^MENSALIDADES\s+ADICIONAIS",
|
||||
"Itens Eventuais": r"^ITENS\s+EVENTUAIS",
|
||||
"TIM Viagem": r"^TIM\s+VIAGEM",
|
||||
"SVA Detalhe Total": r"^Serviços\s+de\s+Valor\s+Adicionado\s+Total",
|
||||
"Desconto Franquia": r"^Desconto\(s\)\s+Franquia",
|
||||
"Desconto SVA": r"^Desconto\(s\)\s+Serviç[io]s",
|
||||
"Franquia": r"^Franquia\s*\(s\)",
|
||||
"SVA": r"^Serviços\s+de\s+valor\s+adicionado\(SVA\)",
|
||||
"Chamadas Rede TIM": r"^CHAMADAS\s+DENTRO\s+DA\s+REDE\s+TIM",
|
||||
"Chamadas Fora Rede TIM": r"^CHAMADAS\s+FORA\s+DA\s+REDE\s+TIM",
|
||||
"Outros Valores": r"^OUTROS\s+VALORES",
|
||||
"Deduções": r"^DEDUÇÕES",
|
||||
"Roaming Internacional": r"^ROAMING\s+INTERNACIONAL",
|
||||
"Cobranças de Terceiros": r"^COBRANÇAS\s+DE\s+TERCEIROS",
|
||||
"Débitos de outras operadoras": r"^DÉBITOS\s+DE\s+OUTRAS\s+OPERADORAS"
|
||||
}
|
||||
SECTION_REGEX = {k: re.compile(v, re.I) for k, v in SECTION_HEADERS.items()}
|
||||
RX_SECTION_TOTAL = re.compile(r"(?:R\$\s*)?(-?[\d\.]+,\d{2})")
|
||||
|
||||
# Patterns que encerram a seção atual sem iniciar uma nova
|
||||
RX_SECTION_BREAK = re.compile(
|
||||
r"^(Nota Fiscal de Servi|SEUS\s+DADOS|ITEM\s+QTDE\s+ICMS|TOTAL\s+TIM|"
|
||||
r"Ficou\s+com\s+dúvidas|Bancos\s+Conveniados|Reservado\s+ao\s+fisco|"
|
||||
r"Tipo:\s+N\s+-\s+Normal)",
|
||||
re.I,
|
||||
)
|
||||
|
||||
###############################################################################
|
||||
# Regex – Linhas #
|
||||
###############################################################################
|
||||
RX_PERIOD = r"(?P<period>(?:\d{2}/\d{2}\s+a\s+\d{2}/\d{2}|-))"
|
||||
RX_DAYS = r"(?P<days>(?:\d+|-))"
|
||||
RX_VALUE = r"(?P<value>-?\d+,\d{2}|Incluído)"
|
||||
RX_PARCEL = r"(?P<parcel>(?:\d+/\d+|-))"
|
||||
|
||||
# --- Plano / Desconto / Chamadas (layout padrão: QTY DESC PARCELA PERIOD DIAS VALOR) ---
|
||||
RX_SIMPLE = re.compile(rf"^\s*(?P<qty>\d+)\s+(?P<desc>.+?)\s+{RX_PARCEL}\s+{RX_PERIOD}\s+{RX_DAYS}\s+{RX_VALUE}\s*$")
|
||||
RX_CONSUMPTION = re.compile(rf"^\s*(?P<qty>\d+)\s+(?P<desc>.+?)\s+{RX_PARCEL}\s+(?P<franchise>Ilimitado|-)\s+(?P<consumption>\d{{1,3}}m\d{{2}}s)\s+{RX_PERIOD}\s+{RX_DAYS}\s+{RX_VALUE}\s*$")
|
||||
RX_SUBTOTAL = re.compile(r"^Subtotal\s+(?P<value>-?\d+,\d{2})\s*$", re.I)
|
||||
RX_DETAIL = re.compile(r"^\d+\s+.+?\s+(-?\d+,\d{2})\s*$")
|
||||
# Fatura Resumo
|
||||
RX_RESUMO = re.compile(r"^\s*(?P<desc>.+?)\s+R\$\s*(?P<value>-?\d+,\d{2})\s*$")
|
||||
RX_TOTAL_GERAL = re.compile(r"^Total\s+geral\s+R\$\s*(?P<value>-?\d+,\d{2})", re.I)
|
||||
RX_FATURA_METADATA = re.compile(
|
||||
r"FATURA\s+PER[ÍI]ODO\s+EMISS[ÃA]O\s+POSTAGEM\s+"
|
||||
r"(?P<fatura>\S+)\s+"
|
||||
r"(?P<period>\d{2}/\d{2}\s+a\s+\d{2}/\d{2})\s+"
|
||||
r"(?P<emissao>\d{2}/\d{2}/\d{4})\s+"
|
||||
r"(?P<postagem>\d{2}/\d{2}/\d{4})",
|
||||
re.I,
|
||||
)
|
||||
RX_DANFE_TOTAL = re.compile(r"^Total\s+geral\s+R\$\s*(?P<value>-?\d+(?:\.\d{3})*,\d{2})", re.I)
|
||||
RX_DANFE_ROW = re.compile(
|
||||
r"^(?P<desc>.+?)\s+"
|
||||
r"(?P<unit>[A-Z]{2})\s+"
|
||||
r"(?P<qty>\d+(?:,\d+)?)\s+"
|
||||
r"(?P<preco_unit>-?\d+(?:\.\d{3})*,\d{2})\s+"
|
||||
r"(?P<pis_cofins>-?\d+(?:\.\d{3})*,\d{2}|-)\s+"
|
||||
r"(?P<bc_icms>-?\d+(?:\.\d{3})*,\d{2}|-)\s+"
|
||||
r"(?P<aliq_icms>\d+(?:,\d+)?%|-)\s+"
|
||||
r"(?P<icms>-?\d+(?:\.\d{3})*,\d{2}|-)\s+"
|
||||
r"(?P<value>-?\d+(?:\.\d{3})*,\d{2})$"
|
||||
)
|
||||
|
||||
# --- Itens Eventuais (layout: QTY DESC PARCELA FRANQUIA CONSUMO PERIODO DIAS VALOR) ---
|
||||
# Com consumo real (ex: 19,77GB) ou consumo zerado ("0")
|
||||
RX_EVENTUAIS = re.compile(
|
||||
r"^\s*(?P<qty>\d+)\s+(?P<desc>.+?)\s+-\s+-\s+"
|
||||
r"(?P<consumption>(?:\S+(?:GB|MB|KB)|0))\s+-\s+-\s+"
|
||||
r"(?P<value>-?\d+,\d{2})\s*$"
|
||||
)
|
||||
# Sem consumo (todos "-")
|
||||
RX_EVENTUAIS_NO_CONS = re.compile(
|
||||
r"^\s*(?P<qty>\d+)\s+(?P<desc>.+?)"
|
||||
r"(?:\s+-){5}\s+"
|
||||
r"(?P<value>-?\d+,\d{2})\s*$"
|
||||
)
|
||||
|
||||
# --- SVA Detalhe Total (layout: # DATA HORA ORIGEM DESC NUMERO TIPO PACOTE - - VALOR) ---
|
||||
# Exemplo: 1 09/04/25 - 02:54:18 RJ AREA 21 TIM Saude Mensal 00700001003511 N FP - - 14,99
|
||||
RX_SVA_DETAIL = re.compile(
|
||||
r"^\s*(?P<seq>\d+)\s+"
|
||||
r"(?P<date>\d{2}/\d{2}/\d{2})\s+-\s+(?P<time>\d{2}:\d{2}:\d{2})\s+"
|
||||
r"\S+\s+AREA\s+\d{2}\s+" # origem (ex: RJ AREA 21)
|
||||
r"(?P<desc>.+?)\s+"
|
||||
r"(?P<number>\d{10,})\s+"
|
||||
r"[A-Z/]+\s+[A-Z]+\s+"
|
||||
r"-\s+-\s+"
|
||||
r"(?P<value>-?\d+,\d{2})\s*$"
|
||||
)
|
||||
# Linha de total SVA (ex: "3 - 2 2 29,98")
|
||||
RX_SVA_SUMMARY = re.compile(
|
||||
r"^\s*(?P<seq>\d+)\s+-\s+\d+\s+\d+\s+(?P<value>-?\d+,\d{2})\s*$"
|
||||
)
|
||||
|
||||
# --- Informações Complementares (layout: QTY DESC PARCELA PERÍODO DIAS VALOR) ---
|
||||
RX_INFO_COMPL = re.compile(
|
||||
r"^\s*(?P<qty>\d+)\s+(?P<desc>.+?)\s+"
|
||||
r"(?P<parcel>(?:\d+/\d+|-))\s+"
|
||||
r"(?P<period>\d{2}/\d{2}\s+a\s+\d{2}/\d{2})\s+"
|
||||
r"(?P<days>\d+)\s+"
|
||||
r"(?P<value>-?\d+,\d{2})\s*$"
|
||||
)
|
||||
# --- Chamadas resumo (layout: QTY DESC PARCELA FRANQUIA CONSUMO PERIODO DIAS VALOR) ---
|
||||
RX_CHAMADAS = re.compile(
|
||||
r"^\s*(?P<qty>\d+)\s+(?P<desc>.+?)\s+-\s+-\s+"
|
||||
r"(?P<consumption>\d{1,3}m\d{2}s)\s+-\s+-\s+"
|
||||
r"(?P<value>-?\d+,\d{2})\s*$"
|
||||
)
|
||||
|
||||
# --- Mensalidades Adicionais (layout: QTY DESC PARCELA FRANQUIA CONSUMO PERIODO DIAS VALOR) ---
|
||||
# Ex.: "1 Apple Music SVA Mes - - 0 - - 21,40" (consumo "0" e sem período)
|
||||
# "1 Internet 30GB - - - 25/04 a 24/05 30 0,00"
|
||||
RX_MENSALIDADE = re.compile(
|
||||
rf"^\s*(?P<qty>\d+)\s+(?P<desc>.+?)\s+{RX_PARCEL}\s+"
|
||||
rf"(?P<franchise>Ilimitado|-)\s+(?P<consumption>\S+)\s+"
|
||||
rf"{RX_PERIOD}\s+{RX_DAYS}\s+{RX_VALUE}\s*$"
|
||||
)
|
||||
|
||||
###############################################################################
|
||||
# Parser #
|
||||
###############################################################################
|
||||
class TimBillParser:
|
||||
"""Extrai e estrutura faturas TIM."""
|
||||
|
||||
def __init__(self, *, x_tolerance: float = 1.5, y_tolerance: float = 3.0):
|
||||
self.x_tol = x_tolerance
|
||||
self.y_tol = y_tolerance
|
||||
self.data: Dict[str, pd.DataFrame] = {}
|
||||
|
||||
# ---------------- API pública ----------------
|
||||
def parse_pdf(self, pdf_bytes: io.BytesIO) -> Dict[str, pd.DataFrame]:
|
||||
|
||||
# 1. Extrai texto bruto de todas as páginas
|
||||
with pdfplumber.open(pdf_bytes) as pdf:
|
||||
raw_text = "\n".join(
|
||||
page.extract_text(x_tolerance=self.x_tol,
|
||||
y_tolerance=self.y_tol) or ""
|
||||
for page in pdf.pages
|
||||
)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 2. Captura o bloco FATURA RESUMO inteiro
|
||||
bloco_pat = re.compile(
|
||||
r"FATURA\s+RESUMO(?P<body>.*?)Total\s+geral\s+R\$\s*(?P<total>-?[\d\.,]+)",
|
||||
re.S | re.I,
|
||||
)
|
||||
resumo_items = []
|
||||
resumo_metadata = self._extract_fatura_metadata(raw_text)
|
||||
if resumo_metadata.get("period"):
|
||||
resumo_items.append(
|
||||
dict(qty=None, desc="PERÍODO", parcel=None, period=resumo_metadata["period"],
|
||||
days=None, value=None, franchise=None, consumption=None, msisdn=None,
|
||||
emissao=None, section_total=None)
|
||||
)
|
||||
if resumo_metadata.get("emissao"):
|
||||
resumo_items.append(
|
||||
dict(qty=None, desc="EMISSÃO", parcel=None, period=None,
|
||||
days=None, value=None, franchise=None, consumption=None, msisdn=None,
|
||||
emissao=resumo_metadata["emissao"], section_total=None)
|
||||
)
|
||||
m_resumo = bloco_pat.search(raw_text)
|
||||
if m_resumo:
|
||||
corpo = m_resumo.group("body")
|
||||
total_val = float(m_resumo.group("total").replace(".", "").replace(",", "."))
|
||||
for ln in corpo.splitlines():
|
||||
ln = _normalize_line(ln)
|
||||
if not ln:
|
||||
continue
|
||||
m_ln = re.match(r"(.+?)\s+R\$\s*([-\d\.,]+)", ln)
|
||||
if m_ln:
|
||||
resumo_items.append(
|
||||
dict(qty=None, desc=m_ln.group(1), parcel=None, period=None,
|
||||
days=None,
|
||||
value=float(m_ln.group(2).replace(".", "").replace(",", ".")),
|
||||
franchise=None, consumption=None, msisdn=None,
|
||||
section_total=None)
|
||||
)
|
||||
resumo_items.append(
|
||||
dict(qty=None, desc="Total geral", parcel=None, period=None,
|
||||
days=None, value=total_val, franchise=None, consumption=None,
|
||||
msisdn=None, section_total=None)
|
||||
)
|
||||
# 3. Remove o bloco para que não polua a etapa linha-a-linha
|
||||
raw_text = raw_text.replace(m_resumo.group(0), "")
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 4. Processa o restante normalmente
|
||||
dfs = self._parse_text(raw_text)
|
||||
if resumo_items:
|
||||
dfs["Fatura Resumo"] = pd.DataFrame(resumo_items)
|
||||
|
||||
self.data = dfs
|
||||
return dfs
|
||||
|
||||
# ---------------- interno -------------------
|
||||
def _extract_fatura_metadata(self, text: str) -> dict[str, str]:
|
||||
normalized_lines = [_normalize_line(line) for line in text.splitlines()]
|
||||
normalized_text = " ".join(line for line in normalized_lines if line)
|
||||
if m := RX_FATURA_METADATA.search(normalized_text):
|
||||
return {
|
||||
"period": m.group("period"),
|
||||
"emissao": m.group("emissao"),
|
||||
}
|
||||
return {}
|
||||
|
||||
def _parse_text(self, text: str) -> Dict[str, pd.DataFrame]:
|
||||
"""Quebra o texto extraído em DataFrames por seção."""
|
||||
buf: Dict[str, list] = {k: [] for k in SECTION_HEADERS if not k.startswith("IGNORE")}
|
||||
current_sec = current_msisdn = None
|
||||
current_total: float | None = None
|
||||
last_sva_detail_item: dict | None = None
|
||||
|
||||
for raw in text.splitlines():
|
||||
line = _normalize_line(raw)
|
||||
if not line:
|
||||
continue
|
||||
|
||||
# cabeçalho de MSISDN (3 variantes)
|
||||
for rx_ms in (RX_MSISDN_HEADER, RX_MSISDN_SEU_NUM, RX_MSISDN_DETALHE):
|
||||
if (m := rx_ms.search(line)):
|
||||
current_msisdn = m.group("msisdn").replace(" ", "")
|
||||
break
|
||||
else:
|
||||
m = None
|
||||
if m:
|
||||
continue
|
||||
|
||||
# section break — encerra seção atual sem iniciar outra
|
||||
if RX_SECTION_BREAK.match(line):
|
||||
current_sec = None
|
||||
continue
|
||||
|
||||
# detecta novo cabeçalho (a menos que estejamos em Fatura Resumo)
|
||||
if current_sec != "Fatura Resumo":
|
||||
sec = next((s for s, rx in SECTION_REGEX.items() if rx.match(line)), None)
|
||||
else:
|
||||
sec = None
|
||||
|
||||
if (sec in ("IGNORE Ilimitados", "IGNORE Detalhamento")):
|
||||
current_sec = None
|
||||
continue
|
||||
if sec is not None:
|
||||
current_sec = sec
|
||||
last_sva_detail_item = None
|
||||
mt = RX_SECTION_TOTAL.search(line)
|
||||
current_total = float(mt.group(1).replace(".", "").replace(",", ".")) if mt else None
|
||||
continue
|
||||
|
||||
if not current_sec:
|
||||
if line[0].isdigit():
|
||||
item = self._parse_sva_detail(line)
|
||||
if item:
|
||||
current_sec = "SVA Detalhe Total"
|
||||
current_total = None
|
||||
item["msisdn"] = current_msisdn
|
||||
item["section_total"] = None
|
||||
buf.setdefault(current_sec, []).append(item)
|
||||
last_sva_detail_item = item
|
||||
continue
|
||||
|
||||
# pula cabeçalhos de página
|
||||
if re.match(r"^Página\s+\d+\s+de\s+\d+", line, re.I):
|
||||
continue
|
||||
|
||||
if current_sec == "DANFE-COM":
|
||||
item = self._parse_danfe(line)
|
||||
if item:
|
||||
item["msisdn"] = None
|
||||
item["section_total"] = current_total
|
||||
buf.setdefault(current_sec, []).append(item)
|
||||
if item.get("is_total"):
|
||||
current_sec = None
|
||||
continue
|
||||
|
||||
if current_sec == "SVA Detalhe Total":
|
||||
if self._is_sva_header_line(line):
|
||||
continue
|
||||
if RX_SVA_SUMMARY.match(line):
|
||||
last_sva_detail_item = None
|
||||
continue
|
||||
if line[0].isdigit():
|
||||
item = self._parse_sva_detail(line)
|
||||
if item:
|
||||
item["msisdn"] = current_msisdn
|
||||
item["section_total"] = current_total
|
||||
buf.setdefault(current_sec, []).append(item)
|
||||
last_sva_detail_item = item
|
||||
continue
|
||||
if last_sva_detail_item:
|
||||
last_sva_detail_item["desc"] = (
|
||||
f"{last_sva_detail_item['desc']} {line}"
|
||||
).strip()
|
||||
continue
|
||||
|
||||
# -------- linhas padrão --------
|
||||
if not (line[0].isdigit() or line.lower().startswith("subtotal")):
|
||||
continue
|
||||
|
||||
item = self._parse_item_line(line, current_sec)
|
||||
if item:
|
||||
item["msisdn"] = current_msisdn
|
||||
item["section_total"] = current_total
|
||||
buf.setdefault(current_sec, []).append(item)
|
||||
|
||||
return {s: pd.DataFrame(lst) for s, lst in buf.items() if lst}
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
def _parse_item_line(self, line: str, section: str = "") -> dict | None:
|
||||
"""Parseia uma linha de item de acordo com a seção."""
|
||||
|
||||
# Ignora subtotal
|
||||
if RX_SUBTOTAL.match(line):
|
||||
return None
|
||||
|
||||
# ── Mensalidades Adicionais ──
|
||||
if section == "Mensalidades Adicionais":
|
||||
return self._parse_mensalidades(line)
|
||||
|
||||
# ── Itens Eventuais ──
|
||||
if section == "Itens Eventuais":
|
||||
return self._parse_eventuais(line)
|
||||
|
||||
# ── SVA Detalhe Total ──
|
||||
if section == "SVA Detalhe Total":
|
||||
return self._parse_sva_detail(line)
|
||||
|
||||
# ── DANFE-COM ──
|
||||
if section == "DANFE-COM":
|
||||
return self._parse_danfe(line)
|
||||
|
||||
# ── Chamadas Rede TIM / Chamadas Fora Rede TIM ──
|
||||
if section.startswith("Chamadas"):
|
||||
return self._parse_chamadas(line)
|
||||
|
||||
# ── Plano / Franquia / SVA / Desconto Franquia / Desconto SVA (layout padrão) ──
|
||||
return self._parse_standard(line)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
def _parse_danfe(self, line: str) -> dict | None:
|
||||
"""DANFE-COM: linhas da tabela de itens e total geral."""
|
||||
if line.upper().startswith("ITENS "):
|
||||
return None
|
||||
|
||||
if (m := RX_DANFE_TOTAL.match(line)):
|
||||
return {
|
||||
"desc": "Total geral",
|
||||
"unit": None,
|
||||
"qty": None,
|
||||
"preco_unit": None,
|
||||
"pis_cofins": None,
|
||||
"bc_icms": None,
|
||||
"aliq_icms": None,
|
||||
"icms": None,
|
||||
"value": _parse_money(m.group("value")),
|
||||
"is_total": True,
|
||||
}
|
||||
|
||||
if (m := RX_DANFE_ROW.match(line)):
|
||||
d = m.groupdict()
|
||||
return {
|
||||
"desc": d["desc"].strip(),
|
||||
"unit": d["unit"],
|
||||
"qty": float(d["qty"].replace(",", ".")),
|
||||
"preco_unit": _parse_money(d["preco_unit"]),
|
||||
"pis_cofins": _parse_money(d["pis_cofins"]),
|
||||
"bc_icms": _parse_money(d["bc_icms"]),
|
||||
"aliq_icms": None if d["aliq_icms"] == "-" else d["aliq_icms"],
|
||||
"icms": _parse_money(d["icms"]),
|
||||
"value": _parse_money(d["value"]),
|
||||
"is_total": False,
|
||||
}
|
||||
|
||||
return None
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
def _parse_standard(self, line: str) -> dict | None:
|
||||
"""Regex padrão: QTY DESC PARCELA [FRANCHISE CONSUMPTION] PERIOD DAYS VALUE."""
|
||||
for rx in (RX_CONSUMPTION, RX_SIMPLE):
|
||||
if (m := rx.match(line)):
|
||||
d = m.groupdict()
|
||||
|
||||
# --- corrige parcel dentro de desc (caso d["parcel"] == "-") ----
|
||||
if d["parcel"] == "-":
|
||||
tail = re.search(r"\b(\d+/\d+)$", d["desc"])
|
||||
if tail:
|
||||
d["parcel"] = tail.group(1)
|
||||
d["desc"] = d["desc"][: tail.start()].rstrip(" -")
|
||||
# ----------------------------------------------------------------
|
||||
|
||||
# limpa trailing dashes e valores de franquia residuais do desc
|
||||
d["desc"] = re.sub(r"(\s+-\s+\d+(?:GB|MB|KB))(?:\s+-)*\s*$|(?:\s+-)+\s*$", "", d["desc"]).strip()
|
||||
|
||||
value = d["value"]
|
||||
d["qty"] = float(d["qty"])
|
||||
d["days"] = None if d.get("days") in (None, "-") else int(d["days"])
|
||||
d["_is_included_value"] = value == "Incluído"
|
||||
d["value"] = 0.0 if d["_is_included_value"] else float(value.replace(",", "."))
|
||||
return d
|
||||
return None
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
def _parse_eventuais(self, line: str) -> dict | None:
|
||||
"""Itens Eventuais: QTY DESC PARCELA FRANQUIA CONSUMO PERIODO DIAS VALUE."""
|
||||
# Tenta com consumo real (ex: 19,77GB)
|
||||
if (m := RX_EVENTUAIS.match(line)):
|
||||
return {
|
||||
"qty": float(m.group("qty")),
|
||||
"desc": m.group("desc").strip(),
|
||||
"parcel": None,
|
||||
"period": None,
|
||||
"days": None,
|
||||
"value": float(m.group("value").replace(",", ".")),
|
||||
"franchise": None,
|
||||
"consumption": None if m.group("consumption") == "0" else m.group("consumption"),
|
||||
}
|
||||
# Tenta sem consumo (todos "-")
|
||||
if (m := RX_EVENTUAIS_NO_CONS.match(line)):
|
||||
return {
|
||||
"qty": float(m.group("qty")),
|
||||
"desc": m.group("desc").strip(),
|
||||
"parcel": None,
|
||||
"period": None,
|
||||
"days": None,
|
||||
"value": float(m.group("value").replace(",", ".")),
|
||||
"franchise": None,
|
||||
"consumption": None,
|
||||
}
|
||||
return None
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
def _parse_mensalidades(self, line: str) -> dict | None:
|
||||
"""Mensalidades Adicionais: QTY DESC PARCELA FRANQUIA CONSUMO PERIODO DIAS VALOR."""
|
||||
if (m := RX_MENSALIDADE.match(line)):
|
||||
d = m.groupdict()
|
||||
d["qty"] = float(d["qty"])
|
||||
d["desc"] = d["desc"].strip()
|
||||
d["parcel"] = None if d["parcel"] == "-" else d["parcel"]
|
||||
d["franchise"] = None if d["franchise"] == "-" else d["franchise"]
|
||||
d["consumption"] = None if d["consumption"] in ("-", "0") else d["consumption"]
|
||||
d["period"] = None if d["period"] == "-" else d["period"]
|
||||
d["days"] = None if d["days"] == "-" else int(d["days"])
|
||||
value = d["value"]
|
||||
d["_is_included_value"] = value == "Incluído"
|
||||
d["value"] = 0.0 if d["_is_included_value"] else float(value.replace(",", "."))
|
||||
return d
|
||||
# Layout de 6 colunas (sem franquia/consumo) → regex padrão
|
||||
return self._parse_standard(line)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
def _is_sva_header_line(self, line: str) -> bool:
|
||||
return bool(
|
||||
re.match(r"^(DURAÇÃO/VOLUME|#\s+DATA\s*/\s*HORA)\b", line, re.I)
|
||||
)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
def _parse_sva_detail(self, line: str) -> dict | None:
|
||||
"""SVA Detalhe Total: linhas com data/hora e linha de totalização."""
|
||||
# Linha de detalhe com data/hora
|
||||
if (m := RX_SVA_DETAIL.match(line)):
|
||||
return {
|
||||
"qty": 1.0,
|
||||
"desc": m.group("desc").strip(),
|
||||
"parcel": None,
|
||||
"period": m.group("date"),
|
||||
"days": None,
|
||||
"value": float(m.group("value").replace(",", ".")),
|
||||
"franchise": None,
|
||||
"consumption": None,
|
||||
}
|
||||
return None
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
def _parse_chamadas(self, line: str) -> dict | None:
|
||||
"""Chamadas Rede TIM / Fora Rede: QTY DESC - - CONSUMPTION - - VALUE."""
|
||||
if (m := RX_CHAMADAS.match(line)):
|
||||
return {
|
||||
"qty": float(m.group("qty")),
|
||||
"desc": m.group("desc").strip(),
|
||||
"parcel": None,
|
||||
"period": None,
|
||||
"days": None,
|
||||
"value": float(m.group("value").replace(",", ".")),
|
||||
"franchise": None,
|
||||
"consumption": m.group("consumption"),
|
||||
}
|
||||
# Fallback para o padrão (algumas chamadas usam formato padrão)
|
||||
return self._parse_standard(line)
|
||||
743
app/domain/contas/parsers/bill_processor.py
Normal file
743
app/domain/contas/parsers/bill_processor.py
Normal file
@@ -0,0 +1,743 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import unicodedata as ud
|
||||
from typing import Any, Dict, List, Tuple
|
||||
|
||||
import pandas as pd
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Regex para linha de VAS
|
||||
# ---------------------------------------------------------------------------
|
||||
RX_VAS_LINE = re.compile(
|
||||
r"""
|
||||
^\d+\s+ # índice "#"
|
||||
(?P<date>\d{2}/\d{2}/\d{2}) # DD/MM/YY
|
||||
\s+-\s+\d{2}:\d{2}:\d{2}\s+ # " - HH:MM:SS "
|
||||
[A-Z]{2}\sAREA\s\d+\s+ # "SP AREA 11"
|
||||
(?P<name>.+?)\s+ # nome do serviço
|
||||
\d{8,} # número chamado
|
||||
""",
|
||||
re.X,
|
||||
)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
class PDFBillingProcessor:
|
||||
"""
|
||||
Pós-processamento dos DataFrames gerados pelo TimBillParser.
|
||||
"""
|
||||
|
||||
SECTIONS = [
|
||||
"Fatura Resumo",
|
||||
"Plano",
|
||||
"DANFE-COM",
|
||||
"Descontos",
|
||||
"Itens Eventuais",
|
||||
"Mensalidades Adicionais",
|
||||
"TIM Viagem",
|
||||
"Outros Valores",
|
||||
"Chamadas Rede TIM",
|
||||
"SVA Detalhe Total",
|
||||
"Serviços Bundle Inclusos",
|
||||
"Deduções",
|
||||
"Roaming Internacional",
|
||||
"Cobranças de Terceiros",
|
||||
"Débitos de outras operadoras",
|
||||
]
|
||||
STRATEGIC_SERVICE_SECTIONS = {
|
||||
"SVA Detalhe Total",
|
||||
"Itens Eventuais",
|
||||
"Serviços Bundle Inclusos",
|
||||
"Mensalidades Adicionais",
|
||||
"Outros Valores",
|
||||
"Cobranças de Terceiros",
|
||||
}
|
||||
SECTION_CLASS_MAP = {
|
||||
"SVA Detalhe Total": "avulso",
|
||||
"Itens Eventuais": "avulso",
|
||||
"Serviços Bundle Inclusos": "bundle",
|
||||
}
|
||||
CLASSE_VERB_MAP = {
|
||||
"avulso": "cancelar",
|
||||
"bundle": "falar sobre",
|
||||
"estrategico": "falar sobre",
|
||||
}
|
||||
STRATEGIC_SERVICES = [
|
||||
"Apple Music",
|
||||
"Deezer",
|
||||
"Disney",
|
||||
"Fuze",
|
||||
"Forge",
|
||||
"HBO",
|
||||
"Looke",
|
||||
"Max Mensal",
|
||||
"Netflix",
|
||||
"Paramount",
|
||||
"TIM Cloud Gaming",
|
||||
"YouTube",
|
||||
"Globoplay",
|
||||
"Amazon Prime"
|
||||
]
|
||||
|
||||
DIGIT_WORDS = {
|
||||
"0": "zero",
|
||||
"1": "um",
|
||||
"2": "dois",
|
||||
"3": "tres",
|
||||
"4": "quatro",
|
||||
"5": "cinco",
|
||||
"6": "seis",
|
||||
"7": "sete",
|
||||
"8": "oito",
|
||||
"9": "nove",
|
||||
}
|
||||
|
||||
def __init__(self, *, include_danfe: bool = False):
|
||||
self.include_danfe = include_danfe
|
||||
self._strategic_service_patterns = [
|
||||
re.compile(rf"(?:^|\s){re.escape(self._service_match_key(service))}(?:\s|$)")
|
||||
for service in self.STRATEGIC_SERVICES
|
||||
]
|
||||
|
||||
def remove_parentheses(self, text: str) -> str:
|
||||
return re.sub(r'\s*\([^)]*\)', '', str(text)).strip()
|
||||
|
||||
def remove_plan_recognition_marker(self, text: str) -> str:
|
||||
return re.sub(
|
||||
r"\s*\(\s*\d+\s*/\s*P[ÓO]S\s*/\s*SMP\s*\)",
|
||||
"",
|
||||
str(text),
|
||||
flags=re.I,
|
||||
).strip()
|
||||
|
||||
def _format_plan_version_number(self, text: str) -> str:
|
||||
return re.sub(r"(?<!\d)(\d)\s+(\d)(?!\d)", r"\1.\2", str(text))
|
||||
|
||||
def _format_msisdn(self, text: Any) -> str:
|
||||
return re.sub(r"\D", "", str(text))
|
||||
|
||||
def _vocalize_digits(self, digits: str) -> str:
|
||||
return " ".join(self.DIGIT_WORDS[d] for d in digits if d in self.DIGIT_WORDS)
|
||||
|
||||
def _collect_msisdns(self, node: Any, found: set) -> None:
|
||||
"""Percorre recursivamente o payload coletando todos os msisdn presentes."""
|
||||
if isinstance(node, dict):
|
||||
for key, value in node.items():
|
||||
if key == "msisdn" and value is not None:
|
||||
msisdn = self._format_msisdn(value)
|
||||
if msisdn:
|
||||
found.add(msisdn)
|
||||
else:
|
||||
self._collect_msisdns(value, found)
|
||||
elif isinstance(node, list):
|
||||
for item in node:
|
||||
self._collect_msisdns(item, found)
|
||||
|
||||
def _build_vocalized_msisdn(self, final_output: Dict[str, Any]) -> Dict[str, str]:
|
||||
"""Mapeia cada msisdn da fatura para a vocalização dos seus 4 últimos dígitos."""
|
||||
found: set = set()
|
||||
for key, value in final_output.items():
|
||||
if re.fullmatch(r"\d+", str(key)):
|
||||
found.add(self._format_msisdn(key))
|
||||
self._collect_msisdns(value, found)
|
||||
|
||||
return {
|
||||
msisdn: self._vocalize_digits(msisdn[-4:])
|
||||
for msisdn in sorted(found)
|
||||
}
|
||||
|
||||
def _clean_plan_name(self, desc: str, *, format_version_number: bool = True) -> str:
|
||||
text = self.remove_parentheses(self.remove_plan_recognition_marker(desc))
|
||||
if format_version_number:
|
||||
text = self._format_plan_version_number(text)
|
||||
return text
|
||||
|
||||
def _discount_match_key(self, desc: str) -> str:
|
||||
text = self._clean_plan_name(desc)
|
||||
text = ud.normalize("NFKD", text)
|
||||
text = "".join(ch for ch in text if not ud.combining(ch))
|
||||
text = text.casefold()
|
||||
text = re.sub(r"\b\d+/\d+\b", " ", text)
|
||||
text = re.sub(r"\b\d+\b", " ", text)
|
||||
text = re.sub(r"[^a-z]+", " ", text)
|
||||
return re.sub(r"\s+", " ", text).strip()
|
||||
|
||||
def _service_match_key(self, desc: str) -> str:
|
||||
text = ud.normalize("NFKD", str(desc))
|
||||
text = "".join(ch for ch in text if not ud.combining(ch))
|
||||
text = text.casefold()
|
||||
text = re.sub(r"[^a-z0-9]+", " ", text)
|
||||
return re.sub(r"\s+", " ", text).strip()
|
||||
|
||||
def _is_strategic_service(self, desc: Any) -> bool:
|
||||
desc_key = self._service_match_key(str(desc or ""))
|
||||
return any(pattern.search(desc_key) for pattern in self._strategic_service_patterns)
|
||||
|
||||
def _is_controle_plan(self, desc: Any) -> bool:
|
||||
desc_key = self._service_match_key(str(desc or ""))
|
||||
return bool(re.search(r"\b(?:controle|ctrl|crtl)\b", desc_key))
|
||||
|
||||
def calculate_discount(self, desc_match: str, msisdn: str, descontos_df: pd.DataFrame) -> float:
|
||||
# Filtra por msisdn e verifica se o desc_match está contido no desc
|
||||
descontos_filtrados = descontos_df[
|
||||
(descontos_df['msisdn'] == msisdn) &
|
||||
(descontos_df['desc'].str.lower().str.contains(desc_match.lower(), na=False))
|
||||
]
|
||||
return descontos_filtrados['value'].sum()
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
def normalize(self, raw: Dict[str, pd.DataFrame]) -> Dict[str, Any]:
|
||||
all_msisdns = set()
|
||||
for sec, df in raw.items():
|
||||
if sec != "Fatura Resumo" and "msisdn" in df.columns:
|
||||
all_msisdns.update(df["msisdn"].dropna().astype(str).unique())
|
||||
|
||||
final_output: Dict[str, Any] = {}
|
||||
|
||||
# Processa Fatura Resumo primeiro (independente de MSISDN)
|
||||
if "Fatura Resumo" in raw:
|
||||
fatura_raw = {"Fatura Resumo": raw["Fatura Resumo"]}
|
||||
fatura_dfs = self._clean_dataframes(fatura_raw)
|
||||
fatura_json = self._build_final_json(fatura_dfs)
|
||||
if "Fatura Resumo" in fatura_json:
|
||||
final_output["Fatura Resumo"] = fatura_json["Fatura Resumo"]
|
||||
|
||||
if self.include_danfe and "DANFE-COM" in raw:
|
||||
danfe_json = self._build_danfe_payload(raw["DANFE-COM"], raw)
|
||||
if danfe_json:
|
||||
final_output["DANFE-COM"] = danfe_json
|
||||
|
||||
if not all_msisdns:
|
||||
# Se não houver MSISDNs, processa tudo como global
|
||||
dfs = self._clean_dataframes(raw)
|
||||
dfs = self._process_plano(dfs)
|
||||
dfs = self._process_eventuais(dfs)
|
||||
dfs = self._process_vas(dfs)
|
||||
dfs = self._process_descontos(dfs)
|
||||
fallback_json = self._build_final_json(dfs)
|
||||
for k, v in fallback_json.items():
|
||||
if k not in final_output:
|
||||
final_output[k] = v
|
||||
final_output["vocalized_msisdn"] = self._build_vocalized_msisdn(final_output)
|
||||
return final_output
|
||||
|
||||
for msisdn in all_msisdns:
|
||||
msisdn_raw = {}
|
||||
for sec, df in raw.items():
|
||||
if sec == "Fatura Resumo":
|
||||
continue
|
||||
if "msisdn" in df.columns:
|
||||
mask = df["msisdn"].astype(str) == msisdn
|
||||
filtered_df = df[mask].copy()
|
||||
if not filtered_df.empty:
|
||||
msisdn_raw[sec] = filtered_df
|
||||
else:
|
||||
# Seções sem MSISDN podem ser globais? (Ex: Fatura Resumo já tratada)
|
||||
pass
|
||||
|
||||
if msisdn_raw:
|
||||
dfs = self._clean_dataframes(msisdn_raw)
|
||||
dfs = self._process_plano(dfs)
|
||||
dfs = self._process_eventuais(dfs)
|
||||
dfs = self._process_vas(dfs)
|
||||
dfs = self._process_descontos(dfs)
|
||||
msisdn_json = self._build_final_json(dfs)
|
||||
|
||||
if msisdn_json:
|
||||
final_output[self._format_msisdn(msisdn)] = msisdn_json
|
||||
|
||||
final_output["vocalized_msisdn"] = self._build_vocalized_msisdn(final_output)
|
||||
return final_output
|
||||
|
||||
def _clean_dataframes(self, raw: Dict[str, pd.DataFrame]) -> Dict[str, pd.DataFrame]:
|
||||
# 1) remove value == 0 exceto de fatura resumo e plano (que pode ter o bundle)
|
||||
dfs = {
|
||||
sec: (df if sec in ["Fatura Resumo", "Plano"] else df[df.value != 0.0]).copy()
|
||||
for sec, df in raw.items() if "value" in df.columns
|
||||
}
|
||||
# 2) drop colunas totalmente NaN
|
||||
dfs = {
|
||||
sec: df.dropna(axis=1, how="all")
|
||||
for sec, df in dfs.items() if not df.dropna(axis=1, how="all").empty
|
||||
}
|
||||
# 3) remove sufixo " - -" do campo desc
|
||||
for df in dfs.values():
|
||||
if "desc" in df.columns:
|
||||
df["desc"] = df["desc"].str.replace(r"\s*- -\s*$", "", regex=True)
|
||||
df["desc"] = df["desc"].str.strip()
|
||||
return dfs
|
||||
|
||||
def _process_plano(self, dfs: Dict[str, pd.DataFrame]) -> Dict[str, pd.DataFrame]:
|
||||
if "Plano" not in dfs:
|
||||
return dfs
|
||||
|
||||
plano_df = dfs["Plano"]
|
||||
if 'msisdn' in plano_df.columns:
|
||||
plano_df['msisdn'] = plano_df['msisdn'].astype(str)
|
||||
|
||||
# Separa somente itens que vieram textualmente como "Incluído".
|
||||
# Planos dependentes podem aparecer como R$ 0,00 e continuam sendo Plano.
|
||||
if "_is_included_value" in plano_df.columns:
|
||||
included_mask = plano_df["_is_included_value"].fillna(False).astype(bool)
|
||||
else:
|
||||
included_mask = plano_df.value == 0.0
|
||||
bundle_df = plano_df[included_mask].copy()
|
||||
plano_df = plano_df[~included_mask].copy()
|
||||
|
||||
if not bundle_df.empty:
|
||||
bundle_df["value"] = "Incluído"
|
||||
if "section_total" in bundle_df.columns:
|
||||
bundle_df = bundle_df.drop(columns=["section_total"])
|
||||
dfs["Serviços Bundle Inclusos"] = bundle_df
|
||||
|
||||
# Seleciona planos principais
|
||||
mask_plan = plano_df["desc"].str.contains(r"PÓS/SMP", case=False, na=False)
|
||||
if mask_plan.sum() == 0:
|
||||
mask_plan = plano_df["desc"].str.match(r"(?i)^tim", case=False)
|
||||
|
||||
# Seleciona descontos
|
||||
descontos_df = plano_df[plano_df['desc'].apply(lambda x: 'desc' in str(x).lower() and 'tim' in str(x).lower())]
|
||||
plano_df = plano_df[mask_plan].copy()
|
||||
plano_df["desc"] = plano_df["desc"].apply(self.remove_plan_recognition_marker)
|
||||
|
||||
dfs["_descontos_temp"] = descontos_df
|
||||
|
||||
if plano_df.empty:
|
||||
dfs.pop("Plano", None)
|
||||
else:
|
||||
dfs["Plano"] = plano_df
|
||||
|
||||
return dfs
|
||||
|
||||
def _process_eventuais(self, dfs: Dict[str, pd.DataFrame]) -> Dict[str, pd.DataFrame]:
|
||||
if "Itens Eventuais" in dfs:
|
||||
ie_df = dfs["Itens Eventuais"]
|
||||
mask_remove = (ie_df["value"] == 0) | (ie_df["desc"].str.contains("Serviços de Valor Adicionado Conteúdo", case=False, na=False))
|
||||
ie_df = ie_df[~mask_remove].copy()
|
||||
|
||||
if ie_df.empty:
|
||||
dfs.pop("Itens Eventuais")
|
||||
else:
|
||||
dfs["Itens Eventuais"] = ie_df
|
||||
return dfs
|
||||
|
||||
def _process_vas(self, dfs: Dict[str, pd.DataFrame]) -> Dict[str, pd.DataFrame]:
|
||||
"""Remove de 'Mensalidades Adicionais' os serviços recorrentes que já vêm
|
||||
detalhados (com data de ativação) em 'SVA Detalhe Total', evitando duplicata.
|
||||
|
||||
A seção 'Serviços de Valor Adicionado Total' do PDF é o detalhamento que
|
||||
reúne tanto os itens eventuais quanto as mensalidades recorrentes; quando o
|
||||
mesmo serviço aparece nas duas seções, mantemos a cópia detalhada do SVA."""
|
||||
sva_df = dfs.get("SVA Detalhe Total")
|
||||
if sva_df is None or sva_df.empty or "desc" not in sva_df.columns:
|
||||
return dfs
|
||||
|
||||
detalhados = {
|
||||
(self._format_msisdn(rec.get("msisdn")), self._service_match_key(rec.get("desc")))
|
||||
for rec in sva_df.to_dict(orient="records")
|
||||
}
|
||||
|
||||
for section in ("Mensalidades Adicionais", "Itens Eventuais"):
|
||||
df = dfs.get(section)
|
||||
if df is None or df.empty or "desc" not in df.columns:
|
||||
continue
|
||||
|
||||
mask_dup = df.apply(
|
||||
lambda r: (
|
||||
self._format_msisdn(r.get("msisdn")),
|
||||
self._service_match_key(r.get("desc")),
|
||||
) in detalhados,
|
||||
axis=1,
|
||||
)
|
||||
if mask_dup.any():
|
||||
df = df[~mask_dup].copy()
|
||||
if df.empty:
|
||||
dfs.pop(section, None)
|
||||
else:
|
||||
dfs[section] = df
|
||||
return dfs
|
||||
|
||||
def _process_descontos(self, dfs: Dict[str, pd.DataFrame]) -> Dict[str, pd.DataFrame]:
|
||||
if "_descontos_temp" in dfs:
|
||||
descontos_df = dfs.pop("_descontos_temp")
|
||||
if not descontos_df.empty:
|
||||
if "section_total" in descontos_df.columns:
|
||||
descontos_df = descontos_df.drop(columns=["section_total"])
|
||||
descontos_df['installment'] = descontos_df['desc'].str.extract(r'(\d+/\d+)')
|
||||
descontos_df['desc'] = descontos_df['desc'].apply(self._format_plan_version_number)
|
||||
if "Descontos" in dfs and not dfs["Descontos"].empty:
|
||||
dfs["Descontos"] = pd.concat(
|
||||
[dfs["Descontos"], descontos_df],
|
||||
ignore_index=True,
|
||||
)
|
||||
else:
|
||||
dfs["Descontos"] = descontos_df
|
||||
return dfs
|
||||
|
||||
def _clean_record(self, rec: Dict[str, Any]) -> Dict[str, Any]:
|
||||
redundant_values = {
|
||||
"qty": [1.0, 1],
|
||||
"parcel": ["-", None],
|
||||
"franchise": [None, "null", "-"],
|
||||
"consumption": [None, "null"],
|
||||
"period": ["-", None],
|
||||
"value": [None],
|
||||
"emissao": [None, "null", "-"],
|
||||
}
|
||||
|
||||
cleaned_rec = {}
|
||||
for k, v in rec.items():
|
||||
if k == "section_total" or k.startswith("_"):
|
||||
continue
|
||||
if k == "msisdn":
|
||||
v = self._format_msisdn(v)
|
||||
if pd.isna(v):
|
||||
v = None
|
||||
elif k == "value" and isinstance(v, (int, float)):
|
||||
v = self._round_money(v)
|
||||
elif k == "days" and isinstance(v, float) and v.is_integer():
|
||||
v = int(v)
|
||||
if k in redundant_values and v in redundant_values[k]:
|
||||
continue
|
||||
cleaned_rec[k] = v
|
||||
return cleaned_rec
|
||||
|
||||
def _records_for_section(self, df: pd.DataFrame) -> List[Dict[str, Any]]:
|
||||
return [
|
||||
self._clean_record(rec)
|
||||
for rec in df.to_dict(orient="records")
|
||||
]
|
||||
|
||||
def _split_strategic_records(
|
||||
self,
|
||||
df: pd.DataFrame,
|
||||
section: str,
|
||||
) -> Tuple[List[Dict[str, Any]], List[Dict[str, Any]]]:
|
||||
records = self._records_for_section(df)
|
||||
if section not in self.STRATEGIC_SERVICE_SECTIONS:
|
||||
return records, []
|
||||
|
||||
regular_records = []
|
||||
strategic_records = []
|
||||
for record in records:
|
||||
if "desc" in record and self._is_strategic_service(record["desc"]):
|
||||
record["estrategico"] = True
|
||||
strategic_records.append(record)
|
||||
else:
|
||||
regular_records.append(record)
|
||||
return regular_records, strategic_records
|
||||
|
||||
def _annotate_class(
|
||||
self,
|
||||
records: List[Dict[str, Any]],
|
||||
section: str,
|
||||
) -> List[Dict[str, Any]]:
|
||||
section_classe = self.SECTION_CLASS_MAP.get(section)
|
||||
if not section_classe:
|
||||
return records
|
||||
for rec in records:
|
||||
# Itens estratégicos dobrados nesta seção (commit 7fc38a4e) não
|
||||
# herdam a classe da seção (avulso): mantêm classe=estrategico e
|
||||
# verbo "falar sobre". Os demais seguem o default da seção.
|
||||
classe = "estrategico" if rec.get("estrategico") is True else section_classe
|
||||
rec["classe"] = classe
|
||||
verb = self.CLASSE_VERB_MAP.get(classe)
|
||||
if verb:
|
||||
rec["verb"] = verb
|
||||
return records
|
||||
|
||||
def _round_money(self, value: float) -> float:
|
||||
return round(float(value) + 0.0, 2)
|
||||
|
||||
def _is_discount_desc(self, desc: str) -> bool:
|
||||
return bool(re.match(r"(?i)^\s*desc(?:onto)?\b", desc))
|
||||
|
||||
def _danfe_record(self, rec: Dict[str, Any]) -> Dict[str, Any]:
|
||||
cleaned = {}
|
||||
for k, v in rec.items():
|
||||
if k in {"section_total", "msisdn", "is_total"}:
|
||||
continue
|
||||
if pd.isna(v):
|
||||
v = None
|
||||
elif k in {"preco_unit", "pis_cofins", "bc_icms", "icms", "value"} and isinstance(v, (int, float)):
|
||||
v = self._round_money(v)
|
||||
elif k == "qty" and isinstance(v, float) and v.is_integer():
|
||||
v = int(v)
|
||||
cleaned[k] = v
|
||||
return cleaned
|
||||
|
||||
def _danfe_item_payload(self, rec: Dict[str, Any]) -> Dict[str, Any]:
|
||||
item = self._danfe_record(rec)
|
||||
valor_bruto = self._round_money(item.pop("value", 0.0) or 0.0)
|
||||
item["valor_bruto"] = valor_bruto
|
||||
item["total_descontos"] = 0.0
|
||||
item["valor_final"] = valor_bruto
|
||||
item["descontos"] = []
|
||||
return item
|
||||
|
||||
def _danfe_discount_payload(self, rec: Dict[str, Any]) -> Dict[str, Any]:
|
||||
discount = self._danfe_record(rec)
|
||||
if "value" in discount and discount["value"] is not None:
|
||||
discount["value"] = self._round_money(discount["value"])
|
||||
return discount
|
||||
|
||||
def _desc_matches_key(self, desc_key: str, target_key: str) -> bool:
|
||||
return bool(
|
||||
desc_key
|
||||
and target_key
|
||||
and (desc_key == target_key or desc_key.startswith(target_key) or target_key.startswith(desc_key))
|
||||
)
|
||||
|
||||
def _discount_matches_item(self, discount_desc: str, item_desc: str) -> bool:
|
||||
discount_key = self._discount_match_key(discount_desc)
|
||||
item_key = self._discount_match_key(item_desc)
|
||||
return bool(item_key and item_key in discount_key)
|
||||
|
||||
def _build_danfe_payload(
|
||||
self,
|
||||
danfe_df: pd.DataFrame,
|
||||
raw: Dict[str, pd.DataFrame],
|
||||
) -> Dict[str, Any]:
|
||||
if danfe_df.empty:
|
||||
return {}
|
||||
|
||||
total_geral = None
|
||||
item_records = []
|
||||
for rec in danfe_df.to_dict(orient="records"):
|
||||
if bool(rec.get("is_total")):
|
||||
total_geral = self._round_money(rec.get("value", 0.0) or 0.0)
|
||||
else:
|
||||
item_records.append(rec)
|
||||
|
||||
plan_names: List[str] = []
|
||||
if "Plano" in raw and "desc" in raw["Plano"].columns:
|
||||
plano_df = raw["Plano"]
|
||||
mask_plan = plano_df["desc"].str.contains(r"PÓS/SMP", case=False, na=False)
|
||||
if mask_plan.sum() == 0:
|
||||
mask_plan = plano_df["desc"].str.match(r"(?i)^tim", na=False)
|
||||
for desc in plano_df.loc[mask_plan, "desc"].dropna().astype(str):
|
||||
plan_name = self._clean_plan_name(desc, format_version_number=False)
|
||||
if plan_name not in plan_names:
|
||||
plan_names.append(plan_name)
|
||||
|
||||
if not plan_names:
|
||||
for rec in item_records:
|
||||
desc = str(rec.get("desc", ""))
|
||||
desc_key = self._discount_match_key(desc)
|
||||
if desc_key.startswith("tim ") and not self._is_discount_desc(desc):
|
||||
plan_name = self._clean_plan_name(desc, format_version_number=False)
|
||||
if plan_name.casefold() != "tim music" and plan_name not in plan_names:
|
||||
plan_names.append(plan_name)
|
||||
|
||||
plan_matchers = [(name, self._discount_match_key(name)) for name in plan_names]
|
||||
|
||||
other_keys: List[str] = []
|
||||
if "Outros Valores" in raw and "desc" in raw["Outros Valores"].columns:
|
||||
for desc in raw["Outros Valores"]["desc"].dropna().astype(str):
|
||||
key = self._discount_match_key(desc)
|
||||
if key and key not in other_keys:
|
||||
other_keys.append(key)
|
||||
|
||||
payload: Dict[str, Any] = {"Planos": {name: [] for name in plan_names}}
|
||||
if total_geral is not None:
|
||||
payload["total_geral"] = total_geral
|
||||
|
||||
outros_itens: List[Dict[str, Any]] = []
|
||||
current_plan: str | None = None
|
||||
last_item_by_plan: Dict[str, Dict[str, Any]] = {}
|
||||
|
||||
for rec in item_records:
|
||||
desc = str(rec.get("desc", ""))
|
||||
desc_key = self._discount_match_key(desc)
|
||||
matched_plan = next(
|
||||
(
|
||||
name
|
||||
for name, plan_key in plan_matchers
|
||||
if self._desc_matches_key(desc_key, plan_key)
|
||||
),
|
||||
None,
|
||||
)
|
||||
|
||||
is_known_other = any(
|
||||
self._desc_matches_key(desc_key, other_key)
|
||||
for other_key in other_keys
|
||||
)
|
||||
is_discount = self._is_discount_desc(desc)
|
||||
|
||||
if matched_plan and not is_discount:
|
||||
current_plan = matched_plan
|
||||
item = self._danfe_item_payload(rec)
|
||||
payload["Planos"].setdefault(current_plan, []).append(item)
|
||||
last_item_by_plan[current_plan] = item
|
||||
continue
|
||||
|
||||
if is_known_other and not is_discount:
|
||||
outros_itens.append(self._danfe_item_payload(rec))
|
||||
continue
|
||||
|
||||
if is_discount:
|
||||
target_item = None
|
||||
if current_plan:
|
||||
for item in reversed(payload["Planos"].get(current_plan, [])):
|
||||
if self._discount_matches_item(desc, str(item.get("desc", ""))):
|
||||
target_item = item
|
||||
break
|
||||
if target_item is None:
|
||||
target_item = last_item_by_plan.get(current_plan)
|
||||
|
||||
if target_item is None:
|
||||
outros_itens.append(self._danfe_discount_payload(rec))
|
||||
continue
|
||||
|
||||
discount = self._danfe_discount_payload(rec)
|
||||
target_item["descontos"].append(discount)
|
||||
continue
|
||||
|
||||
if current_plan:
|
||||
item = self._danfe_item_payload(rec)
|
||||
payload["Planos"].setdefault(current_plan, []).append(item)
|
||||
last_item_by_plan[current_plan] = item
|
||||
else:
|
||||
outros_itens.append(self._danfe_item_payload(rec))
|
||||
|
||||
for items in payload["Planos"].values():
|
||||
for item in items:
|
||||
item["descontos"].sort(
|
||||
key=lambda discount: abs(float(discount.get("value") or 0.0)),
|
||||
reverse=True,
|
||||
)
|
||||
total_descontos = sum(
|
||||
float(discount.get("value") or 0.0)
|
||||
for discount in item["descontos"]
|
||||
)
|
||||
item["total_descontos"] = self._round_money(total_descontos)
|
||||
item["valor_final"] = self._round_money(
|
||||
float(item.get("valor_bruto") or 0.0) + total_descontos
|
||||
)
|
||||
|
||||
if outros_itens:
|
||||
payload["Outros Itens"] = outros_itens
|
||||
|
||||
return payload
|
||||
|
||||
def _match_discount_to_plan(
|
||||
self,
|
||||
discount: Dict[str, Any],
|
||||
plans: List[Tuple[str, Dict[str, Any], str]],
|
||||
) -> str | None:
|
||||
discount_key = self._discount_match_key(str(discount.get("desc", "")))
|
||||
matches = [
|
||||
(plan_name, plan)
|
||||
for plan_name, plan, plan_key in plans
|
||||
if plan_key and plan_key in discount_key
|
||||
]
|
||||
if not matches:
|
||||
return None
|
||||
|
||||
discount_period = discount.get("period")
|
||||
if discount_period:
|
||||
for plan_name, plan in matches:
|
||||
if plan.get("period") == discount_period:
|
||||
return plan_name
|
||||
|
||||
return matches[0][0]
|
||||
|
||||
def _build_plan_payload(
|
||||
self,
|
||||
plano_df: pd.DataFrame | None,
|
||||
descontos_df: pd.DataFrame | None,
|
||||
) -> tuple[Dict[str, Dict[str, Any]], List[Dict[str, Any]]]:
|
||||
if plano_df is None or plano_df.empty:
|
||||
return {}, self._records_for_section(descontos_df) if descontos_df is not None else []
|
||||
|
||||
plan_records = self._records_for_section(plano_df)
|
||||
discount_records = self._records_for_section(descontos_df) if descontos_df is not None else []
|
||||
|
||||
payload: Dict[str, Dict[str, Any]] = {}
|
||||
plan_matchers: List[Tuple[str, Dict[str, Any], str]] = []
|
||||
|
||||
for plan in plan_records:
|
||||
desc = plan.get("desc")
|
||||
if not desc:
|
||||
continue
|
||||
|
||||
plan_name = self._clean_plan_name(str(desc))
|
||||
plan_payload = {}
|
||||
for field in ("period", "days", "msisdn"):
|
||||
if field in plan:
|
||||
plan_payload[field] = plan[field]
|
||||
valor_bruto = self._round_money(plan.get("value", 0.0))
|
||||
plan_payload["valor_final"] = valor_bruto
|
||||
for field, value in plan.items():
|
||||
if field not in {"desc", "period", "days", "msisdn", "value"}:
|
||||
plan_payload[field] = value
|
||||
|
||||
if self._is_controle_plan(desc):
|
||||
plan_payload["is_controle"] = True
|
||||
|
||||
plan_payload["descontos"] = []
|
||||
plan_payload["total_descontos"] = 0.0
|
||||
plan_payload["valor_bruto"] = valor_bruto
|
||||
|
||||
payload[plan_name] = plan_payload
|
||||
plan_matchers.append((plan_name, plan, self._discount_match_key(plan_name)))
|
||||
|
||||
unmatched_discounts: List[Dict[str, Any]] = []
|
||||
|
||||
for discount in discount_records:
|
||||
plan_name = self._match_discount_to_plan(discount, plan_matchers)
|
||||
if plan_name is None:
|
||||
unmatched_discounts.append(discount)
|
||||
continue
|
||||
|
||||
discount_payload = {
|
||||
"desc": discount.get("desc"),
|
||||
"value": discount.get("value"),
|
||||
"installment": discount.get("installment"),
|
||||
}
|
||||
payload[plan_name]["descontos"].append(discount_payload)
|
||||
|
||||
for plan in payload.values():
|
||||
total_descontos = sum(
|
||||
float(discount.get("value") or 0.0)
|
||||
for discount in plan["descontos"]
|
||||
)
|
||||
plan["total_descontos"] = self._round_money(total_descontos)
|
||||
valor_final = self._round_money(
|
||||
float(plan.get("valor_bruto") or 0.0) + total_descontos
|
||||
)
|
||||
plan["valor_final"] = valor_final
|
||||
|
||||
return payload, unmatched_discounts
|
||||
|
||||
def _build_final_json(self, dfs: Dict[str, pd.DataFrame]) -> Dict[str, Any]:
|
||||
out: Dict[str, Any] = {}
|
||||
|
||||
plano_payload, descontos_restantes = self._build_plan_payload(
|
||||
dfs.get("Plano"),
|
||||
dfs.get("Descontos"),
|
||||
)
|
||||
|
||||
for sec in self.SECTIONS:
|
||||
if sec == "DANFE-COM":
|
||||
continue
|
||||
|
||||
if sec == "Plano" and plano_payload:
|
||||
out["Planos"] = plano_payload
|
||||
continue
|
||||
|
||||
if sec == "Descontos" and plano_payload:
|
||||
if descontos_restantes:
|
||||
out[sec] = descontos_restantes
|
||||
continue
|
||||
|
||||
if sec in dfs:
|
||||
section_records, strategic_records = self._split_strategic_records(
|
||||
dfs[sec],
|
||||
sec,
|
||||
)
|
||||
all_records = section_records + strategic_records
|
||||
if all_records:
|
||||
out[sec] = self._annotate_class(all_records, sec)
|
||||
|
||||
return out
|
||||
305
app/domain/contas/parsers/invoice_to_text.py
Normal file
305
app/domain/contas/parsers/invoice_to_text.py
Normal file
@@ -0,0 +1,305 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Transformacao da fatura normalizada (dict JSON) -> formato textual em blocos.
|
||||
|
||||
Formato economico em tokens (~72% menor que o JSON indentado), pensado para enviar
|
||||
a uma LLM. API publica: `to_text(data)` e a flag `USE_TEXT_FORMAT`.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
from typing import Any
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Liga o formato textual da fatura no prompt. Default conceitual: False;
|
||||
# mantido True nesta fase de rollout. Override por env, sem edicao de codigo.
|
||||
USE_TEXT_FORMAT = os.getenv("TIM_INVOICE_DETAIL_AS_TEXT", "true").lower() == "true"
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# secao -> (acao, classe). O cabecalho usa o proprio nome da secao (mantido como no JSON).
|
||||
SEC_META = {
|
||||
"SVA Detalhe Total": ("cancelar", "avulso"),
|
||||
"Itens Eventuais": ("cancelar", "avulso"),
|
||||
"Serviços Bundle Inclusos": ("falar sobre", "bundle"),
|
||||
}
|
||||
|
||||
# Secoes que rendem a acao por ITEM (sem "| ação=... | classe" no header).
|
||||
# Item estrategico (estrategico=True) usa "falar sobre"; avulso, "cancelar".
|
||||
PER_ITEM_ACTION_SECTIONS = {
|
||||
"SVA Detalhe Total",
|
||||
"Itens Eventuais",
|
||||
"Mensalidades Adicionais",
|
||||
}
|
||||
|
||||
# Secoes cujo header recebe o rotulo "| cobrados a parte" (servicos avulsos
|
||||
# contratados separadamente). A acao continua por item.
|
||||
COBRADOS_A_PARTE_SECTIONS = {"SVA Detalhe Total", "Itens Eventuais"}
|
||||
|
||||
# secao do JSON -> rotulo no cabecalho do prompt. "SVA Detalhe Total" e "Itens
|
||||
# Eventuais" sao a MESMA categoria (avulsos cobrados a parte) e saem sob um unico
|
||||
# cabecalho "Itens Eventuais". "Serviços Bundle Inclusos" vira "Serviços Inclusos
|
||||
# no Plano": a palavra "bundle" e tecnica/interna e nao deve chegar ao prompt (nem
|
||||
# ao LLM). As chaves do JSON normalizado NAO mudam nos dois casos.
|
||||
SECTION_LABELS = {
|
||||
"SVA Detalhe Total": "Itens Eventuais",
|
||||
"Serviços Bundle Inclusos": "Benefícios do Plano",
|
||||
}
|
||||
|
||||
# classe interna -> rotulo exibido no cabecalho (ver SEC_META). Mesma logica do
|
||||
# SECTION_LABELS: so a APRESENTACAO muda, a classe interna ("bundle") segue igual
|
||||
# em todo o resto do backend (bill_processor, invoice_resolver, tools, etc.).
|
||||
CLASSE_LABELS = {"bundle": "incluso"}
|
||||
|
||||
_RX_OUTROS = re.compile(r"^\s*([^:()]+?)\s*:\s*\((.+)\)\s*$")
|
||||
|
||||
|
||||
def _fmt_money(value: Any) -> str:
|
||||
if isinstance(value, (int, float)):
|
||||
return f"{value:.2f}"
|
||||
return str(value)
|
||||
|
||||
|
||||
def _snake(desc: Any) -> str:
|
||||
return re.sub(r"\s+", "_", str(desc).strip().lower())
|
||||
|
||||
|
||||
def is_period_range(period: Any) -> bool:
|
||||
"""True quando ``period`` é uma FAIXA (ciclo da fatura, ex.: "14/10 a 13/11"),
|
||||
não uma data única de cobrança. Compartilhado com o resolver, que usa o mesmo
|
||||
critério para decidir se uma entry expõe ``charge_date`` (data única) — assim o
|
||||
que o resolver enxerga como cobrança datada casa exatamente o ``data=`` do render
|
||||
lean do classificador."""
|
||||
text = str(period)
|
||||
return " a " in text or "~" in text
|
||||
|
||||
|
||||
def _item_acao(item: dict[str, Any], section: str) -> str | None:
|
||||
"""Verbo de acao por item: estrategico -> 'falar sobre'; senao, o default da secao."""
|
||||
if item.get("estrategico"):
|
||||
return "falar sobre"
|
||||
acao, _ = SEC_META.get(section, (None, None))
|
||||
return acao
|
||||
|
||||
|
||||
def _render_item(item: dict[str, Any], section: str) -> str:
|
||||
"""<nome solto> | campo=valor | ... (com tratamento especial p/ Outros Valores)."""
|
||||
desc = str(item.get("desc", "")).strip()
|
||||
value = item.get("value")
|
||||
|
||||
if section == "Outros Valores":
|
||||
match = _RX_OUTROS.match(desc)
|
||||
if match:
|
||||
parts = [match.group(1).strip().lower()]
|
||||
if value is not None and value != "Incluído":
|
||||
parts.append(f"valor={_fmt_money(value)}")
|
||||
parts.append(f'ref="{match.group(2).strip()}"')
|
||||
return " | ".join(parts)
|
||||
|
||||
parts = [desc]
|
||||
if value is not None and value != "Incluído":
|
||||
parts.append(f"valor={_fmt_money(value)}")
|
||||
period = item.get("period")
|
||||
if period and not is_period_range(period):
|
||||
parts.append(f"data={period}")
|
||||
if item.get("franchise"):
|
||||
parts.append(f"franquia={item['franchise']}")
|
||||
if item.get("consumption"):
|
||||
parts.append(f"consumo={item['consumption']}")
|
||||
if item.get("installment"):
|
||||
parts.append(f"parcela={item['installment']}")
|
||||
return " | ".join(parts)
|
||||
|
||||
|
||||
# Seções omitidas no modo enxuto (intent classifier): o classificador só precisa
|
||||
# dos NOMES dos itens por linha/seção, não de totais, planos nem juros/multas.
|
||||
_LEAN_SKIP_SECTIONS = frozenset({"Fatura Resumo", "Planos", "Outros Valores"})
|
||||
|
||||
|
||||
def to_text(data: dict[str, Any], *, lean: bool = False) -> str:
|
||||
"""Converte o JSON normalizado de uma fatura no formato textual em blocos.
|
||||
|
||||
``lean=True`` (formato do intent classifier): mantém a estrutura LINHA/seção,
|
||||
o NOME dos itens e o ``valor``/``data`` por item — descarta "Fatura Resumo",
|
||||
"Planos", "Outros Valores" e os demais campos por-item (ação, type, franquia,
|
||||
consumo). O ``valor``/``data`` distinguem cobranças duplicadas do mesmo nome na
|
||||
mesma linha (desambiguação de cobrança). O orquestrador usa ``lean=False``
|
||||
(formato completo, com ações)."""
|
||||
lines: list[str] = []
|
||||
msisdn_count = 0
|
||||
|
||||
# FATURA (achatada) — omitida no modo enxuto.
|
||||
resumo = data.get("Fatura Resumo")
|
||||
if not lean and isinstance(resumo, list):
|
||||
lines.append("Fatura Resumo")
|
||||
for item in resumo:
|
||||
desc = item.get("desc", "?")
|
||||
if "period" in item:
|
||||
lines.append(f" {_snake(desc)}={item['period']}")
|
||||
elif "emissao" in item:
|
||||
lines.append(f" {_snake(desc)}={item['emissao']}")
|
||||
elif "value" in item:
|
||||
lines.append(f" {_snake(desc)}={_fmt_money(item['value'])}")
|
||||
lines.append("")
|
||||
|
||||
# LINHAS (MSISDN)
|
||||
for key, block in data.items():
|
||||
if key in ("Fatura Resumo", "vocalized_msisdn"):
|
||||
continue
|
||||
if not isinstance(block, dict):
|
||||
continue
|
||||
|
||||
msisdn_count += 1
|
||||
lines.append(f"LINHA {key}")
|
||||
|
||||
# Planos — omitido no modo enxuto.
|
||||
planos = block.get("Planos")
|
||||
if not lean and isinstance(planos, dict) and planos:
|
||||
lines.append("Planos")
|
||||
for nome, plano in planos.items():
|
||||
bits = [f"nome={nome}"]
|
||||
if plano.get("period"):
|
||||
bits.append(f"período={plano['period']}")
|
||||
if plano.get("days") is not None:
|
||||
bits.append(f"dias={plano['days']}")
|
||||
bits.append(f"valor_final={_fmt_money(plano.get('valor_final'))}")
|
||||
if plano.get("valor_bruto") not in (None, plano.get("valor_final")):
|
||||
bits.append(f"valor_bruto={_fmt_money(plano.get('valor_bruto'))}")
|
||||
if plano.get("total_descontos"):
|
||||
bits.append(f"total_desc={_fmt_money(plano.get('total_descontos'))}")
|
||||
if plano.get("is_controle"):
|
||||
bits.append("controle=sim")
|
||||
lines.append(" " + " | ".join(bits))
|
||||
descontos = plano.get("descontos") or []
|
||||
if descontos:
|
||||
lines.append(" descontos:")
|
||||
for desconto in descontos:
|
||||
dbits = [
|
||||
f"nome={desconto.get('desc')}",
|
||||
f"valor={_fmt_money(desconto.get('value'))}",
|
||||
]
|
||||
if desconto.get("installment"):
|
||||
dbits.append(f"parcela={desconto['installment']}")
|
||||
lines.append(" " + " | ".join(dbits))
|
||||
|
||||
# Seções agrupadas pelo RÓTULO do cabeçalho (``SECTION_LABELS``): as que
|
||||
# compartilham rótulo saem sob um único cabeçalho, na posição da primeira
|
||||
# delas. Cada item guarda a seção de ORIGEM, que é quem decide a ação/classe
|
||||
# por item — o rótulo é só apresentação.
|
||||
groups: dict[str, tuple[str, list[tuple[str, dict[str, Any]]]]] = {}
|
||||
for section, items in block.items():
|
||||
if section == "Planos" or not isinstance(items, list):
|
||||
continue
|
||||
if lean and section in _LEAN_SKIP_SECTIONS:
|
||||
continue
|
||||
label = SECTION_LABELS.get(section, section)
|
||||
_, entries = groups.setdefault(label, (section, []))
|
||||
entries.extend((section, item) for item in items)
|
||||
|
||||
for label, (first_section, entries) in groups.items():
|
||||
acao, classe = SEC_META.get(first_section, (None, None))
|
||||
header = label
|
||||
# No modo enxuto o cabeçalho é só o nome da seção (sem ação/classe).
|
||||
if not lean and first_section in COBRADOS_A_PARTE_SECTIONS:
|
||||
header += " | cobrados a parte"
|
||||
elif acao and first_section not in PER_ITEM_ACTION_SECTIONS and not lean:
|
||||
header += f" | ação={acao} | {CLASSE_LABELS.get(classe, classe)}"
|
||||
lines.append(header)
|
||||
for section, item in entries:
|
||||
if lean:
|
||||
# Nome do item + valor + data da cobrança (sem ação/type/
|
||||
# franquia/consumo). O valor/data por item permitem o classifier
|
||||
# DISTINGUIR N cobranças do mesmo nome na mesma linha (mesmo
|
||||
# desc/msisdn, períodos diferentes) na desambiguação de cobrança
|
||||
# duplicada — o msisdn vem do cabeçalho ``LINHA``. Itens
|
||||
# ``Incluído`` (bundle) e períodos em faixa (ciclo da fatura, não
|
||||
# data de cobrança) seguem só com o nome, como antes.
|
||||
desc = str(item.get("desc", "")).strip()
|
||||
if not desc:
|
||||
continue
|
||||
parts = [desc]
|
||||
value = item.get("value")
|
||||
if value is not None and value != "Incluído":
|
||||
parts.append(f"valor={_fmt_money(value)}")
|
||||
period = item.get("period")
|
||||
if period and not is_period_range(period):
|
||||
parts.append(f"data={period}")
|
||||
lines.append(" " + " | ".join(parts))
|
||||
continue
|
||||
line = " " + _render_item(item, section)
|
||||
if section in PER_ITEM_ACTION_SECTIONS:
|
||||
item_acao = _item_acao(item, section)
|
||||
if item_acao:
|
||||
line += f" | ação={item_acao}"
|
||||
if item.get("estrategico"):
|
||||
line += " | type=estrategico"
|
||||
lines.append(line)
|
||||
lines.append("")
|
||||
|
||||
text = "\n".join(lines).rstrip()
|
||||
if msisdn_count > 1:
|
||||
text += "\n\nmultiplas_linhas = true"
|
||||
else:
|
||||
text += "\n\nmultiplas_linhas = false"
|
||||
return text + "\n"
|
||||
|
||||
|
||||
# ordem pedida pelo produto: avulso -> estrategico -> bundle
|
||||
# (difere da ordem informacional bundle/estrategico/avulso usada em backend.py)
|
||||
_VAS_PRODUCT_ORDER = ("avulso", "estrategico", "bundle")
|
||||
|
||||
|
||||
def get_vas_product_names(data: dict[str, Any]) -> str:
|
||||
"""Nomes dos VAS (avulso, estrategico, bundle — nessa ordem) da fatura
|
||||
normalizada, deduplicados e separados por vírgula. Ex.: "Focus Mensal,
|
||||
Tamboro Mensal". Ignora plano e demais seções não-VAS. Retorna "" se não houver."""
|
||||
buckets: dict[str, list[str]] = {c: [] for c in _VAS_PRODUCT_ORDER}
|
||||
seen: set[str] = set()
|
||||
for key, block in data.items():
|
||||
if key in ("Fatura Resumo", "vocalized_msisdn") or not isinstance(block, dict):
|
||||
continue
|
||||
for section, items in block.items():
|
||||
if section == "Planos" or not isinstance(items, list):
|
||||
continue
|
||||
for item in items:
|
||||
classe = item.get("classe")
|
||||
if not classe and item.get("estrategico"):
|
||||
classe = "estrategico"
|
||||
if classe not in buckets:
|
||||
continue
|
||||
name = str(item.get("desc", "")).strip()
|
||||
if not name or name in seen:
|
||||
continue
|
||||
seen.add(name)
|
||||
buckets[classe].append(name)
|
||||
ordered = [n for c in _VAS_PRODUCT_ORDER for n in buckets[c]]
|
||||
return ", ".join(ordered)
|
||||
|
||||
|
||||
def render_for_prompt(invoice_detail: Any, *, lean: bool = False) -> str | None:
|
||||
"""Renderiza ``invoice_detail`` (dict ou str JSON) no formato textual ``to_text``
|
||||
para uso no prompt. Fonte única compartilhada pelo orquestrador (system prompt)
|
||||
e pelo intent classifier — garante que ambos leiam a MESMA fatura, sem drift.
|
||||
|
||||
``lean=True`` produz o formato enxuto do intent classifier (nomes + valor/data
|
||||
por item, por linha/seção; ver :func:`to_text`); o orquestrador usa ``lean=False``.
|
||||
|
||||
Retorna ``None`` quando o formato textual está desligado (``USE_TEXT_FORMAT``
|
||||
false) ou em qualquer falha de parse/render — cabe ao chamador decidir o
|
||||
fallback (JSON cru no orquestrador; renderizador compacto no classifier).
|
||||
Nunca levanta exceção: o prompt não pode quebrar por causa da fatura."""
|
||||
if not (USE_TEXT_FORMAT and invoice_detail):
|
||||
return None
|
||||
try:
|
||||
parsed = (
|
||||
json.loads(invoice_detail)
|
||||
if isinstance(invoice_detail, str)
|
||||
else invoice_detail
|
||||
)
|
||||
if isinstance(parsed, dict) and parsed:
|
||||
return to_text(parsed, lean=lean)
|
||||
except Exception: # noqa: BLE001 — nunca quebrar o prompt
|
||||
logger.warning("invoice_to_text.render_for_prompt falhou", exc_info=True)
|
||||
return None
|
||||
Reference in New Issue
Block a user