from __future__ import annotations import logging import re from decimal import Decimal from typing import Any from agente_contas_tim.constants.ic_tags_enum import VAATag from agente_contas_tim.integrations.rct_policy import RCTOperation from agente_contas_tim.observability import get_session_id from agente_contas_tim.protocol_triplets import resolve_protocol_triplet from agente_contas_tim.workflows.actions.inputs import parse_cancel_vas_items from agente_contas_tim.workflows.actions.registry import ( WorkflowRuntimeContext, workflow_action, ) from agente_contas_tim.workflows.runtime_types import ActionResult from agente_contas_tim.workflows.actions.common.helpers import ( _build_ic_context, _emit_vaa, _find_matching_service, _first_text_from_params_or_state, _format_amount, _idempotency_get, _idempotency_set, _parse_amount, _resolve_service_csp_id, _result_failed_or_missing_data, _service_from_dict, _to_dict, ) from agente_contas_tim.workflows.actions.vas_avulso.helpers import ( _build_active_services, _empty_cancel_result, _service_can_be_canceled, ) logger = logging.getLogger("agente_contas_tim.workflows.actions.tim_actions") @workflow_action("consulta_vas") def query_vas( state: dict[str, Any], params: dict[str, Any], runtime: WorkflowRuntimeContext, ) -> ActionResult: input_state = state.get("input", {}) if isinstance(state, dict) else {} app_id = str(input_state.get("app_id", "")) service_context = input_state.get("service_context") if isinstance(service_context, dict): context_app_id = str(service_context.get("app_id", "")).strip() if not app_id or context_app_id == app_id: return ActionResult.ok( { "services": [service_context], "service_found": True, "service_status": str(service_context.get("status", "")), "service": service_context, "consulta_metadata": {"source": "service_context"}, } ) result = runtime.factory.create_query_vas( msisdn=str(params["msisdn"]) ).execute() if _result_failed_or_missing_data(result, state=state): return ActionResult.fail( result.error or "Falha na consulta", **result.metadata, ) payload = _to_dict(result.data) services = payload.get("services", []) target = next( (service for service in services if str(service.get("app_id", "")) == app_id), None, ) return ActionResult.ok( { "services": services, "service_found": target is not None, "service_status": str((target or {}).get("status", "")), "service": target, "consulta_metadata": dict(result.metadata), }, **result.metadata, ) @workflow_action("cancelamento_vas_avulso_batch") def cancelamento_vas_avulso_batch( state: dict[str, Any], params: dict[str, Any], runtime: WorkflowRuntimeContext, ) -> ActionResult: csp_id = str(params.get("csp_id", "740")).strip() or "740" channel = "AIAGENTCR" request_status = str(params.get("request_status", "Fechado")).strip() or "Fechado" status = str(params.get("status", "CLOSED")).strip() or "CLOSED" message_id = _first_text_from_params_or_state( state, params, "message_id", "messageId", ) idempotency_key = str(params.get("idempotency_key", "")).strip() social_sec_no = re.sub( r"\D", "", _first_text_from_params_or_state( state, params, "social_sec_no", "socialSecNo", "customer_document", "customerDocument", ), ) input_state = state.get("input", {}) if isinstance(state, dict) else {} _ic_base: dict[str, Any] = { "sessionId": str(get_session_id() or ""), "gsm": "", "ani": str((input_state.get("ani") if isinstance(input_state, dict) else "") or "").strip(), "uraCallId": str((input_state.get("ura_call_id") if isinstance(input_state, dict) else "") or "").strip(), "agentId": "contas", "channelId": str((input_state.get("channel_id") if isinstance(input_state, dict) else "URA") or "URA").strip(), } parsed_items = parse_cancel_vas_items(params.get("items")) if not parsed_items: return ActionResult.ok( _empty_cancel_result( "", mensagem="Nenhum serviço informado para contestação." ) ) normalized_items: list[tuple[str, str, float]] = [ (item.msisdn, item.service, item.value) for item in parsed_items ] _emit_vaa(VAATag.INICIO_FLUXO, _ic_base, gsm=normalized_items[0][0]) msisdns_in_order: list[str] = [] seen_msisdns: set[str] = set() for item_msisdn, _, _ in normalized_items: if item_msisdn not in seen_msisdns: seen_msisdns.add(item_msisdn) msisdns_in_order.append(item_msisdn) protocol_entries: list[dict[str, str]] = [] active_by_msisdn: dict[str, dict[str, dict[str, Any]]] = {} query_failures: set[str] = set() for line_msisdn in msisdns_in_order: query_result = runtime.factory.create_query_vas( msisdn=line_msisdn ).execute() if _result_failed_or_missing_data(query_result, state=state): query_failures.add(line_msisdn) active_by_msisdn[line_msisdn] = {} continue active_by_msisdn[line_msisdn] = _build_active_services( getattr(query_result.data, "services", []) ) canceled_items: list[dict[str, Any]] = [] errors: list[dict[str, str]] = [] not_found_services: list[dict[str, str]] = [] contestation_candidates: list[dict[str, Any]] = [] technical_error_in_iu = False def _to_display_amount(value: Any) -> str: candidates: list[Any] = [value] def _collect_from_any(raw: Any) -> list[Any]: collected: list[Any] = [] if raw is None: return collected if isinstance(raw, (str, int, float, Decimal)): collected.append(raw) return collected if isinstance(raw, dict): for key in ( "valor", "price", "amount", "cost", "value", "valor_numeric", "valor_float", "price_numeric", "amount_float", ): if key in raw: candidate = raw.get(key) if candidate not in (None, ""): collected.append(candidate) details = raw.get("details") if isinstance(details, dict): collected.extend(_collect_from_any(details)) extra = raw.get("extra") if isinstance(extra, dict): collected.extend(_collect_from_any(extra)) if isinstance(raw, (list, tuple, set)): for item in raw: if item not in (None, ""): collected.extend(_collect_from_any(item)) return collected for candidate in _collect_from_any(value): parsed = _parse_amount(candidate) if parsed is None: continue try: return _format_amount(parsed) except Exception: continue return "" def _resolve_item_amount( requested: Any, matched_service: dict[str, Any], ) -> str: service_context = matched_service.get("service_context") context = service_context if isinstance(service_context, dict) else {} return ( _to_display_amount(requested) or _to_display_amount( { "valor": matched_service.get("valor"), "price": matched_service.get("price"), "amount": matched_service.get("amount"), "cost": matched_service.get("cost"), "value": matched_service.get("value"), "amount_numeric": matched_service.get("amount_numeric"), "valor_numeric": matched_service.get("valor_numeric"), "valor_float": matched_service.get("valor_float"), "price_numeric": matched_service.get("price_numeric"), "service_context": context, } ) ) def _parse_amount_from_item(item: dict[str, Any]) -> Decimal | None: for key in ("valor", "value", "amount", "price", "servico_valor"): raw_value = item.get(key) if raw_value is None: continue parsed = _parse_amount(raw_value) if parsed is not None: return parsed return None def _build_contestation_candidate( *, msisdn: str, service_name: str, amount: Any, app_id: str = "", codigo_boleto: str = "", iu_reason: str = "", ) -> dict[str, Any]: resolved_amount = _to_display_amount(amount) or "0" return { "msisdn": msisdn, "servico": str(service_name or "").strip(), "service": str(service_name or "").strip(), "app_id": str(app_id or "").strip(), "valor": resolved_amount, "value": resolved_amount, "amount": resolved_amount, "price": resolved_amount, "codigo_boleto": str(codigo_boleto or "").strip(), "iu_reason": str(iu_reason or "").strip(), } for item_msisdn, requested_service, requested_value in normalized_items: if item_msisdn in query_failures: technical_error_in_iu = True errors.append( { "msisdn": item_msisdn, "servico": requested_service, "erro": ( "Falha ao consultar a linha de final " f"{item_msisdn[-2:]}." ), } ) continue active_services = active_by_msisdn.get(item_msisdn, {}) matched_service = _find_matching_service( requested_service, active_services, ) if not matched_service: logger.info( "cancelamento_vas_avulso_batch.skip reason=service_not_found msisdn=%s requested_service=%s available_services=%s", item_msisdn, requested_service, list(active_services.keys()), ) not_found_services.append( {"msisdn": item_msisdn, "servico": requested_service} ) contestation_candidates.append( _build_contestation_candidate( msisdn=item_msisdn, service_name=requested_service, amount=requested_value, iu_reason="service_not_found", ) ) continue if not _service_can_be_canceled(matched_service): logger.info( "cancelamento_vas_avulso_batch.skip reason=can_cancel_false msisdn=%s requested_service=%s matched_service=%s", item_msisdn, requested_service, str(matched_service.get("service_name", "")).strip(), ) errors.append( { "msisdn": item_msisdn, "servico": requested_service, "erro": ( "Serviço já se encontra cancelado, não sendo possível " "realizar recancelamento." ), } ) contestation_candidates.append( _build_contestation_candidate( msisdn=item_msisdn, service_name=str( matched_service.get("service_name", "") ).strip() or requested_service, amount=requested_value, app_id=str(matched_service.get("app_id", "")).strip(), codigo_boleto=str( matched_service.get("codigo_boleto", "") ).strip(), iu_reason="already_inactive_or_blocked", ) ) continue service_name = str(matched_service.get("service_name", "")) app_id = str(matched_service.get("app_id", "")) valor = _resolve_item_amount(requested_value, matched_service) codigo_boleto = str(matched_service.get("codigo_boleto", "")).strip() cache_key = "" if idempotency_key and service_name: cache_key = ( f"{idempotency_key}:{item_msisdn}:" f"{service_name.lower()}:{app_id}" ) cached_item = _idempotency_get(cache_key) if cached_item is not None: merged_item = dict(cached_item) if not str( merged_item.get("valor", "") or merged_item.get("value", "") or merged_item.get("amount", "") ).strip(): merged_item.update( { "valor": valor, "value": valor, "amount": valor, "price": valor, } ) if cache_key: _idempotency_set(cache_key, merged_item) canceled_items.append(merged_item) continue resolved_csp_id = _resolve_service_csp_id( matched_service, fallback_csp_id=csp_id, ) _emit_vaa(VAATag.BLOQUEIO_INICIO, _ic_base, gsm=item_msisdn) block_result = runtime.factory.create_block_vas( msisdn=item_msisdn, app_id=app_id, csp_id=resolved_csp_id, service=_service_from_dict( matched_service.get("service_context") ), ic_context={**_ic_base, "gsm": item_msisdn}, ).execute() if not block_result.success: technical_error_in_iu = True _emit_vaa(VAATag.ITEM_CANCELADO_FAIL, _ic_base, gsm=item_msisdn) errors.append( { "msisdn": item_msisdn, "servico": service_name, "erro": block_result.error or "Falha no bloqueio", } ) continue cancel_result = runtime.factory.create_cancellation_vas( msisdn=item_msisdn, app_id=app_id, csp_id=resolved_csp_id, channel=channel, interaction_protocol="", service=_service_from_dict( matched_service.get("service_context") ), ).execute() if not cancel_result.success: technical_error_in_iu = True _emit_vaa(VAATag.ITEM_CANCELADO_FAIL, _ic_base, gsm=item_msisdn) errors.append( { "msisdn": item_msisdn, "servico": service_name, "erro": cancel_result.error or "Falha no cancelamento", } ) continue _emit_vaa(VAATag.ITEM_CANCELADO_OK, _ic_base, gsm=item_msisdn) cancel_payload = _to_dict(cancel_result.data) cancelamento = ( cancel_payload.get("cancelamento", {}) if isinstance(cancel_payload, dict) else {} ) cancellation_protocol = "" if isinstance(cancelamento, dict): cancellation_protocol = str( cancelamento.get("interactionProtocol") or cancelamento.get("protocolId") or cancelamento.get("protocolo_id") or "" ).strip() if not cancellation_protocol: body_payload = cancelamento.get("body", {}) if isinstance(body_payload, dict): cancellation_protocol = str( body_payload.get("interactionProtocol") or body_payload.get("protocolId") or body_payload.get("protocolo_id") or "" ).strip() canceled_item: dict[str, Any] = { "msisdn": item_msisdn, "servico": service_name, "app_id": app_id, "valor": valor, "value": valor, "amount": valor, "price": valor, "codigo_boleto": codigo_boleto, } canceled_items.append(canceled_item) contestation_candidates.append(dict(canceled_item)) if cache_key: _idempotency_set(cache_key, canceled_item) _emit_vaa(VAATag.PROTOCOLO_INICIO, _ic_base, gsm=item_msisdn) close_triplet = resolve_protocol_triplet( "cancelamento_vas_avulso", stage="close", context={"servico": service_name}, ) protocol_result = runtime.factory.create_protocol_v2( msisdn=item_msisdn, interaction_protocol=cancellation_protocol, flag_sms=True, social_sec_no=social_sec_no, source="CHAT", reason1=close_triplet.reason1 or "Informação", reason2=close_triplet.reason2 or "Conta", reason3=close_triplet.reason3 or "Valor", direction_contact="FROM-CLIENT", type="CLIENTE", request_flag=True, request_status=request_status, status=status, service_request_notes=f"Serviço cancelado: {service_name}", client_id="AIAGENTCR", ic_context={**_ic_base, "gsm": str(item_msisdn)}, rct_operation=RCTOperation.REG_ATEND_VAS_AVULSO, message_id=message_id, ).execute() if _result_failed_or_missing_data(protocol_result, state=state): errors.append( { "msisdn": item_msisdn, "servico": service_name, "erro": ( protocol_result.error or "Falha ao registrar protocolo após cancelamento" ), } ) continue _emit_vaa(VAATag.PROTOCOLO_OK, _ic_base, gsm=item_msisdn) protocol_payload = _to_dict(protocol_result.data) protocol_response = ( protocol_payload.get("response", {}) if isinstance(protocol_payload, dict) else {} ) if not isinstance(protocol_response, dict): protocol_response = {} protocolo_id = str( cancellation_protocol or protocol_response.get("interactionProtocol") or protocol_response.get("protocolId") or protocol_response.get("protocolo_id") or "" ).strip() if not protocolo_id: errors.append( { "msisdn": item_msisdn, "servico": service_name, "erro": "Protocolo não retornado no registro de atendimento", } ) continue protocol_entries.append( {"msisdn": item_msisdn, "protocolo_id": protocolo_id} ) summary_parts: list[str] = [] if canceled_items: by_line: dict[str, list[str]] = {} for item in canceled_items: by_line.setdefault( str(item.get("msisdn", "")), [] ).append(str(item.get("servico", ""))) for line, names in by_line.items(): summary_parts.append( "Cancelados com sucesso na linha de final " f"{line[-2:]}: {', '.join(names)}." ) for error in errors: summary_parts.append( f"Erro ao cancelar {error['servico']} " f"(linha {error['msisdn'][-2:]}): {error['erro']}." ) if not_found_services: joined = ", ".join( f"{nf['servico']} (linha {nf['msisdn'][-2:]})" for nf in not_found_services ) summary_parts.append(f"Não encontrados: {joined}.") total_valor = Decimal("0") total_tem_valor = False cancelados_descritivo: list[str] = [] for item in canceled_items: servico = str(item.get("servico", "")).strip() valor = str(item.get("valor", "")).strip() parsed = _parse_amount_from_item(item) if parsed is not None: total_valor += parsed total_tem_valor = True if servico: valor_txt = f" (valor: R$ {valor})" if valor else "" cancelados_descritivo.append(f"{servico}{valor_txt}") total_valor_txt = ( f"R$ {_format_amount(total_valor)}" if total_tem_valor else "R$ 0,00" ) if technical_error_in_iu: contestation_candidates = [] primary_msisdn = msisdns_in_order[0] if msisdns_in_order else "" resumo_agente = ( "Cancelamento VAS avulso concluido. " f"Telefone: {primary_msisdn}. " "Servicos cancelados: " f"{', '.join(cancelados_descritivo) if cancelados_descritivo else 'nenhum'}. " f"Total dos servicos cancelados: {total_valor_txt}." ) return ActionResult.ok( { "success": len(canceled_items) > 0, "mensagem": " ".join(summary_parts), "resumo_agente": resumo_agente, "msisdn": primary_msisdn, "cancelados": canceled_items, "erros": errors, "nao_encontrados": not_found_services, "itens_para_contestacao": contestation_candidates, "protocolos_por_linha": protocol_entries, "protocol_closed": bool(protocol_entries), "prompt_signals": { "servicos_cancelados": [ item.get("servico") for item in canceled_items ], }, } ) @workflow_action("avaliar_proxima_acao") def evaluate_next_action( state: dict[str, Any], params: dict[str, Any], runtime: WorkflowRuntimeContext, ) -> ActionResult: """Action exemplo: decide se deve aguardar confirmação do usuário.""" services = [] previous_query = state.get("vars", {}).get("node1") if isinstance(previous_query, dict): previous_services = previous_query.get("services", []) if isinstance(previous_services, list): services = previous_services metadata: dict[str, Any] = {} if not services: result = runtime.factory.create_query_vas( msisdn=str(params["msisdn"]) ).execute() if _result_failed_or_missing_data(result, state=state): return ActionResult.fail( result.error or "Falha na consulta para avaliação", **result.metadata, ) services = _to_dict(result.data).get("services", []) metadata = dict(result.metadata) tim_music = next( ( s for s in services if str(s.get("service_name", "")).strip().lower() == "tim music" and str(s.get("status", "")).upper() == "ACTIVE" ), None, ) if tim_music: client_payload = { "type": "question", "text": ( "O motivo do aumento da sua fatura é o serviço TIM Music, que está ativo em sua linha. " "Deseja cancelar o TIM Music? Se sim, responda SIM para que eu possa prosseguir com o bloqueio e cancelamento." ), "options": ["SIM", "NAO"], "service": { "app_id": tim_music.get("app_id", ""), "service_name": tim_music.get("service_name", ""), "status": tim_music.get("status", ""), }, } return ActionResult.ok( { "await_user_input": True, "client_payload": client_payload, "service_context": tim_music, }, **metadata, ) # Caso não haja TIM Music ativo, segue fluxo normal app_id = str(params.get("app_id", state.get("input", {}).get("app_id", ""))) target = next( (service for service in services if str(service.get("app_id", "")) == app_id), None, ) if target is None: return ActionResult.ok( { "await_user_input": False, "client_payload": { "type": "info", "text": f"Serviço appId={app_id} não localizado para confirmação.", }, } ) status = str(target.get("status", "")).upper() await_user_input = status == "ACTIVE" if await_user_input: client_payload = { "type": "question", "text": "Deseja continuar com a operação deste serviço?", "options": ["SIM", "NAO"], "service": { "app_id": target.get("app_id", ""), "service_name": target.get("service_name", ""), "status": target.get("status", ""), }, } else: client_payload = { "type": "info", "text": "Serviço não requer confirmação adicional.", "service": { "app_id": target.get("app_id", ""), "service_name": target.get("service_name", ""), "status": target.get("status", ""), }, } return ActionResult.ok( { "await_user_input": await_user_input, "client_payload": client_payload, "service_context": target, }, **metadata, ) @workflow_action("bloquear_vas") def bloquear_vas( state: dict[str, Any], params: dict[str, Any], runtime: WorkflowRuntimeContext, ) -> ActionResult: service_context = state.get("vars", {}).get("node2", {}).get("service_context") msisdn = str(params["msisdn"]) input_state = state.get("input", {}) if isinstance(state, dict) else {} result = runtime.factory.create_block_vas( msisdn=msisdn, app_id=str(params["app_id"]), csp_id=str(params.get("csp_id", "740")), service=_service_from_dict(service_context), ic_context=_build_ic_context(input_state, gsm=msisdn), query_metadata=state.get("vars", {}) .get("node1", {}) .get("consulta_metadata", {}), ).execute() if _result_failed_or_missing_data(result, state=state): return ActionResult.fail( result.error or "Falha no bloqueio", **result.metadata, ) return ActionResult.ok(_to_dict(result.data), **result.metadata) @workflow_action("cancelar_vas") def cancelar_vas( state: dict[str, Any], params: dict[str, Any], runtime: WorkflowRuntimeContext, ) -> ActionResult: service_context = state.get("vars", {}).get("node2", {}).get("service_context") result = runtime.factory.create_cancellation_vas( msisdn=str(params["msisdn"]), app_id=str(params["app_id"]), csp_id=str(params.get("csp_id", "740")), interaction_protocol=str(params.get("interaction_protocol", "")), channel="AIAGENTCR", service=_service_from_dict(service_context), query_metadata=state.get("vars", {}) .get("node1", {}) .get("consulta_metadata", {}), ).execute() if _result_failed_or_missing_data(result, state=state): return ActionResult.fail( result.error or "Falha no cancelamento", **result.metadata, ) return ActionResult.ok(_to_dict(result.data), **result.metadata) @workflow_action("bloquear_vas_single") def bloquear_vas_single( state: dict[str, Any], params: dict[str, Any], runtime: WorkflowRuntimeContext, ) -> ActionResult: """Bloqueia um serviço VAS usando msisdn + app_id dos params.""" service_context = ( state.get("vars", {}) .get("consulta_vas", {}) .get("service") ) msisdn = str(params["msisdn"]) input_state = state.get("input", {}) if isinstance(state, dict) else {} result = runtime.factory.create_block_vas( msisdn=msisdn, app_id=str(params["app_id"]), csp_id=str(params.get("csp_id", "740")), service=_service_from_dict(service_context), ic_context=_build_ic_context(input_state, gsm=msisdn), ).execute() if _result_failed_or_missing_data(result, state=state): return ActionResult.fail( result.error or "Falha no bloqueio", **result.metadata, ) return ActionResult.ok(_to_dict(result.data), **result.metadata) @workflow_action("cancelar_vas_single") def cancelar_vas_single( state: dict[str, Any], params: dict[str, Any], runtime: WorkflowRuntimeContext, ) -> ActionResult: """Cancela um serviço VAS usando msisdn + app_id dos params.""" service_context = ( state.get("vars", {}) .get("consulta_vas", {}) .get("service") ) result = runtime.factory.create_cancellation_vas( msisdn=str(params["msisdn"]), app_id=str(params["app_id"]), csp_id=str(params.get("csp_id", "740")), channel="AIAGENTCR", interaction_protocol=str(params.get("interaction_protocol", "")), service=_service_from_dict(service_context), ).execute() if _result_failed_or_missing_data(result, state=state): return ActionResult.fail( result.error or "Falha no cancelamento", **result.metadata, ) return ActionResult.ok(_to_dict(result.data), **result.metadata) __all__ = [ 'query_vas', 'cancelamento_vas_avulso_batch', 'evaluate_next_action', 'bloquear_vas', 'cancelar_vas', 'bloquear_vas_single', 'cancelar_vas_single', ]