175 lines
7.2 KiB
Python
Executable File
175 lines
7.2 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
"""Repair safe opportunity-detail inconsistencies.
|
|
|
|
Fixes the class of issues seen after Chatwoot recovery:
|
|
- payment is confirmed but old pending payment/follow-up tasks still drive the UI;
|
|
- fiscal data can be safely completed from the linked Jasmin document.
|
|
|
|
By default this is a dry-run. Use --apply to write changes.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
from typing import Any
|
|
|
|
from sqlalchemy import text
|
|
|
|
from app.db import engine
|
|
from app.task_service import mark_obsolete_payment_followup_tasks
|
|
from app.jasmin_fiscal_sync_service import apply_jasmin_fiscal_sync, get_jasmin_fiscal_sync_preview
|
|
|
|
|
|
def _rows(sql: str, params: dict[str, Any]) -> list[dict[str, Any]]:
|
|
with engine.begin() as conn:
|
|
return [dict(r) for r in conn.execute(text(sql), params).mappings().all()]
|
|
|
|
|
|
def _scalar(sql: str, params: dict[str, Any]) -> Any:
|
|
with engine.begin() as conn:
|
|
return conn.execute(text(sql), params).scalar()
|
|
|
|
|
|
def _select_opportunities(args: argparse.Namespace) -> list[dict[str, Any]]:
|
|
if args.opportunity_id:
|
|
return _rows("""
|
|
SELECT id::text, title, conversation_id
|
|
FROM opportunities
|
|
WHERE id = CAST(:id AS UUID)
|
|
""", {"id": args.opportunity_id})
|
|
if args.conversation_id:
|
|
return _rows("""
|
|
SELECT id::text, title, conversation_id
|
|
FROM opportunities
|
|
WHERE conversation_id = :conversation_id
|
|
AND COALESCE(status, 'open') <> 'closed'
|
|
ORDER BY updated_at DESC
|
|
""", {"conversation_id": str(args.conversation_id)})
|
|
if args.document_number:
|
|
return _rows("""
|
|
SELECT o.id::text, o.title, o.conversation_id
|
|
FROM opportunities o
|
|
JOIN commercial_documents d ON d.opportunity_id = o.id
|
|
WHERE d.document_number = :document_number
|
|
AND COALESCE(o.status, 'open') <> 'closed'
|
|
GROUP BY o.id, o.title, o.conversation_id
|
|
ORDER BY MAX(o.updated_at) DESC
|
|
""", {"document_number": str(args.document_number)})
|
|
if args.all_open:
|
|
return _rows("""
|
|
SELECT DISTINCT o.id::text, o.title, o.conversation_id
|
|
FROM opportunities o
|
|
LEFT JOIN operation_links p
|
|
ON p.opportunity_id = o.id
|
|
AND p.system = 'clientflow'
|
|
AND p.external_type = 'payment'
|
|
AND p.status = 'confirmed'
|
|
LEFT JOIN tasks t
|
|
ON t.opportunity_id = o.id
|
|
AND t.status = 'pending'
|
|
AND upper(COALESCE(t.action_code,'')) IN ('CONFIRM_PAYMENT','FOLLOW_UP_PAYMENT','FOLLOW_UP_PROFORMA','FOLLOW_UP_QUOTE')
|
|
LEFT JOIN commercial_documents d
|
|
ON d.opportunity_id = o.id
|
|
AND d.system = 'jasmin'
|
|
AND COALESCE(d.is_active, TRUE) = TRUE
|
|
LEFT JOIN customers c ON c.id = o.local_customer_id
|
|
WHERE COALESCE(o.status, 'open') <> 'closed'
|
|
AND (
|
|
((p.id IS NOT NULL OR o.stage = 'PAYMENT_CONFIRMED') AND t.id IS NOT NULL)
|
|
OR (
|
|
d.id IS NOT NULL AND (
|
|
o.local_customer_id IS NULL
|
|
OR COALESCE(c.email,'') = ''
|
|
OR COALESCE(c.street_name,'') = ''
|
|
OR COALESCE(c.postal_zone,'') = ''
|
|
OR COALESCE(c.city_name,'') = ''
|
|
)
|
|
)
|
|
)
|
|
ORDER BY o.title NULLS LAST, o.id
|
|
LIMIT :limit
|
|
""", {"limit": int(args.limit or 200)})
|
|
raise SystemExit("Use --opportunity-id, --conversation-id, --document-number or --all-open")
|
|
|
|
|
|
def _payment_confirmed(opportunity_id: str) -> bool:
|
|
return bool(_scalar("""
|
|
SELECT 1
|
|
FROM opportunities o
|
|
LEFT JOIN operation_links p
|
|
ON p.opportunity_id = o.id
|
|
AND p.system = 'clientflow'
|
|
AND p.external_type = 'payment'
|
|
AND p.status = 'confirmed'
|
|
WHERE o.id = CAST(:id AS UUID)
|
|
AND (p.id IS NOT NULL OR o.stage = 'PAYMENT_CONFIRMED')
|
|
LIMIT 1
|
|
""", {"id": opportunity_id}))
|
|
|
|
|
|
def _obsolete_pending_count(opportunity_id: str) -> int:
|
|
return int(_scalar("""
|
|
SELECT COUNT(*)
|
|
FROM tasks
|
|
WHERE opportunity_id = CAST(:id AS UUID)
|
|
AND status = 'pending'
|
|
AND upper(COALESCE(action_code,'')) IN ('CONFIRM_PAYMENT','FOLLOW_UP_PAYMENT','FOLLOW_UP_PROFORMA','FOLLOW_UP_QUOTE')
|
|
""", {"id": opportunity_id}) or 0)
|
|
|
|
|
|
def main() -> int:
|
|
parser = argparse.ArgumentParser(description="Repair safe opportunity consistency issues.")
|
|
parser.add_argument("--opportunity-id")
|
|
parser.add_argument("--conversation-id")
|
|
parser.add_argument("--document-number")
|
|
parser.add_argument("--all-open", action="store_true")
|
|
parser.add_argument("--limit", type=int, default=200)
|
|
parser.add_argument("--apply", action="store_true", help="Write safe repairs. Without this, only reports.")
|
|
parser.add_argument("--complete-fiscal-from-jasmin", action="store_true", help="Also fill empty fiscal fields from linked Jasmin documents when safe.")
|
|
args = parser.parse_args()
|
|
|
|
opportunities = _select_opportunities(args)
|
|
print(f"opportunities={len(opportunities)} apply={args.apply} complete_fiscal_from_jasmin={args.complete_fiscal_from_jasmin}")
|
|
total_closed = 0
|
|
total_fiscal = 0
|
|
|
|
for opp in opportunities:
|
|
oid = str(opp.get("id"))
|
|
title = opp.get("title") or oid
|
|
payment_ok = _payment_confirmed(oid)
|
|
obsolete_count = _obsolete_pending_count(oid) if payment_ok else 0
|
|
print("\n" + "=" * 80)
|
|
print(f"{title}")
|
|
print(f"id={oid} conversation={opp.get('conversation_id') or '—'}")
|
|
print(f"payment_confirmed={payment_ok} obsolete_payment_tasks={obsolete_count}")
|
|
|
|
if args.apply and payment_ok and obsolete_count:
|
|
closed = mark_obsolete_payment_followup_tasks(oid, actor="repair_opportunity_consistency")
|
|
total_closed += closed
|
|
print(f"closed_obsolete_tasks={closed}")
|
|
|
|
try:
|
|
preview = get_jasmin_fiscal_sync_preview(oid)
|
|
except Exception as exc:
|
|
print(f"jasmin_fiscal_preview_error={exc}")
|
|
preview = {"available": False}
|
|
if preview.get("available"):
|
|
print(f"jasmin_available=True conflict={bool(preview.get('conflict'))} fillable={preview.get('fillable_fields') or []} missing={preview.get('missing_fields') or []}")
|
|
if args.apply and args.complete_fiscal_from_jasmin and not preview.get("conflict") and (preview.get("fillable_fields") or not preview.get("linked_customer_id")):
|
|
try:
|
|
result = apply_jasmin_fiscal_sync(oid, actor="repair_opportunity_consistency")
|
|
total_fiscal += 1
|
|
print(f"fiscal_sync_applied=True filled={result.get('filled_fields') or []} customer_id={result.get('customer_id')}")
|
|
except Exception as exc:
|
|
print(f"fiscal_sync_error={exc}")
|
|
else:
|
|
print(f"jasmin_available=False reason={preview.get('reason') or 'unknown'}")
|
|
|
|
print("\nSUMMARY")
|
|
print(f"closed_obsolete_tasks={total_closed}")
|
|
print(f"fiscal_sync_applied={total_fiscal}")
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
raise SystemExit(main())
|