Files
first_contas/legacy_reference/workflows/actions/pro_rata/helpers.py
2026-06-16 20:54:49 -03:00

794 lines
24 KiB
Python

from __future__ import annotations
import json
import re
import unicodedata as ud
from datetime import date
from decimal import (
Decimal,
InvalidOperation,
ROUND_HALF_UP,
)
from typing import Any
from agente_contas_tim.workflows.actions.common.helpers import (
_final_msisdn,
_first_text_from_params_or_state,
_first_value_from_params_or_state,
_format_amount,
_format_list_pt_br,
_normalize_bool,
_normalize_number_text,
_parse_amount,
_result_failed_or_missing_data,
)
from agente_contas_tim.workflows.actions.contestacao.helpers import _build_contestation_items
_CENT = Decimal("0.01")
def _format_plano_pro_rata(plano: dict[str, Any]) -> str:
desc = str(plano.get("desc", "")).strip()
valor_txt = _format_plano_pro_rata_value(plano)
final = _final_msisdn(str(plano.get("msisdn", "")))
return f"{desc} (valor R$ {valor_txt}, linha final {final})"
def _format_plano_pro_rata_value(plano: dict[str, Any]) -> str:
raw_value = (
plano.get("valor_final")
or plano.get("valor_liquido")
or plano.get("value")
or plano.get("valor_bruto")
)
if isinstance(raw_value, bool):
valor_parsed = None
elif isinstance(raw_value, (int, float, Decimal)):
valor_parsed = Decimal(str(raw_value))
else:
valor_parsed = _parse_amount(str(raw_value or ""))
return _format_amount(valor_parsed) or "0,00"
def _format_plano_pro_rata_display_name(
plano: dict[str, Any],
*,
fallback: str,
) -> str:
desc = str(plano.get("desc", "")).strip()
clean = re.sub(r"\s*\([^)]*\)\s*$", "", desc).strip()
return clean or fallback
def _format_pro_rata_period_reference(plano: dict[str, Any]) -> str:
days = _resolve_days_number(
plano.get("days") if plano.get("days") is not None else plano.get("dias")
)
if days is None:
return "referente ao período de uso na fatura"
unidade = "dia" if days == 1 else "dias"
return f"referente a {days} {unidade} de uso no período"
def _format_planos_pro_rata(planos: list[dict[str, Any]]) -> str:
formatted = [
_format_plano_pro_rata(p) for p in planos if isinstance(p, dict)
]
return _format_list_pt_br(formatted)
def _build_pro_rata_controle_msg(planos: list[dict[str, Any]]) -> str:
resolved = _resolve_plano_controle(planos)
if resolved is None:
return (
"Houve uma troca de plano e, por isso, na sua fatura "
"apareceram duas cobranças.\n\n"
"Uma é cobrança proporcional de outro plano, referente ao "
"período de uso na fatura.\n\n"
"A outra é do plano Controle.\n\n"
"No plano Controle funciona assim: sempre que a franquia é "
"renovada, o valor do plano é cobrado por completo.\n\n"
"Isso acontece porque, a partir da renovação, você já passa a "
"ter acesso a todos os benefícios do plano imediatamente, como "
"internet, ligações e outras vantagens.\n\n"
"Consegui esclarecer sua dúvida?"
)
plano_controle, outro_plano = resolved
controle_nome = _format_plano_pro_rata_display_name(
plano_controle,
fallback="Controle",
)
outro_nome = _format_plano_pro_rata_display_name(
outro_plano,
fallback="identificado",
)
controle_valor = _format_plano_pro_rata_value(plano_controle)
outro_valor = _format_plano_pro_rata_value(outro_plano)
outro_periodo = _format_pro_rata_period_reference(outro_plano)
return (
"Houve uma troca de plano e, por isso, na sua fatura apareceram "
"duas cobranças.\n\n"
f"Uma é cobrança proporcional do plano {outro_nome}, no valor de "
f"R$ {outro_valor}, {outro_periodo}.\n\n"
f"A outra é do plano {controle_nome}, no valor de R$ "
f"{controle_valor}.\n\n"
"No plano Controle funciona assim: sempre que a franquia é "
"renovada, o valor do plano é cobrado por completo.\n\n"
"Isso acontece porque, a partir da renovação, você já passa a ter "
"acesso a todos os benefícios do plano imediatamente, como "
"internet, ligações e outras vantagens.\n\n"
"Consegui esclarecer sua dúvida?"
)
def _build_pro_rata_sem_controle_msg(planos: list[dict[str, Any]]) -> str:
return (
"Identifiquei a cobranca proporcional na sua fatura "
f"envolvendo os planos {_format_planos_pro_rata(planos)}. "
"Como não envolveu troca para o Plano Controle, segue como "
"cobranca proporcional padrao dos dias usados em cada plano."
)
def _build_pro_rata_oferta_ajuste_msg() -> str:
return (
"Para buscarmos a "
"melhor solução, posso solicitar o "
"ajuste proporcional do plano Controle?"
)
def _build_pro_rata_ajuste_recusado_msg() -> str:
return (
"Entendi. Sem a sua confirmação, a solicitação de ajuste não "
"será aberta neste momento."
)
def _money(value: Decimal) -> Decimal:
return value.quantize(_CENT, rounding=ROUND_HALF_UP)
def _decimal_from_any(value: Any) -> Decimal | None:
if value is None or isinstance(value, bool):
return None
if isinstance(value, Decimal):
return value
if isinstance(value, (int, float)):
return Decimal(str(value))
parsed = _parse_amount(str(value or ""))
return parsed
def _first_decimal_from_mapping(
data: dict[str, Any],
*keys: str,
) -> Decimal | None:
for key in keys:
if key not in data:
continue
value = _decimal_from_any(data.get(key))
if value is not None:
return value
return None
def _normalize_match_text(value: Any) -> str:
text = re.sub(r"\s*\([^)]*\)", "", str(value or "")).strip()
text = ud.normalize("NFKD", text)
text = "".join(ch for ch in text if not ud.combining(ch))
text = text.casefold()
text = re.sub(r"[^a-z0-9]+", " ", text)
return re.sub(r"\s+", " ", text).strip()
def _is_same_plan_name(left: Any, right: Any) -> bool:
left_key = _normalize_match_text(left)
right_key = _normalize_match_text(right)
if not left_key or not right_key:
return False
return left_key == right_key or left_key in right_key or right_key in left_key
def _resolve_plano_controle(
planos: list[dict[str, Any]],
) -> tuple[dict[str, Any], dict[str, Any]] | None:
controles = [
plano
for plano in planos
if isinstance(plano, dict) and bool(plano.get("is_controle"))
]
if len(controles) != 1:
return None
controle = controles[0]
outro = next((plano for plano in planos if plano is not controle), None)
if not isinstance(outro, dict):
return None
return controle, outro
def _resolve_pro_rata_liquid_value(
plano_controle: dict[str, Any],
) -> Decimal | None:
valor_liquido = _first_decimal_from_mapping(
plano_controle,
"valor_final",
"valorFinal",
"valor_liquido",
"valorLiquido",
"net_value",
"netValue",
"subtotal",
"value_final",
)
if valor_liquido is None:
valor_bruto = _first_decimal_from_mapping(
plano_controle,
"valor_bruto",
"valorBruto",
"gross_value",
"grossValue",
"valor_bruto_plano",
"valorBrutoPlano",
"preco_unit",
)
total_descontos = _first_decimal_from_mapping(
plano_controle,
"total_descontos",
"totalDescontos",
"discount_total",
)
if valor_bruto is not None and total_descontos is not None:
valor_liquido = valor_bruto + total_descontos
return valor_liquido
def _parse_emissao_year(
invoice_payload: dict[str, Any],
*,
invoice_emissao: str = "",
) -> int | None:
match = re.search(r"\b\d{2}/\d{2}/(?P<year>\d{4})\b", invoice_emissao)
if match:
return int(match.group("year"))
for item in invoice_payload.get("Fatura Resumo", []) or []:
if not isinstance(item, dict):
continue
emissao = str(item.get("emissao", "") or "").strip()
match = re.search(r"\b\d{2}/\d{2}/(?P<year>\d{4})\b", emissao)
if match:
return int(match.group("year"))
return None
def _extract_resumo_period(
invoice_payload: dict[str, Any],
*,
invoice_period: str = "",
) -> str:
if invoice_period:
return invoice_period
for item in invoice_payload.get("Fatura Resumo", []) or []:
if not isinstance(item, dict):
continue
desc = _normalize_match_text(item.get("desc", ""))
if desc == "periodo":
return str(item.get("period", "") or "").strip()
return ""
def _parse_period_range(value: Any, *, emission_year: int) -> tuple[date, date] | None:
text = str(value or "").strip()
match = re.search(
r"(?P<start_day>\d{2})/(?P<start_month>\d{2})\s+a\s+"
r"(?P<end_day>\d{2})/(?P<end_month>\d{2})",
text,
)
if not match:
return None
start_day = int(match.group("start_day"))
start_month = int(match.group("start_month"))
end_day = int(match.group("end_day"))
end_month = int(match.group("end_month"))
start_year = emission_year
end_year = emission_year
if start_month > end_month:
start_year -= 1
try:
start = date(start_year, start_month, start_day)
end = date(end_year, end_month, end_day)
except ValueError:
return None
if start > end:
return None
return start, end
def _inclusive_days(period: tuple[date, date]) -> int:
return (period[1] - period[0]).days + 1
def _resolve_days_number(value: Any) -> int | None:
try:
days = Decimal(str(value))
except (InvalidOperation, TypeError, ValueError):
return None
if days <= 0:
return None
return int(days.to_integral_value(rounding=ROUND_HALF_UP))
def _resolve_pro_rata_period_days(
*,
outro_plano: dict[str, Any],
invoice_payload: dict[str, Any],
invoice_period: str = "",
invoice_emissao: str = "",
) -> tuple[int, int] | None:
emission_year = _parse_emissao_year(
invoice_payload,
invoice_emissao=invoice_emissao,
)
resumo_period_text = _extract_resumo_period(
invoice_payload,
invoice_period=invoice_period,
)
if emission_year is None or not resumo_period_text:
return None
ciclo_period = _parse_period_range(
resumo_period_text,
emission_year=emission_year,
)
if ciclo_period is None:
return None
dias_ciclo = _inclusive_days(ciclo_period)
if dias_ciclo <= 0:
return None
dias_outro = _resolve_days_number(
outro_plano.get("days")
if outro_plano.get("days") is not None
else outro_plano.get("dias")
)
if dias_outro is None:
return None
if dias_outro >= dias_ciclo:
return dias_ciclo, 1
dias_controle = dias_ciclo - dias_outro
if dias_controle <= 0:
dias_controle = 1
return dias_ciclo, dias_controle
def _parse_invoice_detail_payload(value: Any) -> dict[str, Any]:
if isinstance(value, dict):
return value
if isinstance(value, list):
return {"_items": value}
if not isinstance(value, str):
return {}
text = value.strip()
if not text:
return {}
try:
parsed = json.loads(text)
except json.JSONDecodeError:
return {}
if isinstance(parsed, dict):
return parsed
if isinstance(parsed, list):
return {"_items": parsed}
return {}
def _extract_danfe_from_state_or_params(
state: dict[str, Any],
params: dict[str, Any],
) -> dict[str, Any]:
payload = _extract_invoice_payload_from_state_or_params(state, params)
danfe = payload.get("DANFE-COM") if isinstance(payload, dict) else None
return danfe if isinstance(danfe, dict) else {}
def _extract_invoice_payload_from_state_or_params(
state: dict[str, Any],
params: dict[str, Any],
) -> dict[str, Any]:
detail = _first_value_from_params_or_state(
state,
params,
"invoice_detail",
"invoiceDetail",
"danfe_detail",
"danfeDetail",
)
return _parse_invoice_detail_payload(detail)
def _fetch_invoice_payload_pro_rata(
state: dict[str, Any],
params: dict[str, Any],
runtime: WorkflowRuntimeContext,
) -> tuple[dict[str, Any], str | None]:
payload = _extract_invoice_payload_from_state_or_params(state, params)
if payload.get("DANFE-COM"):
return payload, None
invoice_id = _first_text_from_params_or_state(
state,
params,
"invoice_id",
"invoiceId",
"current_invoice_number",
"currentInvoiceNumber",
)
customer_id = _first_text_from_params_or_state(
state,
params,
"customer_id",
"customerId",
)
msisdn = _first_text_from_params_or_state(state, params, "msisdn")
if not invoice_id or not customer_id or not msisdn:
return payload, (
"Pro-rata exige invoice_id, customer_id e msisdn para buscar "
"fatura com DANFE."
)
result = runtime.factory.create_bill_pdf(
invoice_id=invoice_id,
msisdn=msisdn,
customer_id=customer_id,
include_danfe=True,
).execute()
if _result_failed_or_missing_data(result, state=state):
return payload, result.error or "Falha ao buscar fatura com DANFE."
parsed = result.data.parsed_content or {}
if not isinstance(parsed, dict):
return payload, "Fatura com DANFE retornou conteudo invalido."
return parsed, None
def _fetch_danfe_pro_rata(
state: dict[str, Any],
params: dict[str, Any],
runtime: WorkflowRuntimeContext,
) -> tuple[dict[str, Any], str | None]:
payload, error = _fetch_invoice_payload_pro_rata(state, params, runtime)
if error:
return {}, error
danfe = payload.get("DANFE-COM")
if not isinstance(danfe, dict) or not danfe:
return {}, "DANFE-COM nao encontrado na fatura recuperada."
return danfe, None
def _find_danfe_plan_items(
danfe: dict[str, Any],
plano_controle: dict[str, Any],
) -> list[dict[str, Any]]:
planos = danfe.get("Planos")
if not isinstance(planos, dict):
return []
controle_desc = str(plano_controle.get("desc", "")).strip()
for plan_name, raw_items in planos.items():
if not _is_same_plan_name(plan_name, controle_desc):
continue
if isinstance(raw_items, list):
return [item for item in raw_items if isinstance(item, dict)]
return []
def _build_pro_rata_contestation_items(
*,
danfe: dict[str, Any],
plano_controle: dict[str, Any],
valor_devolver: Decimal,
) -> tuple[list[dict[str, float | str]], Decimal]:
items = _find_danfe_plan_items(danfe, plano_controle)
restante = _money(valor_devolver)
contestation_items: list[dict[str, float | str]] = []
for item in items:
desc = str(item.get("desc", "")).strip()
if not desc:
continue
claimed_amount = _first_decimal_from_mapping(
item,
"valor_final",
"valorFinal",
)
if claimed_amount is None or claimed_amount <= 0:
continue
claimed_amount = _money(claimed_amount)
validated_amount = min(restante, claimed_amount)
if validated_amount <= 0:
continue
contestation_items.append(
{
"itemName": desc,
"itemType": "PRO_RATA",
"claimedAmount": float(claimed_amount),
"validatedAmount": float(validated_amount),
}
)
restante = _money(restante - validated_amount)
if restante <= 0:
break
return contestation_items, restante
def _build_pro_rata_human_validation_text(
*,
plano_controle: dict[str, Any],
valor_liquido: Decimal,
dias_ciclo: int,
dias_controle: int,
valor_usado: Decimal,
valor_devolver: Decimal,
items: list[dict[str, float | str]],
) -> str:
plano_nome = re.sub(
r"\s*\([^)]*\)",
"",
str(plano_controle.get("desc") or "").strip(),
).strip()
if not plano_nome:
plano_nome = "Plano Controle"
item_lines = []
for index, item in enumerate(items, start=1):
item_name = str(item.get("itemName") or "").strip()
claimed = _decimal_from_any(item.get("claimedAmount")) or Decimal("0")
validated = _decimal_from_any(item.get("validatedAmount")) or Decimal("0")
item_lines.append(
f"{index}. {item_name}: valor DANFE R$ {_format_amount(_money(claimed))}; "
f"valor a abater R$ {_format_amount(_money(validated))}"
)
itens_txt = "; ".join(item_lines) if item_lines else "nenhum item gerado"
return (
"Validacao humana pro-rata:"
f"Plano Controle identificado: {plano_nome}. "
f"Base liquida do plano: R$ {_format_amount(valor_liquido)}. "
f"Calculo: ciclo de {dias_ciclo} dias, uso considerado de "
f"{dias_controle} dias; valor usado R$ {_format_amount(_money(valor_usado))}; "
f"valor a devolver R$ {_format_amount(valor_devolver)}. "
f"Itens selecionados no DANFE, na ordem de abatimento: {itens_txt}. "
)
def _extract_pro_rata_contestation_context(
state: dict[str, Any], params: dict[str, Any]
) -> tuple[dict[str, Any] | None, str | None]:
devolucao = params.get("devolucao")
contestation_params = params
if isinstance(devolucao, dict):
contestation_params = {**devolucao, **params}
if "items" not in params:
contestation_params["items"] = devolucao.get("items")
msisdn = _first_text_from_params_or_state(
state,
params,
"msisdn",
"Msisdn",
"MSISDN",
)
if not msisdn:
return None, "msisdn obrigatorio para contestacao de pro_rata"
social_sec_no = re.sub(
r"\D",
"",
_first_text_from_params_or_state(
state,
params,
"social_sec_no",
"socialSecNo",
"cpf",
"customer_document",
"customerDocument",
"document",
),
)
customer_id = _first_text_from_params_or_state(
state,
params,
"customer_id",
"customerId",
)
if not customer_id:
return None, "customer_id obrigatorio para contestacao de pro_rata"
invoice_number = _first_text_from_params_or_state(
state,
params,
"current_invoice_number",
"currentInvoiceNumber",
"invoice_id",
"invoiceId",
"invoice_number",
"invoiceNumber",
)
if not invoice_number:
return None, "invoice_number obrigatorio para contestacao de pro_rata"
items = _build_contestation_items(state, contestation_params)
if not items:
return None, "items obrigatorio para contestacao de pro_rata"
fallback_amount = _normalize_number_text(
format(
sum(
(
_decimal_from_any(item.get("validated_amount"))
or Decimal("0")
)
for item in items
if isinstance(item, dict)
),
"f",
),
default="0",
)
invoice_amount_open = _normalize_number_text(
_first_text_from_params_or_state(
state,
contestation_params,
"invoice_amount_open",
"invoiceAmountOpen",
),
default=fallback_amount,
)
invoice_amount = _normalize_number_text(
_first_text_from_params_or_state(
state,
contestation_params,
"invoice_amount",
"invoiceAmount",
),
default=invoice_amount_open,
)
context = {
"msisdn": msisdn,
"social_sec_no": social_sec_no,
"customer_id": customer_id,
"invoice_number": invoice_number,
"items": items,
"invoice_amount_open": invoice_amount_open,
"invoice_amount": invoice_amount,
}
return context, None
def _build_pro_rata_contestacao_tool_payload(
state: dict[str, Any],
params: dict[str, Any],
*,
context: dict[str, Any],
) -> dict[str, Any]:
return {
"msisdn": context["msisdn"],
"social_sec_no": context["social_sec_no"],
"customer_id": context["customer_id"],
"current_invoice_number": context["invoice_number"],
"invoice_id": context["invoice_number"],
"customer_type": (
_first_text_from_params_or_state(
state, params, "customer_type", "customerType"
)
or "2"
),
"customer_status": (
_first_text_from_params_or_state(
state, params, "customer_status", "customerStatus"
)
or "1"
),
"invoice_status": (
_first_text_from_params_or_state(
state, params, "invoice_status", "invoiceStatus"
)
or "1"
),
"invoice_amount_open": context["invoice_amount_open"],
"invoice_amount": context["invoice_amount"],
"current_invoice_due_date": _first_text_from_params_or_state(
state,
params,
"current_invoice_due_date",
"currentInvoiceDueDate",
),
"contestation_type": (
_first_text_from_params_or_state(
state, params, "contestation_type", "contestationType"
)
or "0"
),
"adjust_reason": (
_first_text_from_params_or_state(
state, params, "adjust_reason", "adjustReason"
)
or "SERVICO_NAO_SOLICITADO"
),
"observation": _first_text_from_params_or_state(
state, params, "observation", "descricao"
),
"refund_option": (
_first_text_from_params_or_state(
state, params, "refund_option", "refundOption"
)
or ""
),
"manual_conta_certa_indicator": _first_value_from_params_or_state(
state,
params,
"manual_conta_certa_indicator",
"manualContaCertaIndicator",
),
"double_refund": _normalize_bool(
_first_value_from_params_or_state(
state,
params,
"double_refund",
"doubleRefund",
),
default=False,
),
"user_id": _first_text_from_params_or_state(
state, params, "user_id", "userId"
),
"message_id": _first_text_from_params_or_state(
state, params, "message_id", "messageId"
),
"client_id": _first_text_from_params_or_state(
state, params, "client_id", "clientId"
),
"tipo_atendimento": "pro_rata",
"skip_invoice_item_validation": True,
"items": context["items"],
}
__all__ = [
'_format_plano_pro_rata',
'_format_plano_pro_rata_value',
'_format_plano_pro_rata_display_name',
'_format_pro_rata_period_reference',
'_format_planos_pro_rata',
'_build_pro_rata_controle_msg',
'_build_pro_rata_sem_controle_msg',
'_build_pro_rata_oferta_ajuste_msg',
'_build_pro_rata_ajuste_recusado_msg',
'_money',
'_decimal_from_any',
'_first_decimal_from_mapping',
'_normalize_match_text',
'_is_same_plan_name',
'_resolve_plano_controle',
'_resolve_pro_rata_liquid_value',
'_parse_emissao_year',
'_extract_resumo_period',
'_parse_period_range',
'_inclusive_days',
'_resolve_days_number',
'_resolve_pro_rata_period_days',
'_parse_invoice_detail_payload',
'_extract_danfe_from_state_or_params',
'_extract_invoice_payload_from_state_or_params',
'_fetch_invoice_payload_pro_rata',
'_fetch_danfe_pro_rata',
'_find_danfe_plan_items',
'_build_pro_rata_contestation_items',
'_build_pro_rata_human_validation_text',
'_extract_pro_rata_contestation_context',
'_build_pro_rata_contestacao_tool_payload',
]