Import ClientFlow production v4928.1.5.132.4

This commit is contained in:
plx
2026-07-29 13:11:01 +00:00
parent 6445044ac6
commit 261d342057
405 changed files with 48373 additions and 1401 deletions

173
repair_opportunity_consistency.py Executable file
View File

@@ -0,0 +1,173 @@
#!/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 DISTINCT 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'
ORDER BY 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())