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