Files
clientflow_backend/scripts/repair_operator_page_consistency_findings.py

365 lines
16 KiB
Python
Executable File

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