365 lines
16 KiB
Python
Executable File
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())
|