#!/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())