Files
clientflow_backend/scripts/apply_reconciliation_coherence_fixes.py

221 lines
9.1 KiB
Python
Executable File

#!/usr/bin/env python3
"""Resolve stale Odoo reconciliation candidates already linked to opportunities.
Dry-run is the default. The script deliberately does not alter opportunity
stage, value, documents or operation_links. On --apply it only resolves stale
reconciliation_items and then asks the central workflow engine to materialize a
single supported human task (for example CREATE_SHIPMENT or PREPARE_ORDER).
"""
from __future__ import annotations
import argparse
import json
import sys
from pathlib import Path
from typing import Any, Dict, List
from sqlalchemy import text
def _project_root() -> Path:
script = Path(__file__).resolve()
for candidate in [script.parent.parent, Path.cwd().resolve(), *script.parents]:
if (candidate / "app" / "db.py").is_file():
return candidate
return script.parent.parent
PROJECT_ROOT = _project_root()
if str(PROJECT_ROOT) not in sys.path:
sys.path.insert(0, str(PROJECT_ROOT))
def _rows(*, limit: int, sales: List[str]) -> List[Dict[str, Any]]:
from app.db import engine
from app.reconciliation_service import ensure_reconciliation_schema
ensure_reconciliation_schema()
sale_filter = ""
params: Dict[str, Any] = {"limit": max(1, min(int(limit), 5000))}
if sales:
sale_filter = "AND UPPER(COALESCE(ri.document_number, '')) = ANY(CAST(:sales AS TEXT[]))"
params["sales"] = [str(value).strip().upper() for value in sales if str(value).strip()]
with engine.begin() as conn:
rows = conn.execute(text(f"""
WITH candidates AS (
SELECT
ri.id::text AS item_id,
ri.external_id,
ri.document_number,
ri.customer_name,
ri.amount,
ri.status AS item_status,
ri.suggested_action,
ri.opportunity_id::text AS current_item_opportunity_id
FROM reconciliation_items ri
WHERE ri.source_system = 'odoo'
AND ri.external_type = 'odoo_sale_order'
AND ri.status IN ('open', 'needs_review', 'conflict')
{sale_filter}
ORDER BY ri.document_date NULLS LAST, ri.created_at
LIMIT :limit
)
SELECT
c.*,
COUNT(DISTINCT o.id)::int AS exact_link_count,
MIN(o.id::text) AS linked_opportunity_id,
MIN(o.title) AS opportunity_title,
MIN(o.stage) AS opportunity_stage,
MIN(o.status) AS opportunity_status,
COUNT(t.id) FILTER (WHERE t.status = 'pending')::int AS pending_tasks
FROM candidates c
LEFT JOIN operation_links ol
ON ol.system = 'odoo'
AND ol.external_type = 'sale_order'
AND (
(NULLIF(c.external_id, '') IS NOT NULL AND ol.external_id = c.external_id)
OR (NULLIF(c.document_number, '') IS NOT NULL AND UPPER(COALESCE(ol.external_name, '')) = UPPER(c.document_number))
)
LEFT JOIN opportunities o
ON o.id = ol.opportunity_id
AND o.status = 'open'
LEFT JOIN tasks t
ON t.opportunity_id = o.id
AND t.status = 'pending'
GROUP BY
c.item_id, c.external_id, c.document_number, c.customer_name,
c.amount, c.item_status, c.suggested_action,
c.current_item_opportunity_id
ORDER BY c.document_number
"""), params).mappings().all()
return [dict(row) for row in rows]
def _resolve_item(item: Dict[str, Any]) -> bool:
from app.db import engine
from app.reconciliation_service import ensure_reconciliation_schema
ensure_reconciliation_schema()
with engine.begin() as conn:
result = conn.execute(text("""
UPDATE reconciliation_items ri
SET opportunity_id = CAST(:opportunity_id AS UUID),
status = 'linked',
resolution_note = 'Resolvido: venda Odoo já estava ligada à oportunidade; fase preservada',
resolved_at = now(),
updated_at = now(),
payload = COALESCE(ri.payload, '{}'::jsonb) || CAST(:payload AS JSONB)
WHERE ri.id = CAST(:item_id AS UUID)
AND ri.status IN ('open', 'needs_review', 'conflict')
"""), {
"item_id": item["item_id"],
"opportunity_id": item["linked_opportunity_id"],
"payload": json.dumps({
"resolved_as_existing_operation_link": True,
"resolved_by": "migration_v4928_1_5_129",
"preserve_opportunity_stage": True,
"previous_suggested_action": item.get("suggested_action"),
"sale_order": item.get("document_number") or item.get("external_id"),
}, ensure_ascii=False),
})
if not result.rowcount:
return False
try:
with conn.begin_nested():
conn.execute(text("""
INSERT INTO reconciliation_decisions (
decision_type, status, item_ids, opportunity_id, note, actor, payload
) VALUES (
'resolve_existing_operation_link', 'linked', CAST(:item_ids AS TEXT[]),
CAST(:opportunity_id AS UUID), :note, 'migration_v4928_1_5_129',
CAST(:payload AS JSONB)
)
"""), {
"item_ids": [item["item_id"]],
"opportunity_id": item["linked_opportunity_id"],
"note": "Venda Odoo já ligada; candidato obsoleto resolvido sem regressão de fase.",
"payload": json.dumps({
"sale_order": item.get("document_number") or item.get("external_id"),
"preserve_stage": True,
}, ensure_ascii=False),
})
except Exception:
# The core update remains valid if an older installation has not
# yet created the optional decisions table.
pass
return True
def _materialize(opportunity_id: str) -> Dict[str, Any]:
from app.opportunity_action_task_materializer import ensure_pending_task_for_next_action
from app.opportunity_next_action_service import get_opportunity_next_action
next_action = get_opportunity_next_action(opportunity_id)
result = ensure_pending_task_for_next_action(
opportunity_id,
next_action,
source="reconciliation_cleanup_v129",
actor="migration_v4928_1_5_129",
)
return {"next_action": next_action, "materialization": result}
def main() -> int:
parser = argparse.ArgumentParser()
parser.add_argument("--apply", action="store_true", help="Apply the reviewed corrections.")
parser.add_argument("--limit", type=int, default=500)
parser.add_argument("--sale", action="append", default=[], help="Restrict to a sale number, e.g. S00325.")
args = parser.parse_args()
rows = _rows(limit=args.limit, sales=args.sale)
safe = [row for row in rows if int(row.get("exact_link_count") or 0) == 1 and row.get("linked_opportunity_id")]
conflicts = [row for row in rows if int(row.get("exact_link_count") or 0) > 1]
unmatched = [row for row in rows if int(row.get("exact_link_count") or 0) == 0]
print(f"Open Odoo reconciliation candidates: {len(rows)}")
print(f"Safe existing links: {len(safe)}")
print(f"Conflicting links: {len(conflicts)}")
print(f"Without exact link: {len(unmatched)}")
for row in rows:
status = "SAFE" if row in safe else ("CONFLICT" if row in conflicts else "MANUAL")
print(
f"{status:8} | {str(row.get('document_number') or row.get('external_id') or ''):10} | "
f"links={int(row.get('exact_link_count') or 0)} | "
f"stage={row.get('opportunity_stage') or '-':18} | "
f"{row.get('customer_name') or '-'}"
)
if not args.apply:
print("Dry-run only. Use --apply after reviewing the safe candidates.")
return 0
resolved = 0
task_results: List[Dict[str, Any]] = []
for row in safe:
if _resolve_item(row):
resolved += 1
task_result = _materialize(str(row["linked_opportunity_id"]))
task_results.append({
"sale": row.get("document_number") or row.get("external_id"),
"opportunity_id": row.get("linked_opportunity_id"),
**task_result,
})
print(f"Candidates resolved: {resolved}")
for result in task_results:
next_action = result.get("next_action") or {}
materialization = result.get("materialization") or {}
print(
f"{result.get('sale')} | next={next_action.get('action_code')} | "
f"task={materialization.get('action_code') or '-'} | "
f"created={bool(materialization.get('created'))} | "
f"reason={materialization.get('reason') or '-'}"
)
return 0
if __name__ == "__main__":
raise SystemExit(main())