mirror of
https://github.com/hoshikawa2/first_contas.git
synced 2026-07-09 10:14:20 +00:00
575 lines
17 KiB
Python
575 lines
17 KiB
Python
from __future__ import annotations
|
||
|
||
import logging
|
||
import os
|
||
import re
|
||
import time
|
||
import unicodedata as ud
|
||
|
||
from dataclasses import (
|
||
asdict,
|
||
is_dataclass,
|
||
)
|
||
from datetime import (
|
||
datetime,
|
||
timezone,
|
||
)
|
||
from decimal import (
|
||
Decimal,
|
||
InvalidOperation,
|
||
)
|
||
from typing import Any
|
||
from pydantic import BaseModel
|
||
from agente_contas_tim.integrations import agent_framework_bridge
|
||
from agente_contas_tim.integrations.noc_events import emit_api_content_mismatch
|
||
from agente_contas_tim.models.service_info import ServiceInfo
|
||
from agente_contas_tim.observability import get_session_id
|
||
|
||
_IDEMPOTENCY_TTL_SECONDS = int(os.getenv("TIM_IDEMPOTENCY_TTL_SECONDS", "3600"))
|
||
_IDEMPOTENCY_CACHE: dict[str, tuple[float, dict[str, Any]]] = {}
|
||
logger = logging.getLogger(__name__)
|
||
|
||
|
||
def _emit_vaa(
|
||
tag: str,
|
||
ic_base: dict[str, Any],
|
||
*,
|
||
gsm: str = "",
|
||
extra_metadata: dict[str, Any] | None = None,
|
||
) -> None:
|
||
"""Emite um IC do fluxo VAS Avulso (VAA.001–VAA.017)."""
|
||
try:
|
||
metadata = {
|
||
**ic_base,
|
||
"tag": tag,
|
||
"eventDate": int(datetime.now(timezone.utc).timestamp() * 1000),
|
||
}
|
||
if isinstance(extra_metadata, dict):
|
||
metadata.update(extra_metadata)
|
||
if gsm:
|
||
metadata["gsm"] = gsm
|
||
agent_framework_bridge.event(tag, metadata=metadata)
|
||
except Exception:
|
||
logger.warning("_emit_vaa tag=%s falhou silenciosamente", tag)
|
||
|
||
|
||
def _build_ic_context(
|
||
input_state: dict[str, Any] | None,
|
||
*,
|
||
gsm: str = "",
|
||
) -> dict[str, Any]:
|
||
if not isinstance(input_state, dict):
|
||
input_state = {}
|
||
return {
|
||
"sessionId": str(get_session_id() or ""),
|
||
"gsm": str(gsm or "").strip(),
|
||
"ani": str(input_state.get("ani", "") or "").strip(),
|
||
"uraCallId": str(input_state.get("ura_call_id", "") or "").strip(),
|
||
"agentId": "contas",
|
||
"channelId": str(
|
||
input_state.get("channel_id")
|
||
or input_state.get("channelId")
|
||
or "URA"
|
||
).strip()
|
||
or "URA",
|
||
}
|
||
|
||
|
||
def _emit_ic(
|
||
ic_code: str,
|
||
input_state: dict[str, Any],
|
||
*,
|
||
extra_metadata: dict[str, Any] | None = None,
|
||
) -> None:
|
||
"""Emite um IC via agent_framework_bridge com metadados padrao."""
|
||
try:
|
||
metadata: dict[str, Any] = {
|
||
"sessionId": str(get_session_id() or ""),
|
||
"tag": ic_code,
|
||
"eventDate": int(datetime.now(timezone.utc).timestamp() * 1000),
|
||
"uraCallId": str(input_state.get("ura_call_id", "") or "").strip(),
|
||
"gsm": str(input_state.get("msisdn", "") or "").strip(),
|
||
"agentId": "contas",
|
||
"channelId": str(
|
||
input_state.get("channel_id")
|
||
or input_state.get("channelId")
|
||
or "URA"
|
||
).strip()
|
||
or "URA",
|
||
}
|
||
ani = str(input_state.get("ani", "") or "").strip()
|
||
if ani:
|
||
metadata["ani"] = ani
|
||
message_id = str(
|
||
input_state.get("message_id") or input_state.get("messageId") or ""
|
||
).strip()
|
||
if message_id:
|
||
metadata["messageId"] = message_id
|
||
if isinstance(extra_metadata, dict):
|
||
metadata.update(extra_metadata)
|
||
agent_framework_bridge.event(ic_code, metadata=metadata)
|
||
except Exception:
|
||
logger.debug("_emit_ic: falha ao emitir %s", ic_code, exc_info=True)
|
||
|
||
|
||
def _idempotency_get(key: str) -> dict[str, Any] | None:
|
||
if not key:
|
||
return None
|
||
now = time.monotonic()
|
||
cached = _IDEMPOTENCY_CACHE.get(key)
|
||
if cached is None:
|
||
return None
|
||
created_at, value = cached
|
||
if now - created_at > _IDEMPOTENCY_TTL_SECONDS:
|
||
_IDEMPOTENCY_CACHE.pop(key, None)
|
||
return None
|
||
return value
|
||
|
||
|
||
def _idempotency_set(key: str, value: dict[str, Any]) -> None:
|
||
if not key:
|
||
return
|
||
_IDEMPOTENCY_CACHE[key] = (time.monotonic(), value)
|
||
|
||
|
||
def _utc_now_iso() -> str:
|
||
return datetime.now(timezone.utc).isoformat()
|
||
|
||
|
||
def _to_epoch_millis(value: Any) -> int:
|
||
if isinstance(value, datetime):
|
||
if value.tzinfo is None:
|
||
value = value.replace(tzinfo=timezone.utc)
|
||
return int(value.timestamp() * 1000)
|
||
raw = str(value or "").strip()
|
||
if not raw:
|
||
return int(datetime.now(timezone.utc).timestamp() * 1000)
|
||
if re.fullmatch(r"\d+(?:\.\d+)?", raw):
|
||
number = float(raw)
|
||
if number > 100_000_000_000:
|
||
return int(number)
|
||
return int(number * 1000)
|
||
iso_raw = raw[:-1] + "+00:00" if raw.endswith("Z") else raw
|
||
try:
|
||
parsed = datetime.fromisoformat(iso_raw)
|
||
except ValueError:
|
||
return int(datetime.now(timezone.utc).timestamp() * 1000)
|
||
if parsed.tzinfo is None:
|
||
parsed = parsed.replace(tzinfo=timezone.utc)
|
||
return int(parsed.timestamp() * 1000)
|
||
|
||
|
||
def _to_bool(value: Any) -> bool:
|
||
if isinstance(value, bool):
|
||
return value
|
||
text = str(value or "").strip().lower()
|
||
return text in {"1", "true", "t", "sim", "yes", "y"}
|
||
|
||
|
||
def _to_dict(data: Any) -> Any:
|
||
if isinstance(data, BaseModel):
|
||
return data.model_dump(by_alias=True)
|
||
if is_dataclass(data):
|
||
return _to_dict(asdict(data))
|
||
if isinstance(data, tuple):
|
||
return [_to_dict(item) for item in data]
|
||
if isinstance(data, list):
|
||
return [_to_dict(item) for item in data]
|
||
if isinstance(data, dict):
|
||
return {key: _to_dict(value) for key, value in data.items()}
|
||
return data
|
||
|
||
|
||
def _result_failed_or_missing_data(result: Any, *, state: dict[str, Any]) -> bool:
|
||
if not getattr(result, "success", False):
|
||
return True
|
||
if getattr(result, "data", None) is not None:
|
||
return False
|
||
input_state = state.get("input", {}) if isinstance(state, dict) else {}
|
||
if not isinstance(input_state, dict):
|
||
input_state = {}
|
||
emit_api_content_mismatch(
|
||
values={
|
||
"msisdn": str(input_state.get("msisdn", "") or "").strip(),
|
||
"invoice_id": str(
|
||
input_state.get("invoice_id")
|
||
or input_state.get("current_invoice_number")
|
||
or ""
|
||
).strip(),
|
||
"channel_id": str(input_state.get("channel_id", "") or "").strip(),
|
||
"ura_call_id": str(input_state.get("ura_call_id", "") or "").strip(),
|
||
},
|
||
reason="workflow_action_command_success_without_data",
|
||
command=str(getattr(result, "metadata", {}).get("command", "") or ""),
|
||
api_response_payload=getattr(result, "metadata", {}),
|
||
)
|
||
return True
|
||
|
||
|
||
def _runtime_llm_callbacks(runtime: WorkflowRuntimeContext) -> list[Any] | None:
|
||
callbacks = list(runtime.llm_callbacks)
|
||
return callbacks or None
|
||
|
||
|
||
def _runtime_llm_metadata(runtime: WorkflowRuntimeContext) -> dict[str, Any]:
|
||
return dict(runtime.llm_metadata)
|
||
|
||
|
||
def _service_from_dict(service: dict[str, Any] | None) -> ServiceInfo | None:
|
||
if service is None:
|
||
return None
|
||
return ServiceInfo(
|
||
msisdn=str(service.get("msisdn", "")),
|
||
app_id=str(service.get("app_id", "")),
|
||
csp_id=str(service.get("csp_id", "")),
|
||
ippid=str(service.get("ippid", "")),
|
||
service_name=str(service.get("service_name", "")),
|
||
status=str(service.get("status", "")),
|
||
extra=dict(service.get("extra", {})),
|
||
)
|
||
|
||
|
||
def _extract_amount(extra: dict[str, Any] | None) -> str:
|
||
if not isinstance(extra, dict):
|
||
return ""
|
||
details = extra.get("details", {})
|
||
if not isinstance(details, dict):
|
||
details = {}
|
||
return str(
|
||
extra.get("valor")
|
||
or extra.get("price")
|
||
or details.get("valor")
|
||
or details.get("price")
|
||
or ""
|
||
)
|
||
|
||
|
||
def _extract_boleto_code(extra: dict[str, Any] | None) -> str:
|
||
if not isinstance(extra, dict):
|
||
return ""
|
||
return str(
|
||
extra.get("codigo_boleto")
|
||
or extra.get("boleto_code")
|
||
or extra.get("linha_digitavel")
|
||
or ""
|
||
).strip()
|
||
|
||
|
||
def _find_matching_service(
|
||
requested_name: str,
|
||
active_services: dict[str, dict[str, Any]],
|
||
) -> dict[str, Any] | None:
|
||
lowered_name = requested_name.strip().lower()
|
||
exact = active_services.get(lowered_name)
|
||
if exact:
|
||
return exact
|
||
normalized_requested = _normalize_service_name(lowered_name)
|
||
for key, value in active_services.items():
|
||
if _normalize_service_name(key) == normalized_requested:
|
||
return value
|
||
return next(
|
||
(
|
||
value
|
||
for key, value in active_services.items()
|
||
if (
|
||
lowered_name in key
|
||
or key in lowered_name
|
||
or normalized_requested in _normalize_service_name(key)
|
||
or _normalize_service_name(key) in normalized_requested
|
||
)
|
||
),
|
||
None,
|
||
)
|
||
|
||
|
||
def _normalize_service_name(value: str) -> str:
|
||
text = ud.normalize("NFKD", str(value or ""))
|
||
text = "".join(ch for ch in text if not ud.combining(ch))
|
||
text = re.sub(r"[^a-zA-Z0-9]+", " ", text).strip().lower()
|
||
return re.sub(r"\s+", " ", text)
|
||
|
||
|
||
def _resolve_service_csp_id(
|
||
matched_service: dict[str, Any],
|
||
*,
|
||
fallback_csp_id: str,
|
||
) -> str:
|
||
if not isinstance(matched_service, dict):
|
||
return fallback_csp_id
|
||
context = matched_service.get("service_context")
|
||
if isinstance(context, dict):
|
||
csp_id = str(
|
||
context.get("csp_id")
|
||
or context.get("cspId")
|
||
or (
|
||
context.get("csp", {}).get("id")
|
||
if isinstance(context.get("csp"), dict)
|
||
else ""
|
||
)
|
||
or ""
|
||
).strip()
|
||
if csp_id:
|
||
return csp_id
|
||
return fallback_csp_id
|
||
|
||
|
||
def _normalize_amount(value: Any) -> str:
|
||
text = str(value or "").strip()
|
||
if not text:
|
||
return ""
|
||
|
||
text = (
|
||
text.replace("R$", "")
|
||
.replace("$", "")
|
||
.replace("\u00a0", "")
|
||
.strip()
|
||
)
|
||
text = text.replace("−", "-").replace("–", "-")
|
||
|
||
has_neg = text.startswith("-")
|
||
if has_neg:
|
||
text = text[1:]
|
||
else:
|
||
text = text.lstrip("+")
|
||
|
||
text = re.sub(r"\s+", "", text)
|
||
text = re.sub(r"[^0-9.,-]", "", text)
|
||
if not text:
|
||
return ""
|
||
|
||
has_any = ("." in text) or ("," in text)
|
||
if not has_any:
|
||
return f"{'-' if has_neg else ''}{text}"
|
||
|
||
if "." in text and "," in text:
|
||
last_dot = text.rfind(".")
|
||
last_comma = text.rfind(",")
|
||
if last_comma > last_dot:
|
||
int_part = text[:last_comma].replace(".", "")
|
||
frac_part = text[last_comma + 1 :]
|
||
else:
|
||
int_part = text[:last_dot].replace(",", "")
|
||
frac_part = text[last_dot + 1 :]
|
||
normalized = f"{'-' if has_neg else ''}{int_part}.{frac_part}"
|
||
return normalized
|
||
|
||
sep = "." if "." in text else ","
|
||
parts = text.split(sep)
|
||
if len(parts) > 2:
|
||
frac = parts[-1]
|
||
if len(frac) <= 2:
|
||
normalized = f"{'-' if has_neg else ''}{''.join(parts[:-1])}.{frac}"
|
||
else:
|
||
normalized = f"{'-' if has_neg else ''}{''.join(parts)}"
|
||
return normalized
|
||
|
||
int_part, frac_part = parts[0], parts[1]
|
||
if len(frac_part) <= 2:
|
||
normalized = (
|
||
f"{'-' if has_neg else ''}{int_part.replace(',', '').replace('.', '')}."
|
||
f"{frac_part}"
|
||
)
|
||
else:
|
||
normalized = f"{'-' if has_neg else ''}{int_part}{frac_part}"
|
||
return normalized
|
||
|
||
|
||
def _parse_amount(value: Any) -> Decimal | None:
|
||
normalized = _normalize_amount(value)
|
||
if not normalized:
|
||
return None
|
||
try:
|
||
return Decimal(normalized)
|
||
except (InvalidOperation, ValueError):
|
||
return None
|
||
|
||
|
||
def _format_amount(value: Decimal | None) -> str:
|
||
if value is None:
|
||
return ""
|
||
return f"{value:.2f}".replace(".", ",")
|
||
|
||
|
||
def _build_sr_notes(
|
||
*,
|
||
sms_enviado: bool,
|
||
codigo_boleto: str,
|
||
data_credito_proxima_fatura: str,
|
||
) -> str:
|
||
if sms_enviado:
|
||
return (
|
||
f"SMS com código do boleto enviado. Código: {codigo_boleto}."
|
||
if codigo_boleto
|
||
else "SMS com código do boleto enviado."
|
||
)
|
||
if data_credito_proxima_fatura:
|
||
return (
|
||
"Crédito do valor do serviço cancelado registrado "
|
||
f"na próxima fatura com vencimento em {data_credito_proxima_fatura}."
|
||
)
|
||
return "Crédito do valor do serviço cancelado registrado na próxima fatura."
|
||
|
||
|
||
def _format_list_pt_br(values: list[str]) -> str:
|
||
cleaned = [str(item).strip() for item in values if str(item).strip()]
|
||
if not cleaned:
|
||
return ""
|
||
if len(cleaned) == 1:
|
||
return cleaned[0]
|
||
if len(cleaned) == 2:
|
||
return f"{cleaned[0]} e {cleaned[1]}"
|
||
return f"{', '.join(cleaned[:-1])} e {cleaned[-1]}"
|
||
|
||
|
||
def _final_msisdn(msisdn: str) -> str:
|
||
digits = re.sub(r"\D", "", msisdn)
|
||
if len(digits) >= 4:
|
||
return digits[-4:]
|
||
return msisdn[-4:] if len(msisdn) >= 4 else msisdn
|
||
|
||
|
||
def _first_text_from_params_or_state(
|
||
state: dict[str, Any],
|
||
params: dict[str, Any],
|
||
*keys: str,
|
||
) -> str:
|
||
input_state = state.get("input", {}) if isinstance(state, dict) else {}
|
||
sources: list[dict[str, Any]] = []
|
||
if isinstance(params, dict):
|
||
sources.append(params)
|
||
if isinstance(input_state, dict):
|
||
sources.append(input_state)
|
||
|
||
for source in sources:
|
||
for key in keys:
|
||
value = source.get(key)
|
||
if value is None:
|
||
continue
|
||
text = str(value).strip()
|
||
if text:
|
||
return text
|
||
return ""
|
||
|
||
|
||
def _first_value_from_params_or_state(
|
||
state: dict[str, Any],
|
||
params: dict[str, Any],
|
||
*keys: str,
|
||
) -> Any:
|
||
input_state = state.get("input", {}) if isinstance(state, dict) else {}
|
||
sources: list[dict[str, Any]] = []
|
||
if isinstance(params, dict):
|
||
sources.append(params)
|
||
if isinstance(input_state, dict):
|
||
sources.append(input_state)
|
||
|
||
for source in sources:
|
||
for key in keys:
|
||
if key not in source:
|
||
continue
|
||
value = source.get(key)
|
||
if value is None:
|
||
continue
|
||
if isinstance(value, str):
|
||
text = value.strip()
|
||
if text:
|
||
return text
|
||
continue
|
||
return value
|
||
return None
|
||
|
||
|
||
def _contains_any_keyword(value: str, keywords: tuple[str, ...]) -> bool:
|
||
normalized = str(value or "").strip().lower()
|
||
if not normalized:
|
||
return False
|
||
return any(keyword in normalized for keyword in keywords)
|
||
|
||
|
||
def _normalize_number_text(value: Any, *, default: str = "0") -> str:
|
||
text = str(value).strip()
|
||
if not text:
|
||
return default
|
||
cleaned = text.replace("R$", "").replace(" ", "")
|
||
if "," in cleaned:
|
||
cleaned = cleaned.replace(".", "").replace(",", ".")
|
||
try:
|
||
normalized = format(Decimal(cleaned), "f")
|
||
except (InvalidOperation, ValueError):
|
||
return default
|
||
if "." in normalized:
|
||
normalized = normalized.rstrip("0").rstrip(".")
|
||
return normalized or default
|
||
|
||
|
||
def _normalize_bool(value: Any, *, default: bool = False) -> bool:
|
||
if isinstance(value, bool):
|
||
return value
|
||
if isinstance(value, (int, float)):
|
||
return bool(value)
|
||
text = str(value).strip().lower()
|
||
if not text:
|
||
return default
|
||
if text in {"true", "1", "sim", "yes", "y"}:
|
||
return True
|
||
if text in {"false", "0", "nao", "não", "no", "n"}:
|
||
return False
|
||
return default
|
||
|
||
|
||
def _map_customer_status_code(value: Any) -> str:
|
||
text = str(value).strip().lower()
|
||
if not text:
|
||
return ""
|
||
if text in {"0", "1", "2", "3"}:
|
||
return text
|
||
if "inativ" in text:
|
||
return "0"
|
||
if "ativ" in text:
|
||
return "1"
|
||
if "suspens" in text:
|
||
return "2"
|
||
if "bloque" in text:
|
||
return "3"
|
||
return ""
|
||
|
||
|
||
def _text_from_keys(source: Any, keys: tuple[str, ...]) -> str:
|
||
if not isinstance(source, dict):
|
||
return ""
|
||
return " ".join(str(source.get(key) or "") for key in keys).strip()
|
||
|
||
|
||
__all__ = [
|
||
'_emit_vaa',
|
||
'_build_ic_context',
|
||
'_emit_ic',
|
||
'_idempotency_get',
|
||
'_idempotency_set',
|
||
'_utc_now_iso',
|
||
'_to_epoch_millis',
|
||
'_to_bool',
|
||
'_to_dict',
|
||
'_result_failed_or_missing_data',
|
||
'_runtime_llm_callbacks',
|
||
'_runtime_llm_metadata',
|
||
'_service_from_dict',
|
||
'_extract_amount',
|
||
'_extract_boleto_code',
|
||
'_find_matching_service',
|
||
'_normalize_service_name',
|
||
'_resolve_service_csp_id',
|
||
'_parse_amount',
|
||
'_format_amount',
|
||
'_build_sr_notes',
|
||
'_format_list_pt_br',
|
||
'_final_msisdn',
|
||
'_first_text_from_params_or_state',
|
||
'_first_value_from_params_or_state',
|
||
'_contains_any_keyword',
|
||
'_normalize_number_text',
|
||
'_normalize_bool',
|
||
'_map_customer_status_code',
|
||
'_text_from_keys',
|
||
]
|