153 lines
5.7 KiB
Python
Executable File
153 lines
5.7 KiB
Python
Executable File
#!/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())
|