Import ClientFlow production v4928.1.5.132.4
This commit is contained in:
407
app/opportunity_action_task_materializer.py
Normal file
407
app/opportunity_action_task_materializer.py
Normal file
@@ -0,0 +1,407 @@
|
||||
"""Materialize central opportunity next actions into operator tasks.
|
||||
|
||||
The workflow engine can decide that the next step is a human action even when no
|
||||
message-triggered task exists. Operator pages should not leave those actions as
|
||||
abstract labels only: a pending task must exist so the workbench, related-task
|
||||
list and task completion flow remain consistent.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import uuid
|
||||
from typing import Any, Mapping
|
||||
|
||||
from sqlalchemy import text
|
||||
|
||||
from app.db import engine
|
||||
from app.task_service import get_action_config
|
||||
from app.work_center_action_policy import (
|
||||
RECONSTRUCTED_SENSITIVE_ACTIONS,
|
||||
canonical_action_code,
|
||||
reconstructed_review_required,
|
||||
)
|
||||
|
||||
|
||||
# v4928.1.5.96 marker: MATERIALIZED_ACTIONS = {"SEND_INVOICE"}
|
||||
# v4928.1.5.105 marker: MATERIALIZED_ACTIONS = {"SEND_INVOICE", "FOLLOW_UP_PAYMENT"}
|
||||
MATERIALIZED_ACTIONS = {"SEND_INVOICE", "FOLLOW_UP_PAYMENT", "PREPARE_ORDER"}
|
||||
# v4928.1.5.129: central workflow emits SHIP_ORDER; operator tasks persist CREATE_SHIPMENT.
|
||||
MATERIALIZED_ACTIONS.add("CREATE_SHIPMENT")
|
||||
MATERIALIZED_ACTIONS.add("VALIDATE_PHYSICAL_ORDER")
|
||||
MATERIALIZED_ACTIONS.add("REVIEW_RECONSTRUCTED_PROCESS")
|
||||
ACTION_ALIASES = {"SHIP_ORDER": "CREATE_SHIPMENT"}
|
||||
|
||||
|
||||
def _s(value: Any) -> str:
|
||||
return str(value or "").strip()
|
||||
|
||||
|
||||
def _json(value: Any) -> str:
|
||||
return json.dumps(value or {}, ensure_ascii=False, default=str)
|
||||
|
||||
|
||||
def ensure_pending_task_for_next_action(
|
||||
opportunity_id: str,
|
||||
next_action: Mapping[str, Any] | None,
|
||||
*,
|
||||
source: str = "opportunity_next_action",
|
||||
actor: str = "system",
|
||||
reactivate_skipped: bool = False,
|
||||
) -> dict[str, Any]:
|
||||
"""Ensure a pending task exists for a materialized next action.
|
||||
|
||||
Returns a small status dictionary. The function is idempotent by both a
|
||||
pending-task lookup and a stable idempotency key.
|
||||
"""
|
||||
|
||||
if not opportunity_id or not next_action:
|
||||
return {"created": False, "reason": "missing_input"}
|
||||
|
||||
raw_action_code = canonical_action_code(next_action.get("action_code") or next_action.get("code"))
|
||||
document_id = _s(next_action.get("document_id"))
|
||||
document_number = _s(next_action.get("document_number"))
|
||||
document_key = document_id or document_number or "no_document"
|
||||
|
||||
with engine.begin() as conn:
|
||||
opp = conn.execute(text("""
|
||||
SELECT
|
||||
id::text,
|
||||
conversation_id,
|
||||
contact_id,
|
||||
customer_id,
|
||||
local_customer_id::text AS local_customer_id,
|
||||
customer_email,
|
||||
title,
|
||||
COALESCE(metadata, '{}'::jsonb) AS metadata
|
||||
FROM opportunities
|
||||
WHERE id = CAST(:opportunity_id AS UUID)
|
||||
"""), {"opportunity_id": opportunity_id}).mappings().first()
|
||||
if not opp:
|
||||
return {"created": False, "reason": "opportunity_not_found", "action_code": raw_action_code}
|
||||
|
||||
blocked_action_code = ""
|
||||
action_code = raw_action_code
|
||||
sensitive_codes = {canonical_action_code(code) for code in RECONSTRUCTED_SENSITIVE_ACTIONS}
|
||||
if reconstructed_review_required(opp.get("metadata")) and action_code in sensitive_codes:
|
||||
blocked_action_code = action_code
|
||||
action_code = "REVIEW_RECONSTRUCTED_PROCESS"
|
||||
|
||||
if action_code not in MATERIALIZED_ACTIONS:
|
||||
return {"created": False, "reason": "action_not_materialized", "action_code": action_code}
|
||||
|
||||
existing = conn.execute(text("""
|
||||
SELECT id::text
|
||||
FROM tasks
|
||||
WHERE opportunity_id = CAST(:opportunity_id AS UUID)
|
||||
AND status = 'pending'
|
||||
AND action_code = :action_code
|
||||
ORDER BY created_at DESC
|
||||
LIMIT 1
|
||||
"""), {
|
||||
"opportunity_id": opportunity_id,
|
||||
"action_code": action_code,
|
||||
}).scalar()
|
||||
if existing:
|
||||
return {"created": False, "reason": "pending_exists", "task_id": existing, "action_code": action_code}
|
||||
|
||||
# v4928.1.5.132.2.1: build the canonical task payload before either
|
||||
# reactivating a terminal task or inserting a new one. The v132.2
|
||||
# pre-insert branch referenced these values before assignment.
|
||||
config = get_action_config(action_code)
|
||||
default_routes = {
|
||||
"SEND_INVOICE": "financeiro",
|
||||
"FOLLOW_UP_PAYMENT": "financeiro",
|
||||
"PREPARE_ORDER": "operacoes",
|
||||
"VALIDATE_PHYSICAL_ORDER": "logistica",
|
||||
"CREATE_SHIPMENT": "logistica",
|
||||
"REVIEW_RECONSTRUCTED_PROCESS": "rever",
|
||||
}
|
||||
route = _s(config.get("route")) or default_routes.get(action_code, "financeiro")
|
||||
if action_code == "PREPARE_ORDER" and route in {"", "rever", "vendas"}:
|
||||
route = "operacoes"
|
||||
if action_code in {"VALIDATE_PHYSICAL_ORDER", "CREATE_SHIPMENT"} and route in {"", "rever", "vendas", "financeiro"}:
|
||||
route = "logistica"
|
||||
if action_code == "REVIEW_RECONSTRUCTED_PROCESS":
|
||||
route = "rever"
|
||||
|
||||
default_labels = {
|
||||
"SEND_INVOICE": "Enviar fatura ao cliente",
|
||||
"FOLLOW_UP_PAYMENT": "Follow-up pagamento",
|
||||
"PREPARE_ORDER": "Preparar encomenda / Odoo",
|
||||
"VALIDATE_PHYSICAL_ORDER": "Validar encomenda física",
|
||||
"CREATE_SHIPMENT": "Enviar encomenda",
|
||||
"REVIEW_RECONSTRUCTED_PROCESS": "Validar processo reconstruído",
|
||||
}
|
||||
default_descriptions = {
|
||||
"SEND_INVOICE": "Fatura criada/associada. Enviar PDF ao cliente e registar evidência.",
|
||||
"FOLLOW_UP_PAYMENT": "Encomenda concluída no Odoo/WH-OUT e fatura enviada. Acompanhar pagamento pós-entrega.",
|
||||
"PREPARE_ORDER": "Fatura e pagamento confirmados. Criar/validar venda Odoo e preparação da encomenda.",
|
||||
"VALIDATE_PHYSICAL_ORDER": "O picking está assigned/reservado no Odoo. Confirmar fisicamente a preparação antes de criar o envio.",
|
||||
"CREATE_SHIPMENT": "Encomenda fisicamente validada. Criar envio/tracking ou registar a expedição pelo canal disponível.",
|
||||
"REVIEW_RECONSTRUCTED_PROCESS": "Confirmar cliente, documento principal, valor e evidências antes de executar a ação sensível bloqueada.",
|
||||
}
|
||||
default_label = default_labels.get(action_code, action_code)
|
||||
default_description = default_descriptions.get(action_code, "Executar próxima ação operacional.")
|
||||
if action_code == "REVIEW_RECONSTRUCTED_PROCESS":
|
||||
action_label = _s(config.get("action") or default_label)
|
||||
description = default_description
|
||||
else:
|
||||
action_label = _s(next_action.get("label") or config.get("action") or default_label)
|
||||
description = _s(next_action.get("description")) or default_description
|
||||
priority = _s(next_action.get("priority")) or "alta"
|
||||
if priority not in {"alta", "normal", "baixa"}:
|
||||
priority = "alta"
|
||||
|
||||
idempotency_key = f"task:next_action:{opportunity_id}:{action_code}:{document_key}"
|
||||
task_id = str(uuid.uuid4())
|
||||
metadata = {
|
||||
"source": source,
|
||||
"actor": actor,
|
||||
"materialized_from_next_action": True,
|
||||
"document_id": document_id or None,
|
||||
"document_number": document_number or None,
|
||||
"next_action": dict(next_action),
|
||||
"raw_action_code": raw_action_code,
|
||||
"normalized_action_code": action_code,
|
||||
"blocked_action_code": blocked_action_code or None,
|
||||
"review_type": "reconstructed_process" if action_code == "REVIEW_RECONSTRUCTED_PROCESS" else None,
|
||||
}
|
||||
|
||||
# v4928.1.5.132.2: when an explicit blocker has just been cleared,
|
||||
# reactivate the stable materialized task before attempting an INSERT.
|
||||
# This avoids depending on the ON CONFLICT branch and makes the transition
|
||||
# observable even when an operator previously skipped the task while it
|
||||
# was superseded by a review task.
|
||||
allowed_reactivation_sources = {
|
||||
"reconstructed_review_completion",
|
||||
"physical_validation_completion",
|
||||
"manual_v132_2_reactivation_repair",
|
||||
}
|
||||
if reactivate_skipped and source in allowed_reactivation_sources:
|
||||
terminal = conn.execute(text("""
|
||||
SELECT id::text, status, action_code, COALESCE(metadata, '{}'::jsonb) AS metadata
|
||||
FROM tasks
|
||||
WHERE opportunity_id = CAST(:opportunity_id AS UUID)
|
||||
AND action_code = :action_code
|
||||
AND status IN ('skipped', 'ignored')
|
||||
AND COALESCE((metadata->>'materialized_from_next_action')::boolean, FALSE) = TRUE
|
||||
ORDER BY created_at DESC
|
||||
LIMIT 1
|
||||
FOR UPDATE
|
||||
"""), {
|
||||
"opportunity_id": opportunity_id,
|
||||
"action_code": action_code,
|
||||
}).mappings().first()
|
||||
if terminal:
|
||||
reactivation_metadata = {
|
||||
**metadata,
|
||||
"reactivated_after_blocker": True,
|
||||
"reactivated_from_status": terminal.get("status"),
|
||||
"reactivated_by": actor,
|
||||
"reactivated_source": source,
|
||||
"reactivated_version": "v4928.1.5.132.2.1",
|
||||
}
|
||||
reactivated = conn.execute(text("""
|
||||
UPDATE tasks
|
||||
SET status = 'pending',
|
||||
route = :route,
|
||||
action = :action,
|
||||
note = :note,
|
||||
action_required = TRUE,
|
||||
safe_to_post = FALSE,
|
||||
source_system = 'clientflow_next_action',
|
||||
source_event_id = :source_event_id,
|
||||
due_at = now(),
|
||||
done_at = NULL,
|
||||
done_by = NULL,
|
||||
priority = :priority,
|
||||
metadata = COALESCE(metadata, '{}'::jsonb) || CAST(:metadata AS JSONB),
|
||||
updated_at = now()
|
||||
WHERE id = CAST(:task_id AS UUID)
|
||||
AND status IN ('skipped', 'ignored')
|
||||
RETURNING id::text
|
||||
"""), {
|
||||
"task_id": terminal.get("id"),
|
||||
"route": route,
|
||||
"action": action_label,
|
||||
"note": description,
|
||||
"source_event_id": f"{source}:{opportunity_id}:{action_code}:{document_key}:reactivated",
|
||||
"priority": priority,
|
||||
"metadata": _json(reactivation_metadata),
|
||||
}).scalar()
|
||||
if reactivated:
|
||||
conn.execute(text("""
|
||||
INSERT INTO task_events (task_id, event_type, payload, created_by)
|
||||
VALUES (CAST(:task_id AS UUID), 'task_reactivated', CAST(:payload AS JSONB), :created_by)
|
||||
"""), {
|
||||
"task_id": reactivated,
|
||||
"payload": _json({
|
||||
"action_code": action_code,
|
||||
"reason": "explicit_blocker_cleared_preinsert",
|
||||
"source": source,
|
||||
"previous_status": terminal.get("status"),
|
||||
"version": "v4928.1.5.132.2.1",
|
||||
}),
|
||||
"created_by": actor,
|
||||
})
|
||||
return {
|
||||
"created": False,
|
||||
"reactivated": True,
|
||||
"reason": "reactivated_after_blocker_preinsert",
|
||||
"task_id": reactivated,
|
||||
"action_code": action_code,
|
||||
}
|
||||
|
||||
|
||||
|
||||
row = conn.execute(text("""
|
||||
INSERT INTO tasks (
|
||||
id,
|
||||
opportunity_id,
|
||||
conversation_id,
|
||||
contact_id,
|
||||
customer_id,
|
||||
action_code,
|
||||
route,
|
||||
action,
|
||||
note,
|
||||
action_required,
|
||||
safe_to_post,
|
||||
status,
|
||||
source_system,
|
||||
source_event_id,
|
||||
idempotency_key,
|
||||
due_at,
|
||||
metadata,
|
||||
priority,
|
||||
created_at,
|
||||
updated_at
|
||||
) VALUES (
|
||||
CAST(:id AS UUID),
|
||||
CAST(:opportunity_id AS UUID),
|
||||
:conversation_id,
|
||||
:contact_id,
|
||||
:customer_id,
|
||||
:action_code,
|
||||
:route,
|
||||
:action,
|
||||
:note,
|
||||
TRUE,
|
||||
FALSE,
|
||||
'pending',
|
||||
'clientflow_next_action',
|
||||
:source_event_id,
|
||||
:idempotency_key,
|
||||
now(),
|
||||
CAST(:metadata AS JSONB),
|
||||
:priority,
|
||||
now(),
|
||||
now()
|
||||
)
|
||||
ON CONFLICT (idempotency_key) DO NOTHING
|
||||
RETURNING id::text
|
||||
"""), {
|
||||
"id": task_id,
|
||||
"opportunity_id": opportunity_id,
|
||||
"conversation_id": opp.get("conversation_id"),
|
||||
"contact_id": opp.get("contact_id"),
|
||||
"customer_id": opp.get("local_customer_id") or opp.get("customer_id") or opp.get("customer_email"),
|
||||
"action_code": action_code,
|
||||
"route": route,
|
||||
"action": action_label,
|
||||
"note": description,
|
||||
"source_event_id": f"{source}:{opportunity_id}:{action_code}:{document_key}",
|
||||
"idempotency_key": idempotency_key,
|
||||
"metadata": _json(metadata),
|
||||
"priority": priority,
|
||||
}).fetchone()
|
||||
|
||||
created_id = row[0] if row else None
|
||||
if not created_id:
|
||||
existing_after_conflict = conn.execute(text("""
|
||||
SELECT id::text, status, action_code, COALESCE(metadata, '{}'::jsonb) AS metadata
|
||||
FROM tasks
|
||||
WHERE idempotency_key = :idempotency_key
|
||||
LIMIT 1
|
||||
FOR UPDATE
|
||||
"""), {"idempotency_key": idempotency_key}).mappings().first()
|
||||
if (
|
||||
reactivate_skipped
|
||||
and existing_after_conflict
|
||||
and _s(existing_after_conflict.get("status")).lower() in {"skipped", "ignored"}
|
||||
and canonical_action_code(existing_after_conflict.get("action_code")) == action_code
|
||||
):
|
||||
reactivated = conn.execute(text("""
|
||||
UPDATE tasks
|
||||
SET status = 'pending',
|
||||
route = :route,
|
||||
action = :action,
|
||||
note = :note,
|
||||
action_required = TRUE,
|
||||
safe_to_post = FALSE,
|
||||
source_system = 'clientflow_next_action',
|
||||
source_event_id = :source_event_id,
|
||||
due_at = now(),
|
||||
done_at = NULL,
|
||||
done_by = NULL,
|
||||
priority = :priority,
|
||||
metadata = COALESCE(metadata, '{}'::jsonb) || CAST(:metadata AS JSONB),
|
||||
updated_at = now()
|
||||
WHERE id = CAST(:task_id AS UUID)
|
||||
AND status IN ('skipped', 'ignored')
|
||||
RETURNING id::text
|
||||
"""), {
|
||||
"task_id": existing_after_conflict.get("id"),
|
||||
"route": route,
|
||||
"action": action_label,
|
||||
"note": description,
|
||||
"source_event_id": f"{source}:{opportunity_id}:{action_code}:{document_key}:reactivated",
|
||||
"priority": priority,
|
||||
"metadata": _json({
|
||||
**metadata,
|
||||
"reactivated_after_blocker": True,
|
||||
"reactivated_from_status": existing_after_conflict.get("status"),
|
||||
"reactivated_by": actor,
|
||||
"reactivated_source": source,
|
||||
"reactivated_version": "v4928.1.5.132.2.1",
|
||||
}),
|
||||
}).scalar()
|
||||
if reactivated:
|
||||
conn.execute(text("""
|
||||
INSERT INTO task_events (task_id, event_type, payload, created_by)
|
||||
VALUES (CAST(:task_id AS UUID), 'task_reactivated', CAST(:payload AS JSONB), :created_by)
|
||||
"""), {
|
||||
"task_id": reactivated,
|
||||
"payload": _json({
|
||||
"action_code": action_code,
|
||||
"reason": "explicit_blocker_cleared",
|
||||
"source": source,
|
||||
"previous_status": existing_after_conflict.get("status"),
|
||||
}),
|
||||
"created_by": actor,
|
||||
})
|
||||
return {
|
||||
"created": False,
|
||||
"reactivated": True,
|
||||
"reason": "reactivated_after_blocker",
|
||||
"task_id": reactivated,
|
||||
"action_code": action_code,
|
||||
}
|
||||
return {
|
||||
"created": False,
|
||||
"reason": "idempotency_conflict",
|
||||
"task_id": existing_after_conflict.get("id") if existing_after_conflict else None,
|
||||
"existing_status": existing_after_conflict.get("status") if existing_after_conflict else None,
|
||||
"action_code": action_code,
|
||||
}
|
||||
|
||||
conn.execute(text("""
|
||||
INSERT INTO task_events (task_id, event_type, payload, created_by)
|
||||
VALUES (CAST(:task_id AS UUID), 'task_created', CAST(:payload AS JSONB), :created_by)
|
||||
"""), {
|
||||
"task_id": created_id,
|
||||
"payload": _json(metadata),
|
||||
"created_by": actor,
|
||||
})
|
||||
|
||||
return {"created": True, "task_id": created_id, "action_code": action_code, "document_number": document_number or None}
|
||||
Reference in New Issue
Block a user