134 lines
6.1 KiB
Python
Executable File
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()
|