Files
clientflow_backend/app/odoo_delivery_task_reconciliation.py

177 lines
7.0 KiB
Python

"""Reconcile operator tasks when Odoo proves the outgoing delivery is done.
A completed WH/OUT is stronger evidence than pending ClientFlow tasks that ask
an operator to validate the physical order or create the shipment. Those tasks
must stop dominating the workbench once the external evidence is unequivocal.
"""
from __future__ import annotations
import json
import uuid
from typing import Any, Mapping
from sqlalchemy import text
def _json(value: Any) -> str:
return json.dumps(value or {}, ensure_ascii=False, default=str)
def _picking_names(evidence: Mapping[str, Any] | None) -> list[str]:
result: list[str] = []
for picking in (evidence or {}).get("pickings", []) or (evidence or {}).get("outgoing_pickings", []) or []:
if not isinstance(picking, Mapping):
continue
if str(picking.get("state") or "").strip().lower() != "done":
continue
name = str(picking.get("name") or "").strip()
if name and name not in result:
result.append(name)
return result
def reconcile_odoo_delivery_done(
conn: Any,
opportunity_id: str,
*,
evidence: Mapping[str, Any] | None = None,
actor: str = "odoo_sync",
upsert_validation: bool = True,
) -> dict[str, Any]:
"""Persist Odoo-done evidence and auto-complete superseded pending tasks.
This deliberately does not call the normal task completion cascade: the
Odoo delivery is already beyond both VALIDATE_PHYSICAL_ORDER and
CREATE_SHIPMENT, so replaying those cascades would create fresh stale work.
"""
evidence = dict(evidence or {})
picking_names = _picking_names(evidence)
sale = evidence.get("sale_order") if isinstance(evidence.get("sale_order"), Mapping) else {}
external_id = str((sale or {}).get("id") or evidence.get("sale_order_id") or "").strip() or None
external_name = str((sale or {}).get("name") or evidence.get("sale_order") or "").strip() or None
if upsert_validation:
conn.execute(text("""
INSERT INTO operation_links (
opportunity_id, system, external_type, external_id, external_name,
external_url, status, payload, last_synced_at, updated_at
) VALUES (
CAST(:opportunity_id AS UUID), 'odoo', 'physical_validation',
:external_id, :external_name, NULL, 'validated', CAST(:payload AS JSONB), now(), now()
)
ON CONFLICT (opportunity_id, system, external_type)
DO UPDATE SET
external_id = COALESCE(EXCLUDED.external_id, operation_links.external_id),
external_name = COALESCE(EXCLUDED.external_name, operation_links.external_name),
status = 'validated',
payload = operation_links.payload || EXCLUDED.payload,
last_synced_at = now(),
updated_at = now()
"""), {
"opportunity_id": opportunity_id,
"external_id": external_id,
"external_name": external_name or "WH/OUT concluído no Odoo",
"payload": _json({
**evidence,
"validated_from_odoo_delivery_done": True,
"validated_by": actor,
"version": "v4928.1.5.132.4",
}),
})
rows = conn.execute(text("""
SELECT id::text, action_code, status
FROM tasks
WHERE opportunity_id = CAST(:opportunity_id AS UUID)
AND status = 'pending'
AND action_code IN ('VALIDATE_PHYSICAL_ORDER', 'CREATE_SHIPMENT')
ORDER BY created_at
FOR UPDATE
"""), {"opportunity_id": opportunity_id}).mappings().all()
completed: list[dict[str, str]] = []
for row in rows:
task_id = str(row.get("id") or "")
action_code = str(row.get("action_code") or "")
if not task_id:
continue
reason = (
"Odoo WH/OUT concluído confirma a preparação física e expedição."
if action_code == "VALIDATE_PHYSICAL_ORDER"
else "Odoo WH/OUT concluído confirma que a expedição já foi executada."
)
metadata = {
"auto_completed_from_odoo_delivery_done": True,
"auto_completed_by": actor,
"auto_completed_version": "v4928.1.5.132.4",
"auto_completed_reason": "odoo_delivery_done_supersedes_pending_action",
"odoo_done_pickings": picking_names,
"previous_status": str(row.get("status") or "pending"),
}
updated = conn.execute(text("""
UPDATE tasks
SET status = 'done',
action_required = FALSE,
done_at = COALESCE(done_at, now()),
done_by = COALESCE(done_by, :actor),
note = CASE
WHEN COALESCE(note, '') = '' THEN :reason
ELSE note || E'\n\n' || :reason
END,
metadata = COALESCE(metadata, '{}'::jsonb) || CAST(:metadata AS JSONB),
updated_at = now()
WHERE id = CAST(:task_id AS UUID)
AND status = 'pending'
RETURNING id::text
"""), {
"task_id": task_id,
"actor": actor,
"reason": reason,
"metadata": _json(metadata),
}).scalar()
if not updated:
continue
conn.execute(text("""
INSERT INTO task_events (task_id, event_type, payload, created_by)
VALUES (
CAST(:task_id AS UUID), 'task_auto_completed',
CAST(:payload AS JSONB), :actor
)
"""), {
"task_id": task_id,
"actor": actor,
"payload": _json({
"action_code": action_code,
"reason": "odoo_delivery_done_supersedes_pending_action",
"odoo_done_pickings": picking_names,
"version": "v4928.1.5.132.4",
}),
})
completed.append({"task_id": task_id, "action_code": action_code})
if completed:
conn.execute(text("""
INSERT INTO opportunity_events (
id, opportunity_id, event_type, action_code, from_stage, to_stage,
note, payload, created_by
)
SELECT
CAST(:id AS UUID), id, 'odoo_delivery_done_tasks_reconciled',
'CLOSE_OPPORTUNITY', stage, stage, :note, CAST(:payload AS JSONB), :actor
FROM opportunities
WHERE id = CAST(:opportunity_id AS UUID)
"""), {
"id": str(uuid.uuid4()),
"opportunity_id": opportunity_id,
"actor": actor,
"note": "WH/OUT concluído no Odoo; tasks físicas/expedição obsoletas concluídas automaticamente.",
"payload": _json({"completed_tasks": completed, "odoo_done_pickings": picking_names, "version": "v4928.1.5.132.4"}),
})
return {
"physical_validation": "validated" if upsert_validation else "preserved",
"completed_tasks": completed,
"odoo_done_pickings": picking_names,
}