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', ]