221 lines
9.1 KiB
Python
Executable File
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())
|