Import ClientFlow production v4928.1.5.132.4
This commit is contained in:
152
scripts/repair_v132_4_odoo_done_stale_tasks.py
Executable file
152
scripts/repair_v132_4_odoo_done_stale_tasks.py
Executable file
@@ -0,0 +1,152 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Repair stale physical/shipment tasks when Odoo WH/OUT is already done.
|
||||
|
||||
Dry-run by default. The repair is deliberately narrow: it requires explicit
|
||||
Odoo delivery-done evidence for the selected opportunity before changing tasks
|
||||
or the operational stage.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import uuid
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import text
|
||||
|
||||
from app.db import engine
|
||||
from app.odoo_delivery_task_reconciliation import reconcile_odoo_delivery_done
|
||||
|
||||
|
||||
def _json(value: Any) -> str:
|
||||
return json.dumps(value or {}, ensure_ascii=False, default=str)
|
||||
|
||||
|
||||
def _payload(value: Any) -> dict[str, Any]:
|
||||
if isinstance(value, dict):
|
||||
return value
|
||||
if isinstance(value, str) and value.strip():
|
||||
try:
|
||||
parsed = json.loads(value)
|
||||
return parsed if isinstance(parsed, dict) else {}
|
||||
except Exception:
|
||||
return {}
|
||||
return {}
|
||||
|
||||
|
||||
def _done_evidence(status: str, payload: dict[str, Any]) -> bool:
|
||||
if str(status or "").lower() in {"shipped", "done", "delivered"}:
|
||||
return True
|
||||
if bool(payload.get("delivery_done")):
|
||||
return True
|
||||
pickings = payload.get("pickings") or payload.get("outgoing_pickings") or []
|
||||
outgoing = [p for p in pickings if isinstance(p, dict)]
|
||||
return bool(outgoing) and all(str(p.get("state") or "").lower() == "done" for p in outgoing)
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--opportunity-id", required=True)
|
||||
parser.add_argument("--apply", action="store_true")
|
||||
args = parser.parse_args()
|
||||
|
||||
with engine.begin() as conn:
|
||||
opportunity = conn.execute(text("""
|
||||
SELECT id::text, title, stage, status
|
||||
FROM opportunities
|
||||
WHERE id = CAST(:opportunity_id AS UUID)
|
||||
FOR UPDATE
|
||||
"""), {"opportunity_id": args.opportunity_id}).mappings().first()
|
||||
if not opportunity:
|
||||
raise SystemExit("Oportunidade não encontrada.")
|
||||
|
||||
physical = conn.execute(text("""
|
||||
SELECT status, payload
|
||||
FROM operation_links
|
||||
WHERE opportunity_id = CAST(:opportunity_id AS UUID)
|
||||
AND system = 'odoo'
|
||||
AND external_type = 'physical_status'
|
||||
LIMIT 1
|
||||
"""), {"opportunity_id": args.opportunity_id}).mappings().first() or {}
|
||||
payload = _payload(physical.get("payload"))
|
||||
|
||||
tasks = 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
|
||||
"""), {"opportunity_id": args.opportunity_id}).mappings().all()
|
||||
|
||||
print(f"Opportunity: {opportunity['title']} ({opportunity['stage']})")
|
||||
print(f"Odoo physical status: {physical.get('status')}")
|
||||
print(f"Delivery done evidence: {_done_evidence(str(physical.get('status') or ''), payload)}")
|
||||
print(f"Pending stale actions: {[row['action_code'] for row in tasks]}")
|
||||
|
||||
if not _done_evidence(str(physical.get("status") or ""), payload):
|
||||
raise SystemExit("ABORTADO: não existe evidência inequívoca de WH/OUT concluído.")
|
||||
if not tasks and str(opportunity.get("stage") or "") == "SHIPPED":
|
||||
print("Sem alterações necessárias.")
|
||||
return 0
|
||||
if not args.apply:
|
||||
print("SAFE: concluir tasks físicas/expedição pendentes e alinhar fase para SHIPPED.")
|
||||
print("Dry-run only. Use --apply.")
|
||||
return 0
|
||||
|
||||
result = reconcile_odoo_delivery_done(
|
||||
conn,
|
||||
args.opportunity_id,
|
||||
evidence=payload,
|
||||
actor="manual_v132_4_odoo_done_repair",
|
||||
upsert_validation=True,
|
||||
)
|
||||
old_stage = str(opportunity.get("stage") or "")
|
||||
conn.execute(text("""
|
||||
UPDATE opportunities
|
||||
SET stage = 'SHIPPED',
|
||||
status = 'open',
|
||||
last_action_code = 'CLOSE_OPPORTUNITY',
|
||||
metadata = COALESCE(metadata, '{}'::jsonb) || CAST(:metadata AS JSONB),
|
||||
updated_at = now()
|
||||
WHERE id = CAST(:opportunity_id AS UUID)
|
||||
AND status = 'open'
|
||||
AND stage IN (
|
||||
'ODOO_ORDER_CREATED', 'IN_PRODUCTION', 'ORDER_PREPARATION',
|
||||
'READY_TO_SHIP', 'SHIPMENT_CREATED', 'SHIPPED'
|
||||
)
|
||||
"""), {
|
||||
"opportunity_id": args.opportunity_id,
|
||||
"metadata": _json({
|
||||
"odoo_done_stage_aligned": True,
|
||||
"odoo_done_stage_from": old_stage,
|
||||
"odoo_done_stage_to": "SHIPPED",
|
||||
"odoo_done_stage_version": "v4928.1.5.132.4",
|
||||
}),
|
||||
})
|
||||
conn.execute(text("""
|
||||
INSERT INTO opportunity_events (
|
||||
id, opportunity_id, event_type, action_code, from_stage, to_stage,
|
||||
note, payload, created_by
|
||||
) VALUES (
|
||||
CAST(:id AS UUID), CAST(:opportunity_id AS UUID),
|
||||
'v132_4_odoo_done_repair', 'CLOSE_OPPORTUNITY',
|
||||
:from_stage, 'SHIPPED', :note, CAST(:payload AS JSONB), :created_by
|
||||
)
|
||||
"""), {
|
||||
"id": str(uuid.uuid4()),
|
||||
"opportunity_id": args.opportunity_id,
|
||||
"from_stage": old_stage,
|
||||
"note": "WH/OUT já concluído no Odoo; fase e tasks operacionais alinhadas.",
|
||||
"payload": _json(result),
|
||||
"created_by": "manual_v132_4_odoo_done_repair",
|
||||
})
|
||||
|
||||
print("Resultado:", result)
|
||||
print("Fase: SHIPPED")
|
||||
print("Próxima ação esperada: CLOSE_OPPORTUNITY")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
Reference in New Issue
Block a user