"""بازمحاسبهٔ ``Customer.last_bazdid_dt`` از روی اقدام‌های «بازدیدِ تمام‌شده». فیلد ``last_bazdid_dt`` «آخرین بازدید» مشتری است و صفحهٔ ``/panel/entezar/`` بر پایهٔ آن مشتریانِ معوق (بیش از N ماه بازدید نشده) را لیست می‌کند. این مقدار باید برابر «بزرگ‌ترین ``finishDateTime`` میان اقدام‌های ``actionType='bazdid'`` و ``status='finish'`` هر مشتری» باشد — همان چیزی که ویوهای ``customerNewAction``/``customerEditAction`` می‌نویسند. اگر رکوردی از راهی غیر از این ویوها وارد شود (اسکریپت، ایمپورت، ویرایش دستی دیتابیس)، این فیلد عقب می‌ماند و مشتریِ واقعاً بازدیدشده اشتباهاً «معوق» شمرده می‌شود. این دستور آن واگرایی (drift) را از منبع حقیقت (جدول Action) ترمیم می‌کند. python manage.py recalc_last_bazdid --dry-run # فقط گزارش، بدون هیچ تغییری python manage.py recalc_last_bazdid # اعمال اصلاحات مشخصات: * idempotent — اجرای دوباره پس از یک‌بار اجرا هیچ تغییری ایجاد نمی‌کند. * پیش‌فرض محافظه‌کارانه است: فقط مقدار را **به‌روز** می‌کند و هیچ‌وقت روی مقادیری که اقدامِ پشتیبان ندارند دست نمی‌گذارد (مگر با ``--clear-orphans``). * روی دیتابیس production هم قابل اجرا است. """ from django.core.management.base import BaseCommand from django.db import transaction from django.db.models import Max from ...models import Action, Customer class Command(BaseCommand): help = ( "بازمحاسبهٔ Customer.last_bazdid_dt از MAX(finishDateTime) اقدام‌های " "بازدیدِ تمام‌شده (ترمیم واگرایی داده؛ idempotent)." ) def add_arguments(self, parser): parser.add_argument( "--dry-run", action="store_true", help="فقط گزارش کن که چه چیزی تغییر می‌کند؛ هیچ رکوردی ذخیره نمی‌شود.", ) 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"] clear_orphans = options["clear_orphans"] batch_size = max(1, options["batch_size"]) prefix = "[شبیه‌سازی] " if dry_run else "" self.stdout.write(self.style.MIGRATE_HEADING( f"{prefix}بازمحاسبهٔ آخرین بازدید مشتریان آغاز شد." )) # منبع حقیقت: بزرگ‌ترین زمانِ پایانِ بازدید در اقدام‌های تمام‌شدهٔ هر مشتری. # (Max مقادیر NULL را نادیده می‌گیرد؛ مشتریِ بدون بازدید به‌کل در نقشه نمی‌آید.) latest_rows = ( Action.objects .filter(actionType="bazdid", status="finish") .exclude(customer_id__isnull=True) .values("customer_id") .annotate(mx=Max("finishDateTime")) ) latest = { row["customer_id"]: row["mx"] for row in latest_rows.iterator(chunk_size=batch_size) if row["mx"] is not None } stats = { "scanned": 0, # کل مشتریان بررسی‌شده "with_visit": 0, # مشتریان دارای بازدیدِ تمام‌شده "updated": 0, # مقدار اصلاح/به‌روز شد "moved_forward": 0, # جدیدتر شد ⇒ از لیست معوق‌ها خارج می‌شود "moved_backward": 0, # عقب‌تر شد "set_null": 0, # با --clear-orphans خالی شد "already_ok": 0, # از قبل درست بود "kept_orphan": 0, # مقدار داشت ولی اقدامِ پشتیبان نداشت (دست‌نخورده ماند) "null_and_no_action": 0, # NULL و بدون اقدام (طبیعی؛ کاری لازم نیست) } pending = [] # (customer_id, target_value) برای اعمال گروهی qs = Customer.objects.only("id", "last_bazdid_dt").order_by("id") for customer in qs.iterator(chunk_size=batch_size): stats["scanned"] += 1 mx = latest.get(customer.id) current = customer.last_bazdid_dt if mx is not None: stats["with_visit"] += 1 if current == mx: stats["already_ok"] += 1 continue if current is None or current < mx: stats["moved_forward"] += 1 else: stats["moved_backward"] += 1 stats["updated"] += 1 pending.append((customer.id, mx)) else: # هیچ بازدیدِ تمام‌شده‌ای پشتوانهٔ این مقدار نیست. if current is None: stats["null_and_no_action"] += 1 elif clear_orphans: stats["set_null"] += 1 pending.append((customer.id, None)) else: stats["kept_orphan"] += 1 if not dry_run and pending: with transaction.atomic(): for customer_id, target in pending: Customer.objects.filter(pk=customer_id).update( last_bazdid_dt=target ) self._report(stats, dry_run, clear_orphans) def _report(self, stats, dry_run, clear_orphans): style = self.style label = "[شبیه‌سازی] " if dry_run else "" self.stdout.write(style.MIGRATE_LABEL(f"\n{label}گزارش بازمحاسبهٔ آخرین بازدید:")) self.stdout.write(f" بررسی‌شده (کل مشتریان): {stats['scanned']:,}") self.stdout.write(f" دارای بازدیدِ تمام‌شده: {stats['with_visit']:,}") self.stdout.write(f" از قبل درست: {stats['already_ok']:,}") self.stdout.write(f" نیازمند اصلاح: {stats['updated']:,}") self.stdout.write(f" → جدیدتر شد (خروج از معوق): {stats['moved_forward']:,}") self.stdout.write(f" → عقب‌تر شد: {stats['moved_backward']:,}") if clear_orphans: self.stdout.write(f" → خالی شد (بدون اقدام): {stats['set_null']:,}") else: self.stdout.write( f" بدون اقدام ولی دارای مقدار (دست‌نخورده): {stats['kept_orphan']:,}" ) self.stdout.write( f" NULL و بدون اقدام (طبیعی): {stats['null_and_no_action']:,}" ) if dry_run: self.stdout.write(style.WARNING( "\n[حالت شبیه‌سازی] هیچ رکوردی ذخیره نشد. برای اعمال، بدون --dry-run اجرا کن." )) elif stats["updated"]: self.stdout.write(style.SUCCESS( f"\nانجام شد: {stats['updated']:,} رکورد به‌روزرسانی شد." )) else: self.stdout.write(style.SUCCESS( "\nانجام شد: هیچ اصلاحی لازم نبود (داده از قبل درست است)." ))