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

157 lines
8.2 KiB
Python

"""بازمحاسبهٔ ``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انجام شد: هیچ اصلاحی لازم نبود (داده از قبل درست است)."
))