Import ClientFlow production v4928.1.5.132.4
This commit is contained in:
220
scripts/apply_reconciliation_coherence_fixes.py
Executable file
220
scripts/apply_reconciliation_coherence_fixes.py
Executable file
@@ -0,0 +1,220 @@
|
||||
#!/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())
|
||||
Reference in New Issue
Block a user