Files
clientflow_backend/scripts/reset_reconciliation_generated.py
2026-06-09 22:55:58 +01:00

134 lines
6.1 KiB
Python
Executable File

#!/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()