mirror of
https://github.com/hoshikawa2/first_contas.git
synced 2026-07-09 10:14:20 +00:00
1208 lines
37 KiB
Python
1208 lines
37 KiB
Python
from __future__ import annotations
|
|
|
|
import logging
|
|
import re
|
|
import unicodedata
|
|
from decimal import Decimal
|
|
from calendar import monthrange
|
|
|
|
from datetime import datetime
|
|
from typing import Any
|
|
|
|
from agente_contas_tim.guardrails import validate_contestation_items
|
|
from agente_contas_tim.workflows.actions.registry import WorkflowRuntimeContext
|
|
from agente_contas_tim.workflows.actions.common.helpers import (
|
|
_contains_any_keyword,
|
|
_first_text_from_params_or_state,
|
|
_first_value_from_params_or_state,
|
|
_map_customer_status_code,
|
|
_normalize_bool,
|
|
_normalize_number_text,
|
|
_normalize_service_name,
|
|
_result_failed_or_missing_data,
|
|
_text_from_keys,
|
|
_to_bool,
|
|
_to_dict,
|
|
)
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
_VENCIMENTOS_CONTA_CERTA = (1, 7, 10, 12, 15, 20, 25, 30)
|
|
|
|
|
|
def _resolve_billing_cutoff_reference(
|
|
cutoff_day: int,
|
|
*,
|
|
reference_datetime: datetime | None = None,
|
|
) -> tuple[bool, datetime | None]:
|
|
if cutoff_day <= 0:
|
|
return False, None
|
|
|
|
reference = reference_datetime or datetime.now()
|
|
current_last_day = monthrange(reference.year, reference.month)[1]
|
|
current_cutoff_day = min(cutoff_day, current_last_day)
|
|
cutoff_reference = datetime(
|
|
reference.year,
|
|
reference.month,
|
|
current_cutoff_day,
|
|
)
|
|
|
|
if reference < cutoff_reference:
|
|
previous_month = reference.month - 1
|
|
previous_year = reference.year
|
|
if previous_month == 0:
|
|
previous_month = 12
|
|
previous_year -= 1
|
|
|
|
previous_last_day = monthrange(previous_year, previous_month)[1]
|
|
previous_cutoff_day = min(cutoff_day, previous_last_day)
|
|
cutoff_reference = datetime(
|
|
previous_year,
|
|
previous_month,
|
|
previous_cutoff_day,
|
|
)
|
|
|
|
return reference >= cutoff_reference, cutoff_reference
|
|
|
|
|
|
def _resolve_next_cutoff_from_cut_date(
|
|
cut_date: datetime,
|
|
*,
|
|
reference_datetime: datetime | None = None,
|
|
) -> tuple[bool, datetime]:
|
|
"""Retorna se a data de referência já passou do próximo corte.
|
|
|
|
Regra:
|
|
- manualContaCertaIndicator = False quando sysdate <= (cutDate + 1 mês)
|
|
- manualContaCertaIndicator = True quando sysdate > (cutDate + 1 mês)
|
|
"""
|
|
reference = reference_datetime or datetime.now()
|
|
next_month = cut_date.month + 1
|
|
next_year = cut_date.year
|
|
if next_month == 13:
|
|
next_month = 1
|
|
next_year += 1
|
|
next_last_day = monthrange(next_year, next_month)[1]
|
|
next_day = min(cut_date.day, next_last_day)
|
|
next_cutoff = datetime(next_year, next_month, next_day)
|
|
return reference > next_cutoff, next_cutoff
|
|
|
|
|
|
|
|
def _is_missing_amount_value(value: Any) -> bool:
|
|
text = str(value).strip().lower()
|
|
if not text or text in {"null", "none", "nan"}:
|
|
return True
|
|
normalized = _normalize_number_text(value, default="")
|
|
return normalized in {"", "0", "-0"}
|
|
|
|
|
|
def _first_invoice_status_from_items(items: Any) -> str:
|
|
if not isinstance(items, (list, tuple)):
|
|
return ""
|
|
for item in items:
|
|
if not isinstance(item, dict):
|
|
continue
|
|
for key in ("status", "invoiceStatus", "invoice_status", "invoice_status_desc"):
|
|
status = str(item.get(key) or "").strip()
|
|
if status:
|
|
return status
|
|
return ""
|
|
|
|
|
|
def _invoice_identifier(value: Any) -> str:
|
|
return re.sub(r"\D", "", str(value or "").strip())
|
|
|
|
|
|
def _invoice_item_matches_id(item: dict[str, Any], invoice_id: Any) -> bool:
|
|
target = _invoice_identifier(invoice_id)
|
|
if not target:
|
|
return False
|
|
for key in (
|
|
"id",
|
|
"invoice_id",
|
|
"invoiceId",
|
|
"invoiceNumber",
|
|
"invoice_number",
|
|
"current_invoice_number",
|
|
"currentInvoiceNumber",
|
|
):
|
|
if _invoice_identifier(item.get(key)) == target:
|
|
return True
|
|
return False
|
|
|
|
|
|
def _invoice_items_from_payload(payload: Any) -> list[dict[str, Any]]:
|
|
if not isinstance(payload, dict):
|
|
return []
|
|
out: list[dict[str, Any]] = []
|
|
for key in ("paymentItems", "payment_items", "open_invoices", "closed_invoices"):
|
|
items = payload.get(key, [])
|
|
if not isinstance(items, (list, tuple)):
|
|
continue
|
|
out.extend(item for item in items if isinstance(item, dict))
|
|
return out
|
|
|
|
|
|
def _find_invoice_item_by_id(payload: Any, invoice_id: Any) -> dict[str, Any] | None:
|
|
for item in _invoice_items_from_payload(payload):
|
|
if _invoice_item_matches_id(item, invoice_id):
|
|
return item
|
|
return None
|
|
|
|
|
|
def _to_tracking_datetime(value: Any) -> str:
|
|
raw = str(value or "").strip()
|
|
if not raw:
|
|
return ""
|
|
raw = raw.replace("Z", "")
|
|
if "T" in raw:
|
|
raw = raw.split("T", 1)[0]
|
|
for fmt in ("%Y-%m-%d", "%d/%m/%Y", "%Y/%m/%d"):
|
|
try:
|
|
parsed = datetime.strptime(raw, fmt)
|
|
return parsed.strftime("%Y-%m-%dT00:00:00.00Z")
|
|
except ValueError:
|
|
continue
|
|
return ""
|
|
|
|
|
|
def _parse_tracking_date(value: Any) -> datetime | None:
|
|
"""Converte datas de payload de invoices para datetime (sem horário)."""
|
|
iso_candidate = _to_tracking_datetime(value)
|
|
if not iso_candidate:
|
|
return None
|
|
try:
|
|
return datetime.strptime(iso_candidate, "%Y-%m-%dT%H:%M:%S.%fZ")
|
|
except ValueError:
|
|
return None
|
|
|
|
|
|
def _parse_tracking_day(value: Any) -> int | None:
|
|
parsed_cut_date = _parse_tracking_date(value)
|
|
if parsed_cut_date is None:
|
|
return None
|
|
return parsed_cut_date.day
|
|
|
|
|
|
def _first_invoice_date_from_items(items: Any, *keys: str) -> str:
|
|
if not isinstance(items, (list, tuple)):
|
|
return ""
|
|
for item in items:
|
|
if not isinstance(item, dict):
|
|
continue
|
|
for key in keys:
|
|
formatted = _to_tracking_datetime(item.get(key))
|
|
if formatted:
|
|
return formatted
|
|
return ""
|
|
|
|
|
|
def _first_invoice_date_from_payload_by_id(
|
|
payload: Any,
|
|
invoice_id: Any,
|
|
*keys: str,
|
|
) -> str:
|
|
item = _find_invoice_item_by_id(payload, invoice_id)
|
|
if not item:
|
|
return ""
|
|
return _first_invoice_date_from_items([item], *keys)
|
|
|
|
|
|
def _first_invoice_text_from_payload_by_id(
|
|
payload: Any,
|
|
invoice_id: Any,
|
|
*keys: str,
|
|
) -> str:
|
|
item = _find_invoice_item_by_id(payload, invoice_id)
|
|
if not item:
|
|
return ""
|
|
for key in keys:
|
|
text = str(item.get(key) or "").strip()
|
|
if text:
|
|
return text
|
|
return ""
|
|
|
|
|
|
def _normalize_due_date_yyyymmdd(value: Any) -> str:
|
|
text = str(value).strip()
|
|
if not text:
|
|
return ""
|
|
digits = re.sub(r"\D", "", text)
|
|
if len(digits) != 8:
|
|
return ""
|
|
if "/" in text or "-" in text:
|
|
day = digits[:2]
|
|
month = digits[2:4]
|
|
year = digits[4:]
|
|
if day.isdigit() and month.isdigit() and year.isdigit():
|
|
if int(day) <= 31 and int(month) <= 12:
|
|
return f"{year}{month}{day}"
|
|
return digits
|
|
|
|
|
|
def _is_dacc_cartao_parcelamento(payment_method: Any) -> bool:
|
|
text = str(payment_method or "").strip().lower()
|
|
if not text:
|
|
return False
|
|
# Aceita variações com/sem acento e separadores.
|
|
return _contains_any_keyword(
|
|
text,
|
|
(
|
|
"dacc",
|
|
"cartao",
|
|
"cartão",
|
|
"parcelamento",
|
|
),
|
|
)
|
|
|
|
|
|
def _is_invoice_paid_status(status_text: Any) -> bool:
|
|
return _contains_any_keyword(
|
|
str(status_text or ""),
|
|
("pago", "quitado", "paid"),
|
|
)
|
|
|
|
|
|
def _is_invoice_unpaid_status(status_text: Any) -> bool:
|
|
return _contains_any_keyword(
|
|
str(status_text or ""),
|
|
("em aberto", "a vencer", "nao pago", "não pago", "pendente", "aberto"),
|
|
)
|
|
|
|
|
|
def _is_invoice_contested_status(status_text: Any) -> bool:
|
|
return _contains_any_keyword(
|
|
str(status_text or ""),
|
|
("contest",),
|
|
)
|
|
|
|
|
|
def _map_invoice_status_to_state(value: Any) -> str:
|
|
text = str(value or "").strip().upper()
|
|
if not text:
|
|
return ""
|
|
text = unicodedata.normalize("NFKD", text)
|
|
text = "".join(c for c in text if not unicodedata.combining(c))
|
|
text = text.replace("/", " ")
|
|
text = re.sub(r"\s+", " ", text).strip()
|
|
|
|
direct_map = {
|
|
"PAGO": "Fechada",
|
|
"ABERTO CC": "Fechada",
|
|
"EM ATRASO CC": "Fechada",
|
|
"EM ATRASO DEB AUT": "Fechada",
|
|
"ABERTO DEB AUT": "Fechada",
|
|
"EM ATRASO PIX": "Fechada",
|
|
"ABERTO PIX": "Fechada",
|
|
"A VENCER": "Aberto",
|
|
"EM ACORDO": "Aberto",
|
|
"EM ATRASO": "Aberto",
|
|
"EM ABERTO": "Aberto",
|
|
"EM PROCESSAMENTO": "Fechada",
|
|
"CONSULTA INDISPONIVEL": "Fechada",
|
|
"PARCELADO": "Negociada",
|
|
"CANCELADO EXPIRADO": "Fechada",
|
|
}
|
|
return direct_map.get(text, "")
|
|
|
|
|
|
def _map_invoice_status_code(value: Any) -> str:
|
|
text = str(value or "").strip().lower()
|
|
if not text:
|
|
return ""
|
|
return "1" if "abert" in text else "0"
|
|
|
|
|
|
def _first_invoice_status_from_payload(payload: Any, *, invoice_id: Any = "") -> str:
|
|
if not isinstance(payload, dict):
|
|
return ""
|
|
matched_item = _find_invoice_item_by_id(payload, invoice_id)
|
|
invoice_status = _first_invoice_status_from_items([matched_item])
|
|
if invoice_status:
|
|
return invoice_status
|
|
|
|
invoice_id_text = _invoice_identifier(invoice_id)
|
|
has_invoice_filter = bool(invoice_id_text)
|
|
if has_invoice_filter:
|
|
# Se o fluxo já definiu a fatura, somente itens dessa fatura são válidos.
|
|
return ""
|
|
|
|
invoice_status = str(payload.get("invoice_status") or "").strip()
|
|
if invoice_status:
|
|
return invoice_status
|
|
invoice_status = (
|
|
_first_invoice_status_from_items(payload.get("open_invoices"))
|
|
or _first_invoice_status_from_items(payload.get("closed_invoices"))
|
|
)
|
|
if invoice_status:
|
|
return invoice_status
|
|
statuses = payload.get("invoice_statuses", [])
|
|
if not isinstance(statuses, (list, tuple)):
|
|
return ""
|
|
for raw_status in statuses:
|
|
status_text = str(raw_status or "").strip()
|
|
if status_text:
|
|
return status_text
|
|
return ""
|
|
|
|
|
|
def _collect_invoice_status_texts(payload: dict[str, Any]) -> list[str]:
|
|
out: list[str] = []
|
|
if not isinstance(payload, dict):
|
|
return out
|
|
statuses = payload.get("invoice_statuses", [])
|
|
if isinstance(statuses, list):
|
|
for raw in statuses:
|
|
text = str(raw or "").strip()
|
|
if text:
|
|
out.append(text)
|
|
for key in ("open_invoices", "closed_invoices"):
|
|
items = payload.get(key, [])
|
|
if not isinstance(items, (list, tuple)):
|
|
continue
|
|
for item in items:
|
|
if not isinstance(item, dict):
|
|
continue
|
|
for status_key in (
|
|
"status",
|
|
"invoiceStatus",
|
|
"invoice_status",
|
|
"invoice_status_desc",
|
|
):
|
|
text = str(item.get(status_key) or "").strip()
|
|
if text:
|
|
out.append(text)
|
|
break
|
|
return out
|
|
|
|
|
|
def _invoice_status_texts_from_payload(
|
|
payload: Any,
|
|
*,
|
|
invoice_id: Any = "",
|
|
) -> list[str]:
|
|
has_invoice_filter = bool(_invoice_identifier(invoice_id))
|
|
item = _find_invoice_item_by_id(payload, invoice_id)
|
|
if item:
|
|
status = _first_invoice_status_from_items([item])
|
|
return [status] if status else []
|
|
if has_invoice_filter:
|
|
return []
|
|
return _collect_invoice_status_texts(payload if isinstance(payload, dict) else {})
|
|
|
|
|
|
def _has_open_bill_from_payload(
|
|
payload: Any,
|
|
*,
|
|
invoice_id: Any = "",
|
|
default: bool = False,
|
|
) -> bool:
|
|
item = _find_invoice_item_by_id(payload, invoice_id)
|
|
if item:
|
|
status = _first_invoice_status_from_items([item])
|
|
return _is_invoice_unpaid_status(status)
|
|
if isinstance(payload, dict) and "has_open_bill" in payload:
|
|
return bool(payload.get("has_open_bill"))
|
|
return default
|
|
|
|
|
|
def _resolve_refund_and_resolution_decision(
|
|
*,
|
|
payment_method: str,
|
|
has_open_bill: bool,
|
|
invoice_status_texts: list[str],
|
|
) -> dict[str, Any]:
|
|
is_dacc = _is_dacc_cartao_parcelamento(payment_method)
|
|
is_contested = any(_is_invoice_contested_status(s) for s in invoice_status_texts)
|
|
is_paid = any(_is_invoice_paid_status(s) for s in invoice_status_texts) and not has_open_bill
|
|
is_unpaid = has_open_bill or any(_is_invoice_unpaid_status(s) for s in invoice_status_texts)
|
|
|
|
should_generate_boleto = is_unpaid and not is_dacc and not is_contested
|
|
|
|
refund_option = "1" if should_generate_boleto else "0"
|
|
format_text = "sms" if should_generate_boleto else "conta_futura"
|
|
resolution_type = "new_boleto" if should_generate_boleto else "credit_bill"
|
|
decision_reason = "credito_conta_futura"
|
|
if should_generate_boleto:
|
|
decision_reason = "fatura_nao_paga"
|
|
elif is_dacc:
|
|
decision_reason = "forma_pagamento_dacc_cartao_parcelamento"
|
|
elif is_contested:
|
|
decision_reason = "fatura_ja_contestada"
|
|
elif is_paid:
|
|
decision_reason = "fatura_paga"
|
|
|
|
logger.info(
|
|
"contestacao.decisao_refund_decision "
|
|
"payment_method=%s has_open_bill=%s is_dacc=%s is_contested=%s is_paid=%s is_unpaid=%s "
|
|
"refund_option=%s format_text=%s resolution_type=%s decision_reason=%s",
|
|
payment_method,
|
|
has_open_bill,
|
|
is_dacc,
|
|
is_contested,
|
|
is_paid,
|
|
is_unpaid,
|
|
"1" if should_generate_boleto else "0",
|
|
"sms" if should_generate_boleto else "conta_futura",
|
|
"new_boleto" if should_generate_boleto else "credit_bill",
|
|
decision_reason,
|
|
)
|
|
|
|
return {
|
|
"refund_option": refund_option,
|
|
"format_text": format_text,
|
|
"resolution_type": resolution_type,
|
|
"decision_reason": decision_reason,
|
|
"is_dacc": is_dacc,
|
|
"is_paid": is_paid,
|
|
"is_unpaid": is_unpaid,
|
|
"is_contested": is_contested,
|
|
}
|
|
|
|
|
|
def _resolve_contestation_due_date(
|
|
state: dict[str, Any],
|
|
params: dict[str, Any],
|
|
) -> str:
|
|
raw_due_date = _first_text_from_params_or_state(
|
|
state,
|
|
params,
|
|
"current_invoice_due_date",
|
|
"currentInvoiceDueDate",
|
|
"CurrentInvoiceDueDate",
|
|
"invoice_due_date",
|
|
"invoiceDueDate",
|
|
"InvoiceDueDate",
|
|
)
|
|
if raw_due_date:
|
|
return _normalize_due_date_yyyymmdd(raw_due_date)
|
|
|
|
vars_state = state.get("vars", {}) if isinstance(state, dict) else {}
|
|
invoice_id = _first_text_from_params_or_state(
|
|
state,
|
|
params,
|
|
"invoice_id",
|
|
"invoiceId",
|
|
"current_invoice_number",
|
|
"currentInvoiceNumber",
|
|
"invoice_number",
|
|
"invoiceNumber",
|
|
)
|
|
has_invoice_filter = bool(_invoice_identifier(invoice_id))
|
|
profile_payload = (
|
|
vars_state.get("consultar_perfil_fatura", {})
|
|
if isinstance(vars_state, dict)
|
|
else {}
|
|
)
|
|
if isinstance(profile_payload, dict):
|
|
profile_due_date_by_id = _first_invoice_text_from_payload_by_id(
|
|
profile_payload,
|
|
invoice_id,
|
|
"dueDate",
|
|
"due_date",
|
|
)
|
|
if profile_due_date_by_id:
|
|
return _normalize_due_date_yyyymmdd(profile_due_date_by_id)
|
|
|
|
profile_due_date = str(
|
|
profile_payload.get("due_date")
|
|
or profile_payload.get("dueDate")
|
|
or ""
|
|
).strip()
|
|
if profile_due_date and not has_invoice_filter:
|
|
return _normalize_due_date_yyyymmdd(profile_due_date)
|
|
|
|
check_payload = (
|
|
vars_state.get("check_invoice_status", {})
|
|
if isinstance(vars_state, dict)
|
|
else {}
|
|
)
|
|
if not isinstance(check_payload, dict):
|
|
return ""
|
|
|
|
check_due_date_by_id = _first_invoice_text_from_payload_by_id(
|
|
check_payload,
|
|
invoice_id,
|
|
"dueDate",
|
|
"due_date",
|
|
)
|
|
if check_due_date_by_id:
|
|
return _normalize_due_date_yyyymmdd(check_due_date_by_id)
|
|
|
|
raw_due_date = str(
|
|
check_payload.get("due_date") or check_payload.get("dueDate") or ""
|
|
).strip()
|
|
if raw_due_date and not has_invoice_filter:
|
|
return _normalize_due_date_yyyymmdd(raw_due_date)
|
|
|
|
if has_invoice_filter:
|
|
return ""
|
|
|
|
for invoice_key in ("open_invoices", "closed_invoices"):
|
|
invoices = check_payload.get(invoice_key, [])
|
|
if not isinstance(invoices, list):
|
|
continue
|
|
for invoice in invoices:
|
|
if not isinstance(invoice, dict):
|
|
continue
|
|
candidate = str(
|
|
invoice.get("dueDate")
|
|
or invoice.get("due_date")
|
|
or ""
|
|
).strip()
|
|
if candidate:
|
|
return _normalize_due_date_yyyymmdd(candidate)
|
|
|
|
return ""
|
|
|
|
|
|
def _extrair_dia_vencimento(due_date: str) -> str:
|
|
"""Extrai o dia de vencimento e retorna o valor válido mais próximo.
|
|
|
|
Aceita formatos YYYYMMDD, YYYY-MM-DD, DD/MM/YYYY.
|
|
Retorna string com zero-fill: "01", "07", "10", "12", "15", "20", "25", "30".
|
|
"""
|
|
text = str(due_date).strip()
|
|
if not text:
|
|
return "01"
|
|
digits = re.sub(r"\D", "", text)
|
|
day: int | None = None
|
|
if len(digits) == 8:
|
|
if "/" in text or (len(text) >= 3 and text[2] in "/-"):
|
|
# DD/MM/YYYY ou DD-MM-YYYY
|
|
day = int(digits[:2])
|
|
else:
|
|
# YYYYMMDD
|
|
day = int(digits[6:8])
|
|
elif len(digits) >= 2:
|
|
day = int(digits[-2:])
|
|
if day is None or day < 1 or day > 31:
|
|
return "01"
|
|
closest = min(_VENCIMENTOS_CONTA_CERTA, key=lambda v: abs(v - day))
|
|
return f"{closest:02d}"
|
|
|
|
|
|
def _is_plano_familia(value: Any) -> bool:
|
|
if isinstance(value, str):
|
|
normalized = _normalize_service_name(value)
|
|
return "familia" in normalized or "family" in normalized
|
|
if isinstance(value, dict):
|
|
for key in ("is_family", "isFamily", "familia", "family"):
|
|
if key in value and _normalize_bool(value.get(key), default=False):
|
|
return True
|
|
text = _text_from_keys(
|
|
value,
|
|
(
|
|
"desc",
|
|
"name",
|
|
"item_name",
|
|
"nome_plano",
|
|
"plan_name",
|
|
"planName",
|
|
"offer_name",
|
|
"offerName",
|
|
"commercialName",
|
|
"productName",
|
|
),
|
|
)
|
|
normalized = _normalize_service_name(text)
|
|
return "familia" in normalized or "family" in normalized
|
|
|
|
|
|
def _iter_plan_candidates(source: Any) -> list[Any]:
|
|
if not isinstance(source, dict):
|
|
return []
|
|
candidates: list[Any] = []
|
|
for key in (
|
|
"planos",
|
|
"plans",
|
|
"plan",
|
|
"plano",
|
|
"nome_plano",
|
|
"nomePlano",
|
|
"plan_name",
|
|
"planName",
|
|
"offer",
|
|
"oferta",
|
|
"offers",
|
|
"ofertas",
|
|
"product",
|
|
"products",
|
|
"mainProduct",
|
|
"service",
|
|
"services",
|
|
):
|
|
value = source.get(key)
|
|
if isinstance(value, list):
|
|
candidates.extend(value)
|
|
elif value is not None:
|
|
candidates.append(value)
|
|
return candidates
|
|
|
|
|
|
def _has_plano_familia(*sources: Any) -> bool:
|
|
for source in sources:
|
|
for candidate in _iter_plan_candidates(source):
|
|
if _is_plano_familia(candidate):
|
|
return True
|
|
if _is_plano_familia(source):
|
|
return True
|
|
return False
|
|
|
|
|
|
def _is_dependent_access_value(value: Any) -> bool:
|
|
if isinstance(value, bool):
|
|
return value
|
|
text = _normalize_service_name(str(value or ""))
|
|
return "depend" in text
|
|
|
|
|
|
def _has_dependent_access(*sources: Any) -> bool:
|
|
for source in sources:
|
|
if not isinstance(source, dict):
|
|
continue
|
|
for key in (
|
|
"dependent",
|
|
"dependente",
|
|
"is_dependent",
|
|
"isDependent",
|
|
"tipo_acesso",
|
|
"tipoAcesso",
|
|
"access_type",
|
|
"accessType",
|
|
"line_role",
|
|
"lineRole",
|
|
"role",
|
|
"holder_type",
|
|
"holderType",
|
|
"subscriber_type",
|
|
"subscriberType",
|
|
):
|
|
if key in source and _is_dependent_access_value(source.get(key)):
|
|
return True
|
|
for candidate in _iter_plan_candidates(source):
|
|
if isinstance(candidate, dict) and _has_dependent_access(candidate):
|
|
return True
|
|
return False
|
|
|
|
|
|
def _is_plano_familia_dependent_access(
|
|
state: dict[str, Any],
|
|
params: dict[str, Any],
|
|
contract_payload: dict[str, Any] | None = None,
|
|
) -> bool:
|
|
input_state = state.get("input", {}) if isinstance(state, dict) else {}
|
|
sources = [
|
|
params if isinstance(params, dict) else {},
|
|
input_state if isinstance(input_state, dict) else {},
|
|
]
|
|
if isinstance(contract_payload, dict):
|
|
sources.append(contract_payload)
|
|
contract = contract_payload.get("contract")
|
|
if isinstance(contract, dict):
|
|
sources.append(contract)
|
|
billing_profile = contract_payload.get("billing_profile")
|
|
if isinstance(billing_profile, dict):
|
|
sources.append(billing_profile)
|
|
return _has_plano_familia(*sources) and _has_dependent_access(*sources)
|
|
|
|
|
|
def _resolve_manual_conta_certa_indicator_default(
|
|
state: dict[str, Any],
|
|
params: dict[str, Any],
|
|
runtime: WorkflowRuntimeContext,
|
|
*,
|
|
msisdn: str,
|
|
) -> bool:
|
|
invoice_id = _first_text_from_params_or_state(
|
|
state,
|
|
params,
|
|
"invoice_id",
|
|
"invoiceId",
|
|
"current_invoice_number",
|
|
"currentInvoiceNumber",
|
|
"invoice_number",
|
|
"invoiceNumber",
|
|
)
|
|
|
|
if _is_plano_familia_dependent_access(state, params):
|
|
return True
|
|
|
|
vars_state = state.get("vars", {}) if isinstance(state, dict) else {}
|
|
if isinstance(vars_state, dict):
|
|
check_invoice_payload = vars_state.get("check_invoice_status", {})
|
|
if isinstance(check_invoice_payload, dict):
|
|
invoice_item = (
|
|
_find_invoice_item_by_id(check_invoice_payload, invoice_id)
|
|
if invoice_id
|
|
else None
|
|
)
|
|
if isinstance(invoice_item, dict):
|
|
for key in ("cutDate", "cutoffDate"):
|
|
parsed_cut_date = _parse_tracking_date(invoice_item.get(key))
|
|
if parsed_cut_date is not None:
|
|
return _resolve_next_cutoff_from_cut_date(
|
|
parsed_cut_date
|
|
)[0]
|
|
parsed_cut_day = _parse_tracking_day(invoice_item.get(key))
|
|
if parsed_cut_day is not None:
|
|
return _resolve_billing_cutoff_reference(parsed_cut_day)[0]
|
|
|
|
cutoff_payload = vars_state.get("consultar_contrato_corte", {})
|
|
if isinstance(cutoff_payload, dict):
|
|
if _normalize_bool(
|
|
cutoff_payload.get("plano_familia_acesso_dependente"),
|
|
default=False,
|
|
):
|
|
return True
|
|
data_corte_flag = str(cutoff_payload.get("data_corte_flag") or "").strip()
|
|
if data_corte_flag == "0":
|
|
return True
|
|
if data_corte_flag == "1":
|
|
return False
|
|
|
|
try:
|
|
dia_corte = int(cutoff_payload.get("dia_corte") or 0)
|
|
except (TypeError, ValueError):
|
|
dia_corte = 0
|
|
if dia_corte > 0:
|
|
return _resolve_billing_cutoff_reference(dia_corte)[0]
|
|
|
|
if not msisdn:
|
|
return False
|
|
|
|
result = runtime.factory.create_contrato(msisdn=msisdn).execute()
|
|
if _result_failed_or_missing_data(result, state=state):
|
|
return False
|
|
payload = _to_dict(result.data)
|
|
if _is_plano_familia_dependent_access(state, params, payload):
|
|
return True
|
|
billing = payload.get("billing_profile", {}) if isinstance(payload, dict) else {}
|
|
if not isinstance(billing, dict):
|
|
billing = {}
|
|
date_info = billing.get("date", {})
|
|
if not isinstance(date_info, dict):
|
|
date_info = {}
|
|
try:
|
|
dia_corte = int(date_info.get("cutoffDay") or 0)
|
|
except (TypeError, ValueError):
|
|
dia_corte = 0
|
|
if dia_corte <= 0:
|
|
return False
|
|
return _resolve_billing_cutoff_reference(dia_corte)[0]
|
|
|
|
|
|
def _resolve_contestation_request_rules(
|
|
state: dict[str, Any],
|
|
params: dict[str, Any],
|
|
runtime: WorkflowRuntimeContext,
|
|
*,
|
|
msisdn: str,
|
|
invoice_number: str,
|
|
invoice_payload: dict[str, Any] | None = None,
|
|
) -> dict[str, Any]:
|
|
"""Resolve campos de negócio usados no abrir_contestacao_cliente."""
|
|
vars_state = state.get("vars", {}) if isinstance(state, dict) else {}
|
|
items = _build_contestation_items(state, params)
|
|
if not items:
|
|
return {
|
|
"validation_error": "items obrigatório para abrir contestação",
|
|
"validation_log": [],
|
|
"item_validation_skipped": False,
|
|
"items": [],
|
|
"customer_status": "1",
|
|
"invoice_status": "0",
|
|
"invoice_due_date": "",
|
|
"invoice_amount_open": "0",
|
|
"invoice_amount": "0",
|
|
"refund_option": "0",
|
|
"manual_conta_certa_indicator": False,
|
|
}
|
|
|
|
claimed_total = Decimal("0")
|
|
validated_total = Decimal("0")
|
|
for item in items:
|
|
claimed_amount = _normalize_number_text(item.get("claimed_amount", "0"))
|
|
validated_amount = _normalize_number_text(item.get("validated_amount", "0"))
|
|
claimed_total += Decimal(claimed_amount)
|
|
validated_total += Decimal(validated_amount)
|
|
logger.info(
|
|
"contestacao.item_values_sum msisdn=%s total_claimed=%s total_validated=%s itens=%s",
|
|
msisdn,
|
|
format(claimed_total, "f"),
|
|
format(validated_total, "f"),
|
|
[
|
|
{
|
|
"item_name": item.get("item_name", ""),
|
|
"claimed_amount": _normalize_number_text(
|
|
item.get("claimed_amount", "")
|
|
),
|
|
"validated_amount": _normalize_number_text(
|
|
item.get("validated_amount", "")
|
|
),
|
|
}
|
|
for item in items
|
|
],
|
|
)
|
|
|
|
customer_status = _first_text_from_params_or_state(
|
|
state,
|
|
params,
|
|
"customer_status",
|
|
"customerStatus",
|
|
)
|
|
if not customer_status:
|
|
contract = _first_value_from_params_or_state(state, params, "contract")
|
|
if isinstance(contract, dict):
|
|
customer_status = str(contract.get("msisdn_status", "")).strip()
|
|
customer_status = _map_customer_status_code(customer_status) or "1"
|
|
|
|
invoice_status = _first_text_from_params_or_state(
|
|
state,
|
|
params,
|
|
"invoice_status",
|
|
"invoiceStatus",
|
|
)
|
|
if not invoice_status and isinstance(vars_state, dict):
|
|
invoice_status = _first_invoice_status_from_payload(
|
|
vars_state.get("check_invoice_status", {}),
|
|
invoice_id=invoice_number,
|
|
)
|
|
if not invoice_status:
|
|
return {
|
|
"validation_error": "invoiceStatus da fatura obrigatório para abrir contestação",
|
|
"validation_log": [],
|
|
"item_validation_skipped": False,
|
|
"items": items,
|
|
"customer_status": customer_status,
|
|
"invoice_status": "0",
|
|
"invoice_due_date": "",
|
|
"invoice_amount_open": format(claimed_total, "f"),
|
|
"invoice_amount": format(validated_total, "f"),
|
|
"refund_option": "0",
|
|
"manual_conta_certa_indicator": False,
|
|
}
|
|
refund_option = _first_text_from_params_or_state(
|
|
state,
|
|
params,
|
|
"refund_option",
|
|
"refundOption",
|
|
)
|
|
if not refund_option:
|
|
has_open_bill = False
|
|
payment_method = ""
|
|
invoice_status_texts: list[str] = []
|
|
if isinstance(vars_state, dict):
|
|
perfil_payload = vars_state.get("consultar_perfil_fatura", {})
|
|
if isinstance(perfil_payload, dict):
|
|
has_open_bill = _has_open_bill_from_payload(
|
|
perfil_payload,
|
|
invoice_id=invoice_number,
|
|
default=has_open_bill,
|
|
)
|
|
payment_method = str(
|
|
perfil_payload.get("method_payment")
|
|
or perfil_payload.get("forma_pagamento")
|
|
or ""
|
|
).strip()
|
|
invoice_status_texts.extend(
|
|
_invoice_status_texts_from_payload(
|
|
perfil_payload,
|
|
invoice_id=invoice_number,
|
|
)
|
|
)
|
|
check_payload = vars_state.get("check_invoice_status", {})
|
|
if isinstance(check_payload, dict):
|
|
has_open_bill = _has_open_bill_from_payload(
|
|
check_payload,
|
|
invoice_id=invoice_number,
|
|
default=has_open_bill,
|
|
)
|
|
invoice_status_texts.extend(
|
|
_invoice_status_texts_from_payload(
|
|
check_payload,
|
|
invoice_id=invoice_number,
|
|
)
|
|
)
|
|
decision = _resolve_refund_and_resolution_decision(
|
|
payment_method=payment_method,
|
|
has_open_bill=has_open_bill,
|
|
invoice_status_texts=invoice_status_texts,
|
|
)
|
|
refund_option = str(decision["refund_option"])
|
|
|
|
invoice_due_date = _resolve_contestation_due_date(state, params)
|
|
|
|
raw_invoice_amount_open = _first_text_from_params_or_state(
|
|
state,
|
|
params,
|
|
"invoice_amount_open",
|
|
"invoiceAmountOpen",
|
|
)
|
|
raw_invoice_amount = _first_text_from_params_or_state(
|
|
state,
|
|
params,
|
|
"invoice_amount",
|
|
"invoiceAmount",
|
|
"valor",
|
|
"amount",
|
|
)
|
|
|
|
claimed_total_str = format(claimed_total, "f")
|
|
validated_total_str = format(validated_total, "f")
|
|
|
|
if _is_missing_amount_value(raw_invoice_amount_open):
|
|
invoice_amount_open = claimed_total_str
|
|
else:
|
|
invoice_amount_open = _normalize_number_text(
|
|
raw_invoice_amount_open,
|
|
default=claimed_total_str,
|
|
)
|
|
|
|
if _is_missing_amount_value(raw_invoice_amount):
|
|
invoice_amount = validated_total_str
|
|
else:
|
|
invoice_amount = _normalize_number_text(
|
|
raw_invoice_amount,
|
|
default=validated_total_str,
|
|
)
|
|
|
|
resolved_manual_conta_certa_indicator = _resolve_manual_conta_certa_indicator_default(
|
|
state,
|
|
params,
|
|
runtime,
|
|
msisdn=msisdn,
|
|
)
|
|
raw_manual_indicator = _first_value_from_params_or_state(
|
|
state,
|
|
params,
|
|
"manual_conta_certa_indicator",
|
|
"manualContaCertaIndicator",
|
|
)
|
|
manual_conta_certa_indicator = (
|
|
resolved_manual_conta_certa_indicator
|
|
or _normalize_bool(raw_manual_indicator, default=False)
|
|
)
|
|
|
|
validation_log: list[dict[str, Any]] = []
|
|
item_validation_skipped = _should_skip_contestation_item_validation(
|
|
state,
|
|
params,
|
|
items,
|
|
)
|
|
if item_validation_skipped:
|
|
validation_log = []
|
|
else:
|
|
resolved_invoice_payload = invoice_payload if isinstance(invoice_payload, dict) else {}
|
|
items, validation_log, validation_error = validate_contestation_items(
|
|
items=items,
|
|
invoice_payload=resolved_invoice_payload,
|
|
)
|
|
if validation_error:
|
|
return {
|
|
"validation_error": validation_error,
|
|
"validation_log": validation_log,
|
|
"item_validation_skipped": item_validation_skipped,
|
|
"items": items,
|
|
"customer_status": customer_status,
|
|
"invoice_status": invoice_status,
|
|
"invoice_due_date": invoice_due_date,
|
|
"invoice_amount_open": invoice_amount_open,
|
|
"invoice_amount": invoice_amount,
|
|
"refund_option": refund_option,
|
|
"manual_conta_certa_indicator": manual_conta_certa_indicator,
|
|
}
|
|
|
|
return {
|
|
"validation_error": "",
|
|
"validation_log": validation_log,
|
|
"item_validation_skipped": item_validation_skipped,
|
|
"items": items,
|
|
"customer_status": customer_status,
|
|
"invoice_status": invoice_status,
|
|
"invoice_due_date": invoice_due_date,
|
|
"invoice_amount_open": invoice_amount_open,
|
|
"invoice_amount": invoice_amount,
|
|
"refund_option": refund_option,
|
|
"manual_conta_certa_indicator": manual_conta_certa_indicator,
|
|
}
|
|
|
|
|
|
def _should_skip_contestation_item_validation(
|
|
state: dict[str, Any],
|
|
params: dict[str, Any],
|
|
items: list[dict[str, Any]],
|
|
) -> bool:
|
|
if _to_bool(
|
|
_first_value_from_params_or_state(
|
|
state,
|
|
params,
|
|
"skip_invoice_item_validation",
|
|
"skipInvoiceItemValidation",
|
|
)
|
|
):
|
|
return True
|
|
|
|
scenario = _first_text_from_params_or_state(
|
|
state,
|
|
params,
|
|
"tipo_atendimento",
|
|
"scenario",
|
|
)
|
|
scenario_key = _normalize_service_name(scenario).replace(" ", "_")
|
|
if scenario_key == "pro_rata":
|
|
return True
|
|
|
|
return bool(items) and all(
|
|
str(item.get("item_type", "")).strip().upper() == "PRO_RATA"
|
|
for item in items
|
|
)
|
|
|
|
def _build_contestation_items(
|
|
state: dict[str, Any],
|
|
params: dict[str, Any],
|
|
) -> list[dict[str, Any]]:
|
|
def _resolve_amount_with_fallback(
|
|
primary: Any,
|
|
fallback: Any,
|
|
) -> str:
|
|
if _is_missing_amount_value(primary) and not _is_missing_amount_value(fallback):
|
|
return _normalize_number_text(fallback, default="0")
|
|
if _is_missing_amount_value(primary):
|
|
return "0"
|
|
return _normalize_number_text(primary, default="0")
|
|
|
|
raw_items = _first_value_from_params_or_state(
|
|
state,
|
|
params,
|
|
"items",
|
|
)
|
|
items: list[dict[str, Any]] = []
|
|
if isinstance(raw_items, list):
|
|
|
|
def _first_present_amount(raw_item: dict[str, Any], *keys: str) -> Any:
|
|
for key in keys:
|
|
value = raw_item.get(key)
|
|
if not _is_missing_amount_value(value):
|
|
return value
|
|
return None
|
|
|
|
for raw_item in raw_items:
|
|
if not isinstance(raw_item, dict):
|
|
continue
|
|
item_name = str(
|
|
raw_item.get("item_name")
|
|
or raw_item.get("itemName")
|
|
or raw_item.get("name")
|
|
or raw_item.get("servico")
|
|
or raw_item.get("service_name")
|
|
or ""
|
|
).strip()
|
|
if not item_name:
|
|
continue
|
|
item_type = str(
|
|
raw_item.get("item_type")
|
|
or raw_item.get("itemType")
|
|
or raw_item.get("type")
|
|
or "VAS_AVULSO"
|
|
).strip() or "VAS_AVULSO"
|
|
claimed_raw = _first_present_amount(
|
|
raw_item,
|
|
"claimed_amount",
|
|
"claimedAmount",
|
|
)
|
|
validated_raw = _first_present_amount(
|
|
raw_item,
|
|
"validated_amount",
|
|
"validatedAmount",
|
|
)
|
|
claimed_amount = _resolve_amount_with_fallback(
|
|
claimed_raw,
|
|
validated_raw,
|
|
)
|
|
validated_amount = _resolve_amount_with_fallback(
|
|
validated_raw,
|
|
claimed_raw,
|
|
)
|
|
items.append(
|
|
{
|
|
"item_name": item_name,
|
|
"item_type": item_type,
|
|
"claimed_amount": claimed_amount,
|
|
"validated_amount": validated_amount,
|
|
}
|
|
)
|
|
|
|
if items:
|
|
return items
|
|
|
|
servico = _first_text_from_params_or_state(
|
|
state,
|
|
params,
|
|
"servico",
|
|
"service_name",
|
|
"item_name",
|
|
"itemName",
|
|
"name",
|
|
)
|
|
valor = _first_text_from_params_or_state(
|
|
state,
|
|
params,
|
|
"valor",
|
|
"amount",
|
|
"price",
|
|
"claimed_amount",
|
|
"claimedAmount",
|
|
"validated_amount",
|
|
"validatedAmount",
|
|
)
|
|
if not servico:
|
|
return []
|
|
normalized_value = _normalize_number_text(valor, default="0")
|
|
return [
|
|
{
|
|
"item_name": servico,
|
|
"item_type": "VAS_AVULSO",
|
|
"claimed_amount": normalized_value,
|
|
"validated_amount": normalized_value,
|
|
}
|
|
]
|
|
|
|
|
|
__all__ = [
|
|
'_first_invoice_status_from_items',
|
|
'_invoice_identifier',
|
|
'_invoice_item_matches_id',
|
|
'_invoice_items_from_payload',
|
|
'_find_invoice_item_by_id',
|
|
'_to_tracking_datetime',
|
|
'_first_invoice_date_from_items',
|
|
'_first_invoice_date_from_payload_by_id',
|
|
'_first_invoice_text_from_payload_by_id',
|
|
'_normalize_due_date_yyyymmdd',
|
|
'_is_dacc_cartao_parcelamento',
|
|
'_is_invoice_paid_status',
|
|
'_is_invoice_unpaid_status',
|
|
'_is_invoice_contested_status',
|
|
'_map_invoice_status_to_state',
|
|
'_map_invoice_status_code',
|
|
'_first_invoice_status_from_payload',
|
|
'_collect_invoice_status_texts',
|
|
'_invoice_status_texts_from_payload',
|
|
'_has_open_bill_from_payload',
|
|
'_resolve_refund_and_resolution_decision',
|
|
'_resolve_contestation_due_date',
|
|
'_extrair_dia_vencimento',
|
|
'_is_plano_familia',
|
|
'_iter_plan_candidates',
|
|
'_has_plano_familia',
|
|
'_is_dependent_access_value',
|
|
'_has_dependent_access',
|
|
'_is_plano_familia_dependent_access',
|
|
'_resolve_manual_conta_certa_indicator_default',
|
|
'_resolve_contestation_request_rules',
|
|
'_should_skip_contestation_item_validation',
|
|
'_build_contestation_items',
|
|
]
|