#!/usr/bin/env python3 """Reset generated reconciliation staging items so they can be rebuilt. Safe by default: dry-run only and only affects generated external candidates from Jasmin/Odoo/Packlink in open/needs_review/ignored states. It does not remove opportunities, commercial documents, payment proofs, operation links or external system records. Examples: PYTHONPATH=. python scripts/reset_reconciliation_generated.py PYTHONPATH=. python scripts/reset_reconciliation_generated.py --apply PYTHONPATH=. python scripts/reset_reconciliation_generated.py --source jasmin --source odoo --apply PYTHONPATH=. python scripts/reset_reconciliation_generated.py --days 30 --apply PYTHONPATH=. python scripts/reset_reconciliation_generated.py --include-manual --apply """ from __future__ import annotations import argparse from datetime import datetime, timedelta, timezone from typing import Any, Dict, List from sqlalchemy import text from app.db import engine, init_db from app.reconciliation_service import ensure_reconciliation_schema DEFAULT_SOURCES = ["jasmin", "odoo", "packlink"] DEFAULT_STATUSES = ["open", "needs_review", "ignored"] def _backup_table_name() -> str: return "reconciliation_items_reset_backup_" + datetime.now(timezone.utc).strftime("%Y%m%d_%H%M%S") def _build_where(args: argparse.Namespace) -> tuple[str, Dict[str, Any]]: sources = list(args.source or DEFAULT_SOURCES) if args.include_manual and "manual" not in sources: sources.append("manual") statuses = list(args.status or DEFAULT_STATUSES) params: Dict[str, Any] = {"sources": sources, "statuses": statuses} clauses = [ "source_system = ANY(CAST(:sources AS TEXT[]))", "status = ANY(CAST(:statuses AS TEXT[]))", ] # Extra guard: never touch items already linked to an opportunity unless # the operator explicitly changes the status list and unlinks manually. clauses.append("opportunity_id IS NULL") if args.days is not None: days = max(int(args.days), 1) cutoff = (datetime.now(timezone.utc).date() - timedelta(days=days - 1)).isoformat() params["cutoff"] = cutoff clauses.append("(document_date IS NULL OR document_date >= CAST(:cutoff AS DATE))") return " AND ".join(clauses), params def _summarize(where_sql: str, params: Dict[str, Any], limit: int) -> Dict[str, Any]: with engine.begin() as conn: counts = conn.execute(text(f""" SELECT source_system, external_type, status, COUNT(*) AS total FROM reconciliation_items WHERE {where_sql} GROUP BY source_system, external_type, status ORDER BY source_system, external_type, status """), params).mappings().all() rows = conn.execute(text(f""" SELECT id::text, source_system, external_type, status, document_number, customer_name, customer_tax_id, document_date, amount, title FROM reconciliation_items WHERE {where_sql} ORDER BY updated_at DESC, created_at DESC LIMIT :limit """), {**params, "limit": int(limit)}).mappings().all() return {"counts": [dict(r) for r in counts], "items": [dict(r) for r in rows]} def _apply_reset(where_sql: str, params: Dict[str, Any]) -> Dict[str, Any]: backup_table = _backup_table_name() # backup_table is generated internally from digits/underscore only. with engine.begin() as conn: total = conn.execute(text(f"SELECT COUNT(*) FROM reconciliation_items WHERE {where_sql}"), params).scalar() or 0 conn.execute(text(f"CREATE TABLE {backup_table} AS SELECT * FROM reconciliation_items WHERE {where_sql}"), params) deleted = conn.execute(text(f"DELETE FROM reconciliation_items WHERE {where_sql}"), params).rowcount or 0 return {"matched": int(total), "deleted": int(deleted), "backup_table": backup_table} def main() -> None: parser = argparse.ArgumentParser(description="Reset generated reconciliation candidates and keep a DB backup table.") parser.add_argument("--source", action="append", choices=["jasmin", "odoo", "packlink", "manual"], help="source_system to reset; repeatable. Default: jasmin, odoo, packlink") parser.add_argument("--status", action="append", choices=["open", "needs_review", "ignored"], help="status to reset; repeatable. Default: open, needs_review, ignored") parser.add_argument("--days", type=int, help="only reset candidates inside the last N days; default is all dates") parser.add_argument("--include-manual", action="store_true", help="also include source_system=manual; use with care") parser.add_argument("--limit", type=int, default=50, help="preview sample size") parser.add_argument("--apply", action="store_true", help="delete matched staging rows after creating a backup table") args = parser.parse_args() init_db() ensure_reconciliation_schema() where_sql, params = _build_where(args) summary = _summarize(where_sql, params, args.limit) print("Alvo do reset:") print(f" fontes: {', '.join(params['sources'])}") print(f" estados: {', '.join(params['statuses'])}") print(" proteção: opportunity_id IS NULL") if args.days is not None: print(f" janela: >= {params['cutoff']} ({args.days} dias)") print("\nContagens:") if not summary["counts"]: print(" 0 itens encontrados") for row in summary["counts"]: print(f" {row['source_system']} · {row['external_type']} · {row['status']}: {row['total']}") print("\nAmostra:") for row in summary["items"]: print( f" {row.get('document_date') or '-'} · {row.get('source_system')} · {row.get('external_type')} · " f"{row.get('status')} · {row.get('document_number') or '-'} · {row.get('customer_name') or '-'}" ) if not args.apply: print("\nDry-run. Para aplicar: repetir com --apply") return result = _apply_reset(where_sql, params) print("\nReset aplicado:") print(f" encontrados: {result['matched']}") print(f" apagados: {result['deleted']}") print(f" backup: {result['backup_table']}") if __name__ == "__main__": main()