271 lines
13 KiB
Python
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}")
|