first commit

This commit is contained in:
2026-06-16 20:54:49 -03:00
commit 60c2f03eb3
153 changed files with 19704 additions and 0 deletions

View File

@@ -0,0 +1,851 @@
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',
]