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