Release v4928.1.4.2 stable

This commit is contained in:
2026-06-09 22:55:58 +01:00
commit 6445044ac6
280 changed files with 41775 additions and 0 deletions

View File

@@ -0,0 +1,592 @@
import json
import os
from typing import Any, Dict, List, Optional
from sqlalchemy import text
from app.db import engine
def _json(value: Any) -> str:
return json.dumps(value or {}, ensure_ascii=False)
def create_outbox_item(
*,
business_event_id: str,
target_system: str,
action_type: str,
payload: Dict[str, Any],
idempotency_key: str,
) -> Optional[str]:
sql = text("""
INSERT INTO integration_outbox (
business_event_id,
target_system,
action_type,
payload,
status,
retry_count,
idempotency_key
)
VALUES (
CAST(:business_event_id AS UUID),
:target_system,
:action_type,
CAST(:payload AS JSONB),
'pending',
0,
:idempotency_key
)
ON CONFLICT (idempotency_key) DO NOTHING
RETURNING id::text
""")
with engine.begin() as conn:
row = conn.execute(sql, {
"business_event_id": business_event_id,
"target_system": target_system,
"action_type": action_type,
"payload": _json(payload),
"idempotency_key": idempotency_key,
}).fetchone()
return row[0] if row else None
def build_outbox_specs(
*,
business_event_id: str,
event_type: str,
task: Dict[str, Any],
payload: Dict[str, Any],
) -> List[Dict[str, Any]]:
"""Constrói ações de outbox para integrações externas ativas.
A integração CRM externa antiga foi removida. O pipeline comercial passa a viver no
ClientFlow: oportunidades, produtos, financeiro e encomendas são entidades
internas. Esta função já não cria novos itens para o CRM externo antigo.
"""
base_payload = {
"business_event_id": business_event_id,
"event_type": event_type,
"task_id": task.get("id"),
"conversation_id": task.get("conversation_id"),
"contact_id": task.get("contact_id"),
"action_code": task.get("action_code"),
"route": task.get("route"),
"action": task.get("action"),
"note": task.get("note"),
"event_payload": payload or {},
}
if event_type == "invoice_sent":
return [
{
"target_system": "chatwoot",
"action_type": "add_private_note",
"payload": {**base_payload, "note": "Fatura marcada como enviada no ClientFlow."},
},
{
"target_system": "mautic",
"action_type": "add_tag",
"payload": {**base_payload, "tag": "invoice_sent"},
},
]
if event_type == "payment_confirmed":
return [
{
"target_system": "mautic",
"action_type": "add_tag",
"payload": {**base_payload, "tag": "payment_confirmed"},
},
{
"target_system": "mautic",
"action_type": "remove_tag",
"payload": {**base_payload, "tag": "proforma_unpaid"},
},
]
if event_type == "order_prepared":
return [
{
"target_system": "chatwoot",
"action_type": "add_private_note",
"payload": {**base_payload, "note": "Encomenda preparada no ClientFlow."},
},
]
if event_type == "shipment_validated":
return [
{
"target_system": "chatwoot",
"action_type": "add_private_note",
"payload": {**base_payload, "note": "Envio validado no ClientFlow."},
},
{
"target_system": "mautic",
"action_type": "add_tag",
"payload": {**base_payload, "tag": "shipment_validated"},
},
]
return []
def create_outbox_for_business_event(
*,
business_event_id: str,
event_type: str,
task: Dict[str, Any],
payload: Optional[Dict[str, Any]] = None,
) -> List[str]:
specs = build_outbox_specs(
business_event_id=business_event_id,
event_type=event_type,
task=task,
payload=payload or {},
)
created_ids: List[str] = []
for spec in specs:
idempotency_key = ":".join([
"outbox",
business_event_id,
spec["target_system"],
spec["action_type"],
])
outbox_id = create_outbox_item(
business_event_id=business_event_id,
target_system=spec["target_system"],
action_type=spec["action_type"],
payload=spec["payload"],
idempotency_key=idempotency_key,
)
if outbox_id:
created_ids.append(outbox_id)
return created_ids
def list_outbox(
*,
status: Optional[str] = None,
target_system: Optional[str] = None,
limit: int = 100,
) -> List[Dict[str, Any]]:
where = []
params: Dict[str, Any] = {"limit": limit}
if status:
where.append("status = :status")
params["status"] = status
if target_system:
where.append("target_system = :target_system")
params["target_system"] = target_system
where_sql = ""
if where:
where_sql = "WHERE " + " AND ".join(where)
sql = text(f"""
SELECT
id::text,
business_event_id::text,
target_system,
action_type,
payload,
status,
retry_count,
idempotency_key,
last_error,
created_at,
updated_at,
sent_at,
locked_at,
lock_owner,
ignored_at
FROM integration_outbox
{where_sql}
ORDER BY created_at DESC
LIMIT :limit
""")
with engine.begin() as conn:
rows = conn.execute(sql, params).mappings().all()
return [dict(row) for row in rows]
def get_outbox_item(outbox_id: str) -> Optional[Dict[str, Any]]:
sql = text("""
SELECT
id::text,
business_event_id::text,
target_system,
action_type,
payload,
status,
retry_count,
idempotency_key,
last_error,
created_at,
updated_at,
sent_at,
locked_at,
lock_owner,
ignored_at
FROM integration_outbox
WHERE id = CAST(:outbox_id AS UUID)
LIMIT 1
""")
with engine.begin() as conn:
row = conn.execute(sql, {"outbox_id": outbox_id}).mappings().first()
return dict(row) if row else None
def list_pending_outbox(
limit: int = 50,
target_system: Optional[str] = None,
) -> List[Dict[str, Any]]:
return list_outbox(
status="pending",
target_system=target_system,
limit=limit,
)
def claim_pending_outbox(
*,
limit: int = 50,
target_system: Optional[str] = None,
lock_owner: str = "worker",
) -> List[Dict[str, Any]]:
"""Claim pending outbox rows atomically for one worker.
This prevents two systemd timers/workers from processing the same pending
integration item at the same time. PostgreSQL SKIP LOCKED lets concurrent
workers take different rows without blocking each other.
"""
sql = text("""
WITH picked AS (
SELECT id
FROM integration_outbox
WHERE status = 'pending'
AND (:target_system IS NULL OR target_system = :target_system)
ORDER BY created_at ASC
FOR UPDATE SKIP LOCKED
LIMIT :limit
)
UPDATE integration_outbox io
SET
status = 'processing',
locked_at = now(),
lock_owner = :lock_owner,
updated_at = now(),
last_error = NULL
FROM picked
WHERE io.id = picked.id
RETURNING
io.id::text,
io.business_event_id::text,
io.target_system,
io.action_type,
io.payload,
io.status,
io.retry_count,
io.idempotency_key,
io.last_error,
io.created_at,
io.updated_at,
io.sent_at,
io.locked_at,
io.lock_owner
""")
with engine.begin() as conn:
rows = conn.execute(sql, {
"limit": int(limit),
"target_system": target_system,
"lock_owner": str(lock_owner or "worker")[:120],
}).mappings().all()
return [dict(row) for row in rows]
def outbox_stale_minutes() -> int:
"""Configured threshold for stuck processing rows."""
raw = os.getenv("OUTBOX_STALE_PROCESSING_MINUTES", "30").strip()
try:
return max(1, int(raw))
except ValueError:
return 30
def recover_stale_processing_outbox(
*,
stale_minutes: Optional[int] = None,
mode: Optional[str] = None,
limit: int = 100,
actor: str = "system",
) -> List[Dict[str, Any]]:
"""Recover or expose outbox items left in processing too long.
Modes:
- manual_only: mark rows as ``stale`` so the operator can decide;
- mark_failed: mark rows as ``failed`` with a stale-processing reason;
- retry_pending: return rows to ``pending`` so the worker retries them.
"""
stale_minutes = int(stale_minutes or outbox_stale_minutes())
mode = str(mode or os.getenv("OUTBOX_STALE_RECOVERY_MODE", "manual_only")).strip().lower()
if mode not in {"manual_only", "mark_failed", "retry_pending"}:
mode = "manual_only"
if mode == "retry_pending":
new_status = "pending"
retry_sql = "retry_count = retry_count + 1,"
error = f"Processing stale há mais de {stale_minutes} minutos; reposto para pending por {actor}."
elif mode == "mark_failed":
new_status = "failed"
retry_sql = "retry_count = retry_count + 1,"
error = f"Processing stale há mais de {stale_minutes} minutos; marcado failed por {actor}."
else:
new_status = "stale"
retry_sql = ""
error = f"Processing stale há mais de {stale_minutes} minutos; requer revisão manual."
sql = text(f"""
WITH picked AS (
SELECT id
FROM integration_outbox
WHERE status = 'processing'
AND locked_at IS NOT NULL
AND locked_at < now() - (:stale_minutes * interval '1 minute')
ORDER BY locked_at ASC
FOR UPDATE SKIP LOCKED
LIMIT :limit
)
UPDATE integration_outbox io
SET
status = :new_status,
{retry_sql}
locked_at = NULL,
lock_owner = NULL,
last_error = :error,
updated_at = now()
FROM picked
WHERE io.id = picked.id
RETURNING
io.id::text,
io.business_event_id::text,
io.target_system,
io.action_type,
io.payload,
io.status,
io.retry_count,
io.idempotency_key,
io.last_error,
io.created_at,
io.updated_at,
io.sent_at,
io.locked_at,
io.lock_owner,
io.ignored_at
""")
with engine.begin() as conn:
rows = conn.execute(sql, {
"stale_minutes": stale_minutes,
"limit": int(limit),
"new_status": new_status,
"error": error[:2000],
}).mappings().all()
recovered = [dict(row) for row in rows]
if recovered:
try:
from app.operator_audit_service import record_operator_action_best_effort
for item in recovered:
record_operator_action_best_effort(
action="outbox_stale_recovered",
entity_type="outbox",
entity_id=item.get("id"),
actor=actor,
payload={
"mode": mode,
"stale_minutes": stale_minutes,
"new_status": item.get("status"),
"target_system": item.get("target_system"),
"action_type": item.get("action_type"),
},
)
except Exception as exc:
print(f"ClientFlow stale outbox audit failed: {exc}", flush=True)
return recovered
def mark_outbox_sent(outbox_id: str) -> None:
with engine.begin() as conn:
conn.execute(text("""
UPDATE integration_outbox
SET
status = 'sent',
sent_at = now(),
locked_at = NULL,
lock_owner = NULL,
updated_at = now(),
last_error = NULL
WHERE id = CAST(:outbox_id AS UUID)
"""), {"outbox_id": outbox_id})
def mark_outbox_failed(outbox_id: str, error: str) -> None:
with engine.begin() as conn:
conn.execute(text("""
UPDATE integration_outbox
SET
status = 'failed',
retry_count = retry_count + 1,
locked_at = NULL,
lock_owner = NULL,
last_error = :error,
updated_at = now()
WHERE id = CAST(:outbox_id AS UUID)
"""), {
"outbox_id": outbox_id,
"error": error[:2000],
})
def set_outbox_status(
*,
outbox_id: str,
status: str,
error: str | None = None,
) -> None:
"""Atualiza o estado de um item da outbox com semântica operacional clara."""
allowed = {"pending", "processing", "sent", "failed", "blocked", "dry_run", "ignored", "cancelled", "stale"}
if status not in allowed:
raise ValueError(f"Estado inválido: {status}")
status_defaults = {
"failed": "Marcado manualmente como failed.",
"blocked": "Bloqueado por configuração ou pré-condição.",
"dry_run": "Validado em OUTBOX_DRY_RUN=true; nenhuma integração real foi executada.",
"ignored": "Ignorado manualmente.",
"cancelled": "Cancelado manualmente.",
"stale": "Processing preso; requer revisão ou reprocessamento manual.",
}
if status == "sent":
sql = text("""
UPDATE integration_outbox
SET
status = 'sent',
sent_at = COALESCE(sent_at, now()),
locked_at = NULL,
lock_owner = NULL,
last_error = NULL,
updated_at = now()
WHERE id = CAST(:outbox_id AS UUID)
""")
params = {"outbox_id": outbox_id}
elif status == "pending":
sql = text("""
UPDATE integration_outbox
SET
status = 'pending',
sent_at = NULL,
locked_at = NULL,
lock_owner = NULL,
ignored_at = NULL,
last_error = NULL,
updated_at = now()
WHERE id = CAST(:outbox_id AS UUID)
""")
params = {"outbox_id": outbox_id}
elif status == "processing":
sql = text("""
UPDATE integration_outbox
SET
status = 'processing',
locked_at = now(),
lock_owner = COALESCE(:error, 'worker'),
updated_at = now()
WHERE id = CAST(:outbox_id AS UUID)
""")
params = {"outbox_id": outbox_id, "error": error}
else:
ignored_at_sql = "ignored_at = now()," if status == "ignored" else "ignored_at = ignored_at,"
retry_sql = "retry_count = retry_count + 1," if status == "failed" else ""
sql = text(f"""
UPDATE integration_outbox
SET
status = :status,
{retry_sql}
sent_at = NULL,
locked_at = NULL,
lock_owner = NULL,
{ignored_at_sql}
last_error = :error,
updated_at = now()
WHERE id = CAST(:outbox_id AS UUID)
""")
params = {
"outbox_id": outbox_id,
"status": status,
"error": (error or status_defaults.get(status) or "Estado atualizado.")[:2000],
}
with engine.begin() as conn:
conn.execute(sql, params)
def mark_outbox_dry_run(outbox_id: str, message: str | None = None) -> None:
set_outbox_status(
outbox_id=outbox_id,
status="dry_run",
error=message or "OUTBOX_DRY_RUN=true; ação não executada na integração externa.",
)
def mark_outbox_blocked(outbox_id: str, message: str | None = None) -> None:
set_outbox_status(
outbox_id=outbox_id,
status="blocked",
error=message or "Integração desativada ou configuração incompleta.",
)
def get_outbox_item(outbox_id: str) -> Optional[Dict[str, Any]]:
sql = text("""
SELECT
id::text,
business_event_id::text,
target_system,
action_type,
payload,
status,
retry_count,
idempotency_key,
last_error,
created_at,
updated_at,
sent_at,
locked_at,
lock_owner,
ignored_at
FROM integration_outbox
WHERE id = CAST(:outbox_id AS UUID)
""")
with engine.begin() as conn:
row = conn.execute(sql, {"outbox_id": outbox_id}).mappings().first()
return dict(row) if row else None