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\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\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\d{2})/(?P\d{2})\s+a\s+" r"(?P\d{2})/(?P\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', ]