#!/usr/bin/env python3 """Read-only/apply repair for SEND_INVOICE tasks whose invoice already exists. The script keeps legacy reconciliation tasks aligned with the current document state. It does not send emails. With --apply it updates pending SEND_INVOICE notes/actions when a current Jasmin invoice is linked to the opportunity. """ from __future__ import annotations import argparse import json from typing import Any, Dict from sqlalchemy import text from app.db import engine def _json(value: Any) -> str: return json.dumps(value or {}, ensure_ascii=False, default=str) def _money(value: Any, currency: str = "EUR") -> str: if value is None or value == "": return "" try: return f"{float(value):,.2f} {currency or 'EUR'}".replace(",", "X").replace(".", ",").replace("X", ".") except Exception: return str(value) def find_candidates(limit: int = 100) -> list[Dict[str, Any]]: with engine.begin() as conn: rows = conn.execute(text(""" WITH current_invoice AS ( SELECT DISTINCT ON (opportunity_id) id::text AS document_id, opportunity_id, document_number, external_id, total_amount, amount, currency, status, system, role, is_primary, created_at FROM commercial_documents WHERE document_kind = 'invoice' AND opportunity_id IS NOT NULL AND COALESCE(is_primary, TRUE) = TRUE AND COALESCE(role, 'current') IN ('current', 'accepted') ORDER BY opportunity_id, created_at DESC, id DESC ) SELECT t.id::text AS task_id, t.action_code, t.route, t.action, t.note, t.status AS task_status, t.priority, t.opportunity_id::text, o.title AS opportunity_title, o.customer_name, o.stage AS opportunity_stage, i.document_id, i.document_number, i.external_id, i.total_amount, i.amount, i.currency, i.status AS invoice_status, i.system AS invoice_system FROM tasks t JOIN opportunities o ON o.id = t.opportunity_id JOIN current_invoice i ON i.opportunity_id = t.opportunity_id WHERE t.status = 'pending' AND t.action_code = 'SEND_INVOICE' ORDER BY t.created_at DESC LIMIT :limit """), {"limit": int(limit)}).mappings().all() return [dict(row) for row in rows] def apply_candidate(row: Dict[str, Any]) -> int: number = str(row.get("document_number") or row.get("external_id") or "fatura").strip() amount = _money(row.get("total_amount") or row.get("amount"), str(row.get("currency") or "EUR")) action = f"Enviar fatura {number} ao cliente" note = f"Enviar fatura {number} ao cliente." + (f" Valor: {amount}." if amount else "") + " PDF/anexo disponível para envio automático." patch = { "invoice_delivery_context": { "document_id": row.get("document_id"), "document_number": number, "amount": str(row.get("total_amount") or row.get("amount") or ""), "currency": str(row.get("currency") or "EUR"), "pdf_available": True, "synced_by": "sync_invoice_delivery_context", } } with engine.begin() as conn: result = conn.execute(text(""" UPDATE tasks SET action = CAST(:action AS TEXT), note = CAST(:note AS TEXT), updated_at = now(), metadata = COALESCE(metadata, '{}'::jsonb) || CAST(:metadata_patch AS JSONB) WHERE id = CAST(:task_id AS UUID) AND status = 'pending' AND action_code = 'SEND_INVOICE' """), { "task_id": row["task_id"], "action": action, "note": note, "metadata_patch": _json(patch), }) return int(getattr(result, "rowcount", 0) or 0) def main() -> int: parser = argparse.ArgumentParser() parser.add_argument("--apply", action="store_true", help="Update matching pending SEND_INVOICE task notes/actions") parser.add_argument("--limit", type=int, default=100) args = parser.parse_args() rows = find_candidates(args.limit) print(f"SEND_INVOICE pendentes com fatura atual ligada: {len(rows)}") updated = 0 for row in rows: print("-", row["task_id"], row.get("customer_name"), row.get("document_number") or row.get("external_id"), row.get("total_amount") or row.get("amount")) if args.apply: updated += apply_candidate(row) if args.apply: print(f"updated: {updated}") else: print("dry-run apenas. Usa --apply para atualizar contexto da task.") return 0 if __name__ == "__main__": raise SystemExit(main())