Files
khatibi/admin_panel/management/commands/recalc_last_bazdid.py
T
2026-10-20 19:02:34 +03:30

271 lines
13 KiB
Python

"""بازمحاسبهٔ ``Customer.last_bazdid_dt`` از روی منبع حقیقت «آخرین بازدید».
منبع حقیقت، ``admin_panel.visit_policy.last_visit_map`` است: بزرگ‌ترین تاریخ مؤثر
در میان اقدام‌های ``bazdid + finish`` هر مشتری (تاریخ مؤثر = ``actionDateTime`` اگر
از حالا جلوتر نباشد، وگرنه ``finishDateTime``).
این دستور **فقط** ستون ``last_bazdid_dt`` مشتری را عوض می‌کند و هیچ رکورد اقدامی
(از جمله رکوردهای «هماهنگی») را نمی‌خواند/نمی‌نویسد.
python manage.py recalc_last_bazdid --dry-run # فقط گزارش
python manage.py recalc_last_bazdid # فقط اصلاح رو به جلو
python manage.py recalc_last_bazdid --exact --confirm=YES # ترمیم کامل + بکاپ خودکار
* ``--dry-run``: هیچ نوشتنی انجام نمی‌شود.
* ``--exact``: هر اختلافی (رو به جلو، رو به عقب، پرکردن مقدار خالی) اصلاح می‌شود.
بدون ``--confirm=YES`` اجرا نمی‌شود و پیش از هر نوشتن، خودکار ``VACUUM INTO``
می‌گیرد؛ اگر بکاپ شکست بخورد، هیچ چیز نوشته نمی‌شود.
* ``--clear-orphans``: مقادیری که هیچ بازدید معتبری پشتشان نیست را ``NULL`` می‌کند.
* ``--batch-size``: اندازهٔ دستهٔ خواندن مشتریان.
idempotent است: اجرای دوباره در همان حالت هیچ تغییری ایجاد نمی‌کند.
"""
import os
from datetime import datetime
from django.core.management.base import BaseCommand, CommandError
from django.db import connection, transaction
from ...models import Action, Customer
from ...visit_policy import as_aware, last_visit_map
CONFIRM_TOKEN = "YES"
SAMPLE_LIMIT = 20
def _bucket(current, new, clear_orphans):
"""دستهٔ تغییر پیشنهادی برای یک مشتری."""
if new is None:
if current is not None and clear_orphans:
return "orphan_cleared"
return "no_change"
if current is None:
return "fill_null"
if new > current:
return "forward"
if new < current:
return "backward"
return "no_change"
class Command(BaseCommand):
help = (
"بازمحاسبهٔ Customer.last_bazdid_dt از منبع حقیقت بازدید "
"(فقط bazdid + finish؛ هرگز هماهنگی)."
)
def add_arguments(self, parser):
parser.add_argument(
"--dry-run", action="store_true",
help="فقط گزارش؛ هیچ رکوردی ذخیره نمی‌شود.",
)
parser.add_argument(
"--exact", action="store_true",
help="هر اختلافی را اصلاح کن (شامل عقب‌بردن مقدار). نیازمند --confirm=YES.",
)
parser.add_argument(
"--confirm", default="",
help="برای اجرای --exact باید مقدار YES باشد.",
)
parser.add_argument(
"--clear-orphans", action="store_true",
help="مقدارهایی که هیچ اقدام بازدید معتبری پشتشان نیست را NULL کن.",
)
parser.add_argument(
"--batch-size", type=int, default=500,
help="اندازهٔ دستهٔ خواندن مشتریان (پیش‌فرض ۵۰۰).",
)
# ------------------------------------------------------------------
def handle(self, *args, **options):
dry_run = options["dry_run"]
exact = options["exact"]
clear_orphans = options["clear_orphans"]
batch_size = max(1, options["batch_size"])
if exact and not dry_run:
if options["confirm"].strip().upper() != CONFIRM_TOKEN:
raise CommandError(
"اجرای --exact بدون تاییدیه مجاز نیست. ابتدا --dry-run --exact "
"را ببینید، از دیتابیس بکاپ بگیرید و سپس با --confirm=YES اجرا کنید."
)
prefix = "[شبیه‌سازی] " if dry_run else ""
self.stdout.write(self.style.MIGRATE_HEADING(
f"{prefix}بازمحاسبهٔ آخرین بازدید مشتریان آغاز شد."
))
stats, samples, pending, suspects = self._plan(exact, clear_orphans, batch_size)
backup_path = None
if exact and not dry_run and pending:
backup_path = self._backup()
self.stdout.write(self.style.SUCCESS(f"بکاپ ساخته شد: {backup_path}"))
if not dry_run and pending:
self._apply(pending)
self._report(stats, samples, suspects, dry_run, exact, clear_orphans,
len(pending), backup_path)
# ------------------------------------------------------------------
def _plan(self, exact, clear_orphans, batch_size):
"""محاسبهٔ تغییرات پیشنهادی بدون هیچ نوشتنی."""
visits = last_visit_map()
stats = {
"scanned": 0, "with_visit": 0, "no_change": 0,
"fill_null": 0, "forward": 0, "backward": 0, "orphan_cleared": 0,
"kept_orphan": 0,
}
pending = []
samples = []
qs = Customer.objects.only("id", "name_family", "last_bazdid_dt").order_by("id")
for customer in qs.iterator(chunk_size=batch_size):
stats["scanned"] += 1
new = visits.get(customer.id)
current = as_aware(customer.last_bazdid_dt)
if new is not None:
stats["with_visit"] += 1
bucket = _bucket(current, new, clear_orphans)
if bucket == "no_change":
stats["no_change"] += 1
if new is None and current is not None:
stats["kept_orphan"] += 1
continue
if not exact and bucket in ("backward", "orphan_cleared"):
stats["no_change"] += 1 # حالت پیش‌فرض فقط رو به جلو حرکت می‌کند
continue
stats[bucket] += 1
pending.append((customer.id, new))
if len(samples) < SAMPLE_LIMIT:
samples.append((customer.id, customer.name_family, current, new, bucket))
return stats, samples, pending, self._hamahangi_suspect_count()
def _apply(self, pending):
"""اعمال تغییرات؛ فقط ستون ``last_bazdid_dt`` مشتری نوشته می‌شود."""
with transaction.atomic():
for customer_id, target in pending:
Customer.objects.filter(pk=customer_id).update(last_bazdid_dt=target)
# ------------------------------------------------------------------
def _backup(self):
"""یک نسخهٔ سالم از دیتابیس با timestamp می‌سازد؛ خطا یعنی توقف کامل اجرا.
اول ``VACUUM INTO`` (سریع و اتمی) و اگر SQLite اجازه نداد (مثلاً داخل یک
تراکنش باز)، نسخهٔ سازگار با روش رسمی ``sqlite3.Connection.backup``
گرفته می‌شود تا در هر دو حالت یک فایل سالم حاصل شود.
"""
db_path = str(connection.settings_dict.get("NAME") or "")
if not db_path:
raise CommandError("مسیر دیتابیس مشخص نیست؛ بکاپ ممکن نشد.")
target_dir = os.path.dirname(os.path.abspath(db_path))
stamp = datetime.now().strftime("%Y%m%d-%H%M%S")
backup_path = os.path.join(target_dir, f"backup-{stamp}.sqlite3")
try:
with connection.cursor() as cursor:
cursor.execute("VACUUM INTO %s", [backup_path])
except Exception as vacuum_error: # noqa: BLE001
try:
self._copy_database(db_path, backup_path)
except Exception as copy_error: # noqa: BLE001
raise CommandError(
"بکاپ گرفتن شکست خورد؛ هیچ تغییری اعمال نشد: "
f"{vacuum_error} / {copy_error}"
)
if not os.path.exists(backup_path) or os.path.getsize(backup_path) == 0:
raise CommandError("فایل بکاپ ساخته نشد یا خالی است؛ اجرا متوقف شد.")
return backup_path
def _copy_database(self, source_path, target_path):
"""نسخهٔ پشتیبان سازگار از فایل SQLite (روش رسمی ``backup``)."""
import sqlite3
source = sqlite3.connect(self._source_uri(source_path), uri=True)
try:
target = sqlite3.connect(target_path)
try:
source.backup(target)
finally:
target.close()
finally:
source.close()
@staticmethod
def _source_uri(source_path):
"""URI فقط‌خواندنی؛ اگر مسیر خودش URI باشد (دیتابیس تست) دست‌نخورده می‌ماند."""
if source_path.startswith("file:"):
return source_path
return f"file:{source_path}?mode=ro"
def _hamahangi_suspect_count(self):
"""تعداد مشتریانی که مقدارشان دقیقاً برابر تاریخ یک رکورد هماهنگی است.
تشخیص **تقریبی** است: ``last_bazdid_dt`` هیچ ارجاعی به رکورد منبع ندارد،
پس برابری مقدار فقط «نشانه» است، نه اثبات.
"""
stamps = {}
rows = Action.objects.filter(
actionType=Action.ActionType.HAMAHANGI,
).values_list("customer_id", "actionDateTime", "finishDateTime")
for customer_id, action_dt, finish_dt in rows.iterator():
stamps.setdefault(customer_id, set()).update(
v for v in (as_aware(action_dt), as_aware(finish_dt)) if v is not None
)
if not stamps:
return 0
count = 0
qs = Customer.objects.filter(id__in=list(stamps)).only("id", "last_bazdid_dt")
for customer in qs.iterator():
if as_aware(customer.last_bazdid_dt) in stamps[customer.id]:
count += 1
return count
def _report(self, stats, samples, suspects, dry_run, exact, clear_orphans,
changed, backup_path=None):
style = self.style
label = "[شبیه‌سازی] " if dry_run else ""
mode = "exact" if exact else "default(forward-only)"
self.stdout.write(style.MIGRATE_LABEL(
f"\n{label}گزارش بازمحاسبهٔ آخرین بازدید (حالت: {mode}):"
))
self.stdout.write(f" بررسی‌شده (کل مشتریان): {stats['scanned']:,}")
self.stdout.write(f" دارای بازدید معتبر: {stats['with_visit']:,}")
self.stdout.write(f" بدون تغییر: {stats['no_change']:,}")
self.stdout.write(f" پر کردن مقدار خالی: {stats['fill_null']:,}")
self.stdout.write(f" اصلاح رو به جلو: {stats['forward']:,}")
self.stdout.write(f" اصلاح رو به عقب: {stats['backward']:,}")
if clear_orphans:
self.stdout.write(f" پاک‌سازی یتیم: {stats['orphan_cleared']:,}")
else:
self.stdout.write(
f" بدون بازدید ولی دارای مقدار (دست‌نخورده): {stats['kept_orphan']:,}"
)
self.stdout.write(style.WARNING(
f" نشانهٔ آلودگی هماهنگی (تقریبی): {suspects:,}"
))
if samples:
self.stdout.write("\n نمونهٔ تغییرات (id | مشتری | فعلی → جدید | دسته):")
for customer_id, name, current, new, bucket in samples:
self.stdout.write(f" {customer_id} | {name} | {current} → {new} | {bucket}")
if dry_run:
self.stdout.write(style.WARNING(
f"\n[شبیه‌سازی] {changed:,} تغییر پیشنهادی. هیچ رکوردی ذخیره نشد."
))
elif changed:
self.stdout.write(style.SUCCESS(
f"\nانجام شد: {changed:,} مشتری به‌روزرسانی شد (فقط ستون last_bazdid_dt)."
))
else:
self.stdout.write(style.SUCCESS(
"\nانجام شد: هیچ اصلاحی لازم نبود (داده از قبل هم‌راستا است)."
))
if backup_path:
self.stdout.write(f" بکاپ: {backup_path}")