#!/usr/bin/env python3 """Repair safe findings from audit_operator_page_consistency.py. This helper is deliberately conservative. By default it only reports what it would do. Use --apply to write safe repairs. Safe repairs implemented: - after_delivery_invoice_task_too_early: ignore premature pending SEND_INVOICE tasks while payment_terms=after_delivery and Odoo is still in production. - obsolete_payment_task_pending: ignore pending payment/follow-up tasks when payment is already confirmed. - future_due_task_already_completed: add explicit metadata/note so the timeline says the task was completed early instead of looking like a future action ran. - invoice_sent_task_done_but_document_status_not_sent: store ClientFlow evidence on the commercial document payload when SEND_INVOICE is done. Critical business exceptions such as delivery done before payment confirmed are reported but never fixed automatically. """ from __future__ import annotations import argparse import json from typing import Any, Dict, List from sqlalchemy import text from app.db import engine PAYMENT_TASK_CODES = {"CONFIRM_PAYMENT", "FOLLOW_UP_PAYMENT", "FOLLOW_UP_PROFORMA", "FOLLOW_UP_QUOTE"} PENDING_STATUSES = {"pending", "open", "new", "pendente"} DONE_STATUSES = {"done", "completed", "concluida", "concluída"} def _rows(sql: str, params: Dict[str, Any] | None = None) -> List[Dict[str, Any]]: with engine.begin() as conn: return [dict(r) for r in conn.execute(text(sql), params or {}).mappings().all()] def _execute(sql: str, params: Dict[str, Any]) -> int: with engine.begin() as conn: result = conn.execute(text(sql), params) return int(result.rowcount or 0) def _json(value: Any) -> str: return json.dumps(value or {}, ensure_ascii=False, default=str) def _select_premature_after_delivery_invoice_tasks(limit: int) -> List[Dict[str, Any]]: return _rows(""" WITH odoo AS ( SELECT opportunity_id, bool_or(system = 'odoo' AND external_type = 'sale_order') AS has_sale, bool_or(system = 'odoo' AND external_type = 'physical_status' AND status IN ('in_production','waiting_stock','order_created','created','confirmed')) AS physical_active, bool_or(system = 'odoo' AND external_type = 'production' AND status IN ('in_progress','in_production','confirmed','created')) AS production_active, bool_or(system = 'odoo' AND external_type IN ('physical_status','physical_validation') AND status IN ('ready_to_ship','validated','shipped','done','delivered')) AS ready_or_done FROM operation_links GROUP BY opportunity_id ) SELECT t.id::text AS task_id, t.action_code, t.status AS task_status, t.due_at, o.id::text AS opportunity_id, o.title AS opportunity_title, o.customer_name, o.metadata FROM tasks t JOIN opportunities o ON o.id = t.opportunity_id LEFT JOIN odoo ON odoo.opportunity_id = o.id WHERE upper(COALESCE(t.action_code,'')) = 'SEND_INVOICE' AND lower(COALESCE(t.status,'')) IN ('pending','open','new','pendente') AND lower(COALESCE(o.metadata->>'payment_terms','')) = 'after_delivery' AND COALESCE(odoo.ready_or_done, FALSE) = FALSE AND (COALESCE(odoo.production_active, FALSE) = TRUE OR COALESCE(odoo.physical_active, FALSE) = TRUE OR COALESCE(odoo.has_sale, FALSE) = TRUE) ORDER BY COALESCE(t.due_at, t.created_at) ASC LIMIT :limit """, {"limit": limit}) def _select_obsolete_payment_tasks(limit: int) -> List[Dict[str, Any]]: return _rows(""" SELECT t.id::text AS task_id, t.action_code, t.status AS task_status, t.due_at, o.id::text AS opportunity_id, o.title AS opportunity_title, o.customer_name FROM tasks t JOIN opportunities o ON o.id = t.opportunity_id WHERE lower(COALESCE(t.status,'')) IN ('pending','open','new','pendente') AND upper(COALESCE(t.action_code,'')) IN ('CONFIRM_PAYMENT','FOLLOW_UP_PAYMENT','FOLLOW_UP_PROFORMA','FOLLOW_UP_QUOTE') AND ( o.stage = 'PAYMENT_CONFIRMED' OR EXISTS ( SELECT 1 FROM operation_links p WHERE p.opportunity_id = o.id AND p.system = 'clientflow' AND p.external_type = 'payment' AND p.status = 'confirmed' ) ) ORDER BY COALESCE(t.due_at, t.created_at) ASC LIMIT :limit """, {"limit": limit}) def _select_future_due_done_tasks(limit: int) -> List[Dict[str, Any]]: return _rows(""" SELECT t.id::text AS task_id, t.action_code, t.status AS task_status, t.due_at, o.id::text AS opportunity_id, o.title AS opportunity_title, o.customer_name FROM tasks t JOIN opportunities o ON o.id = t.opportunity_id WHERE t.due_at IS NOT NULL AND t.due_at > now() AND lower(COALESCE(t.status,'')) IN ('done','completed','concluida','concluída') AND NOT (COALESCE(t.metadata, '{}'::jsonb) ? 'future_done_acknowledged_by') AND NOT (COALESCE(t.metadata, '{}'::jsonb) ? 'completed_before_due_acknowledged_at') ORDER BY t.due_at ASC LIMIT :limit """, {"limit": limit}) def _select_invoice_sent_evidence_missing(limit: int) -> List[Dict[str, Any]]: return _rows(""" SELECT DISTINCT ON (d.id) d.id::text AS document_id, d.document_number, d.status AS document_status, o.id::text AS opportunity_id, o.title AS opportunity_title, o.customer_name, t.id::text AS task_id, t.done_at, t.updated_at FROM commercial_documents d JOIN opportunities o ON o.id = d.opportunity_id JOIN tasks t ON t.opportunity_id = o.id WHERE lower(COALESCE(d.document_kind,'')) IN ('invoice','fatura','jasmin_invoice','fa','ft') AND upper(COALESCE(t.action_code,'')) = 'SEND_INVOICE' AND lower(COALESCE(t.status,'')) IN ('done','completed','concluida','concluída') AND lower(COALESCE(d.status,'')) NOT IN ('sent','issued_sent') AND NOT (COALESCE(d.payload, '{}'::jsonb) ? 'clientflow_invoice_sent_evidence') AND NOT (COALESCE(d.payload, '{}'::jsonb) ? 'invoice_sent_at') ORDER BY d.id, COALESCE(t.done_at, t.updated_at, t.created_at) DESC LIMIT :limit """, {"limit": limit}) def _select_delivered_without_payment(limit: int) -> List[Dict[str, Any]]: return _rows(""" WITH odoo_done AS ( SELECT opportunity_id FROM operation_links WHERE system = 'odoo' AND external_type IN ('physical_status','physical_validation') AND status IN ('shipped','done','delivered','validated') GROUP BY opportunity_id ) SELECT o.id::text AS opportunity_id, o.title AS opportunity_title, o.customer_name, o.metadata FROM opportunities o JOIN odoo_done d ON d.opportunity_id = o.id WHERE COALESCE(lower(o.metadata->>'payment_terms'), 'before_shipping') IN ('before_shipping','','undefined','agreement') AND o.stage <> 'PAYMENT_CONFIRMED' AND NOT EXISTS ( SELECT 1 FROM operation_links p WHERE p.opportunity_id = o.id AND p.system = 'clientflow' AND p.external_type = 'payment' AND p.status = 'confirmed' ) ORDER BY COALESCE(o.updated_at, o.created_at) DESC LIMIT :limit """, {"limit": limit}) def _acknowledge_future_done_tasks(task_ids: List[str], *, apply: bool) -> int: if not task_ids: return 0 print(f"future_done_tasks_to_acknowledge={len(task_ids)}") if not apply: return 0 metadata = _json({ "future_done_acknowledged_by": "repair_operator_page_consistency_findings", "completed_before_due_acknowledged_at": "now()", "reason": "task concluída antes da data planeada", }) changed = 0 for task_id in task_ids: changed += _execute(""" UPDATE tasks SET note = concat_ws(E'\n', NULLIF(note, ''), CAST(:note AS TEXT)), metadata = COALESCE(metadata, '{}'::jsonb) || (CAST(:metadata AS JSONB) - 'completed_before_due_acknowledged_at') || jsonb_build_object('completed_before_due_acknowledged_at', now()), updated_at = now() WHERE id = CAST(:task_id AS UUID) """, { "task_id": task_id, "note": "Concluída antecipadamente antes da data de follow-up planeada.", "metadata": metadata, }) return changed def _mark_invoice_sent_evidence(rows: List[Dict[str, Any]], *, apply: bool) -> int: if not rows: return 0 print(f"invoice_documents_to_mark_sent_evidence={len(rows)}") if not apply: return 0 changed = 0 for row in rows: payload = _json({ "clientflow_invoice_sent_evidence": { "source": "task_done", "task_id": row.get("task_id"), "recorded_by": "repair_operator_page_consistency_findings", } }) changed += _execute(""" UPDATE commercial_documents SET payload = COALESCE(payload, '{}'::jsonb) || CAST(:payload AS JSONB) || jsonb_build_object('invoice_sent_at', COALESCE(CAST(:sent_at AS TIMESTAMPTZ), now())), updated_at = now() WHERE id = CAST(:document_id AS UUID) """, { "document_id": row.get("document_id"), "payload": payload, "sent_at": row.get("done_at") or row.get("updated_at"), }) return changed def _ignore_tasks(task_ids: List[str], *, reason: str, apply: bool) -> int: if not task_ids: return 0 print(f"tasks_to_ignore={len(task_ids)} reason={reason}") if not apply: return 0 # Use one UUID-typed parameter per update instead of passing a Python list # into ANY(:task_ids). psycopg3 can fail to infer the type of list/array # parameters in SQLAlchemy text() statements on some installations. metadata = _json({"auto_ignored_by": "repair_operator_page_consistency_findings", "reason": reason}) changed = 0 for task_id in task_ids: changed += _execute(""" UPDATE tasks SET status = 'ignored', note = concat_ws(E'\n', NULLIF(note, ''), CAST(:reason AS TEXT)), metadata = COALESCE(metadata, '{}'::jsonb) || CAST(:metadata AS JSONB), updated_at = now() WHERE id = CAST(:task_id AS UUID) """, { "task_id": task_id, "reason": reason, "metadata": metadata, }) return changed def main() -> int: parser = argparse.ArgumentParser(description="Repair safe operator-page consistency findings.") parser.add_argument("--limit", type=int, default=200) parser.add_argument("--apply", action="store_true", help="Write safe repairs. Without this, dry-run only.") parser.add_argument("--fix-premature-after-delivery-invoice", action="store_true", help="Ignore premature pending SEND_INVOICE tasks for payment-after-delivery orders still in production.") parser.add_argument("--fix-obsolete-payment-tasks", action="store_true", help="Ignore payment/follow-up tasks after payment is confirmed.") parser.add_argument("--fix-future-done-task-notes", action="store_true", help="Add note/metadata to future-due tasks already completed so the timeline is explicit.") parser.add_argument("--fix-invoice-sent-evidence", action="store_true", help="Store ClientFlow invoice-sent evidence on documents when SEND_INVOICE task is done.") parser.add_argument("--show-critical", action="store_true", help="List delivered-before-payment cases. Never auto-fixes them.") args = parser.parse_args() if not ( args.fix_premature_after_delivery_invoice or args.fix_obsolete_payment_tasks or args.fix_future_done_task_notes or args.fix_invoice_sent_evidence or args.show_critical ): args.fix_premature_after_delivery_invoice = True args.fix_obsolete_payment_tasks = True args.fix_future_done_task_notes = True args.fix_invoice_sent_evidence = True args.show_critical = True print(f"apply={args.apply} limit={args.limit}") total_changed = 0 if args.fix_premature_after_delivery_invoice: rows = _select_premature_after_delivery_invoice_tasks(args.limit) print(f"\nPREMATURE_AFTER_DELIVERY_SEND_INVOICE={len(rows)}") for r in rows: print(f"- task={r['task_id']} opp={r['opportunity_id']} title={r.get('opportunity_title')} customer={r.get('customer_name')} due={r.get('due_at')}") changed = _ignore_tasks([r["task_id"] for r in rows], reason="obsoleta: pagamento após entrega; aguardar produção/preparação Odoo antes de emitir/enviar fatura", apply=args.apply) total_changed += changed if args.apply: print(f"ignored_premature_after_delivery_invoice_tasks={changed}") if args.fix_obsolete_payment_tasks: rows = _select_obsolete_payment_tasks(args.limit) print(f"\nOBSOLETE_PAYMENT_TASKS={len(rows)}") for r in rows: print(f"- task={r['task_id']} code={r.get('action_code')} opp={r['opportunity_id']} title={r.get('opportunity_title')} customer={r.get('customer_name')} due={r.get('due_at')}") changed = _ignore_tasks([r["task_id"] for r in rows], reason="obsoleta: pagamento já confirmado", apply=args.apply) total_changed += changed if args.apply: print(f"ignored_obsolete_payment_tasks={changed}") if args.fix_future_done_task_notes: rows = _select_future_due_done_tasks(args.limit) print(f"\nFUTURE_DUE_TASK_ALREADY_COMPLETED={len(rows)}") for r in rows: print(f"- task={r['task_id']} code={r.get('action_code')} opp={r['opportunity_id']} title={r.get('opportunity_title')} customer={r.get('customer_name')} due={r.get('due_at')}") changed = _acknowledge_future_done_tasks([r["task_id"] for r in rows], apply=args.apply) total_changed += changed if args.apply: print(f"acknowledged_future_due_done_tasks={changed}") if args.fix_invoice_sent_evidence: rows = _select_invoice_sent_evidence_missing(args.limit) print(f"\nINVOICE_SENT_TASK_DONE_BUT_DOCUMENT_NOT_MARKED={len(rows)}") for r in rows: print(f"- doc={r.get('document_number')} document_id={r.get('document_id')} task={r.get('task_id')} opp={r.get('opportunity_id')} title={r.get('opportunity_title')} customer={r.get('customer_name')}") changed = _mark_invoice_sent_evidence(rows, apply=args.apply) total_changed += changed if args.apply: print(f"invoice_sent_evidence_marked={changed}") if args.show_critical: rows = _select_delivered_without_payment(args.limit) print(f"\nCRITICAL_DELIVERED_WITHOUT_PAYMENT_CONFIRMED={len(rows)}") for r in rows: print(f"- opp={r['opportunity_id']} title={r.get('opportunity_title')} customer={r.get('customer_name')}") if rows: print("manual_action=Confirmar se o pagamento existe. Se sim, registar pagamento confirmado na oportunidade; se não, contactar/regularizar antes de fechar.") print("\nSUMMARY") print(f"changed={total_changed}") if not args.apply: print("dry_run=True") return 0 if __name__ == "__main__": raise SystemExit(main())