improve entezar bazdid

This commit is contained in:
javad committed 2026-10-20 19:02:34 +03:30
1 parent 1fd27d0445
commit 2aee85593f
18 files changed
+10271 -7558

No files matched your search

@@ -0,0 +1,81 @@
"""پاک‌سازی کلیدهای «آلوده» دفتر یکتاسازی یادآوری بازدید.
چرا لازم است؟ کلید یکتاسازیِ یادآوری بازدید، در نسخهٔ قدیمی ``visit:<id>:<شمارهٔ چرخه>``
بود که شمارهٔ چرخه با تقسیم روز بر یک عدد ثابت محاسبه می‌شد. وقتی آن عدد صفر می‌شد،
عددهای بی‌معنا (مثل ``visit:1601:24663``) ساخته و برای همیشه در ``SMSDedup`` ثبت
می‌شدند؛ نتیجه‌اش قفل دائمی یادآوری برای همان مشتری بود.
قاعدهٔ جدید کلید را با «موعد تقویمی» می‌سازد: ``visit:<id>:<YYYY-MM>``. کلیدهای قدیمی
هرگز با کلید جدید برخورد نمی‌کنند، بنابراین بی‌ضرر ولی بازدارنده‌اند.
این دستور **فقط** کلیدهای بی‌معنا/قدیمیِ دفتر را حذف می‌کند و هیچ مشتری، اقدام یا
پیامکی را تغییر نمی‌دهد.
python manage.py cleanup_visit_dedup --dry-run # فقط گزارش
python manage.py cleanup_visit_dedup # حذف کلیدهای بی‌معنا (cycle > حد)
python manage.py cleanup_visit_dedup --all-legacy # حذف همهٔ کلیدهای قالب قدیمی
"""
import re
from django.core.management.base import BaseCommand
from django.db import transaction
from ...models import SMSDedup
# سقف منطقی شمارهٔ چرخه؛ هر عدد بزرگ‌تر از این، نتیجهٔ باگ تقسیم قدیمی است.
MAX_SANE_CYCLE = 50
LEGACY_PATTERN = re.compile(r"^visit:(\d+):(\d+)$")
NEW_PATTERN = re.compile(r"^visit:(\d+):(\d{4}-\d{2})$")
class Command(BaseCommand):
help = "حذف کلیدهای آلوده/قدیمی دفتر یکتاسازی یادآوری بازدید (بدون تغییر دادهٔ مشتری)."
def add_arguments(self, parser):
parser.add_argument("--dry-run", action="store_true",
help="فقط گزارش؛ چیزی حذف نمی‌شود.")
parser.add_argument("--all-legacy", action="store_true",
help="همهٔ کلیدهای قالب قدیمی (visit:<id>:<عدد>) حذف شوند، "
"نه فقط عددهای بی‌معنا.")
def handle(self, *args, **options):
dry_run = options["dry_run"]
all_legacy = options["all_legacy"]
absurd, sane_legacy, current = [], [], []
for key in SMSDedup.objects.filter(key__startswith="visit:").values_list(
"key", flat=True
).iterator():
if NEW_PATTERN.match(key):
current.append(key)
continue
match = LEGACY_PATTERN.match(key)
if match:
cycle = int(match.group(2))
(absurd if cycle > MAX_SANE_CYCLE else sane_legacy).append(key)
else:
absurd.append(key)
doomed = absurd + (sane_legacy if all_legacy else [])
self.stdout.write(self.style.MIGRATE_LABEL("گزارش کلیدهای یادآوری بازدید:"))
self.stdout.write(f" کلیدهای قالب جدید (موعد تقویمی): {len(current):,}")
self.stdout.write(f" کلیدهای بی‌معنا (چرخه > {MAX_SANE_CYCLE} یا ناشناخته): {len(absurd):,}")
self.stdout.write(f" کلیدهای قدیمیِ سالم (چرخه ≤ {MAX_SANE_CYCLE}): {len(sane_legacy):,}")
if absurd:
self.stdout.write(" نمونهٔ کلیدهای بی‌معنا: " + ", ".join(absurd[:5]))
self.stdout.write(f" قابل حذف در این اجرا: {len(doomed):,}")
if dry_run:
self.stdout.write(self.style.WARNING(
"\n[شبیه‌سازی] هیچ کلیدی حذف نشد."
))
return
if not doomed:
self.stdout.write(self.style.SUCCESS("\nانجام شد: چیزی برای حذف نبود."))
return
with transaction.atomic():
deleted, _ = SMSDedup.objects.filter(key__in=doomed).delete()
self.stdout.write(self.style.SUCCESS(
f"\nانجام شد: {deleted:,} کلید حذف شد (فقط از دفتر یکتاسازی)."
))
@@ -1,156 +1,270 @@
"""بازمحاسبهٔ ``Customer.last_bazdid_dt`` از روی اقدام‌های «بازدیدِ تمام‌شده».
"""بازمحاسبهٔ ``Customer.last_bazdid_dt`` از روی منبع حقیقت «آخرین بازدید».
فیلد ``last_bazdid_dt`` «آخرین بازدید» مشتری است و صفحهٔ ``/panel/entezar/`` بر پایهٔ
آن مشتریانِ معوق (بیش از N ماه بازدید نشده) را لیست می‌کند. این مقدار باید برابر
«بزرگ‌ترین ``finishDateTime`` میان اقدام‌های ``actionType='bazdid'`` و ``status='finish'``
هر مشتری» باشد — همان چیزی که ویوهای ``customerNewAction``/``customerEditAction``
می‌نویسند.
منبع حقیقت، ``admin_panel.visit_policy.last_visit_map`` است: بزرگ‌ترین تاریخ مؤثر
در میان اقدام‌های ``bazdid + finish`` هر مشتری (تاریخ مؤثر = ``actionDateTime`` اگر
از حالا جلوتر نباشد، وگرنه ``finishDateTime``).
اگر رکوردی از راهی غیر از این ویوها وارد شود (اسکریپت، ایمپورت، ویرایش دستی دیتابیس)،
این فیلد عقب می‌ماند و مشتریِ واقعاً بازدیدشده اشتباهاً «معوق» شمرده می‌شود. این
دستور آن واگرایی (drift) را از منبع حقیقت (جدول Action) ترمیم می‌کند.
این دستور **فقط** ستون ``last_bazdid_dt`` مشتری را عوض می‌کند و هیچ رکورد اقدامی
(از جمله رکوردهای «هماهنگی») را نمی‌خواند/نمی‌نویسد.
python manage.py recalc_last_bazdid --dry-run # فقط گزارش، بدون هیچ تغییری
python manage.py recalc_last_bazdid # اعمال اصلاحات
python manage.py recalc_last_bazdid --dry-run # فقط گزارش
python manage.py recalc_last_bazdid # فقط اصلاح رو به جلو
python manage.py recalc_last_bazdid --exact --confirm=YES # ترمیم کامل + بکاپ خودکار
مشخصات:
* idempotent — اجرای دوباره پس از یک‌بار اجرا هیچ تغییری ایجاد نمی‌کند.
* پیش‌فرض محافظه‌کارانه است: فقط مقدار را **به‌روز** می‌کند و هیچ‌وقت روی مقادیری که
اقدامِ پشتیبان ندارند دست نمی‌گذارد (مگر با ``--clear-orphans``).
* روی دیتابیس production هم قابل اجرا است.
* ``--dry-run``: هیچ نوشتنی انجام نمی‌شود.
* ``--exact``: هر اختلافی (رو به جلو، رو به عقب، پرکردن مقدار خالی) اصلاح می‌شود.
بدون ``--confirm=YES`` اجرا نمی‌شود و پیش از هر نوشتن، خودکار ``VACUUM INTO``
می‌گیرد؛ اگر بکاپ شکست بخورد، هیچ چیز نوشته نمی‌شود.
* ``--clear-orphans``: مقادیری که هیچ بازدید معتبری پشتشان نیست را ``NULL`` می‌کند.
* ``--batch-size``: اندازهٔ دستهٔ خواندن مشتریان.
idempotent است: اجرای دوباره در همان حالت هیچ تغییری ایجاد نمی‌کند.
"""
from django.core.management.base import BaseCommand
from django.db import transaction
from django.db.models import Max
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 از MAX(finishDateTime) اقدام‌های "
"بازدیدِ تمام‌شده (ترمیم واگرایی داده؛ idempotent)."
"بازمحاسبهٔ Customer.last_bazdid_dt از منبع حقیقت بازدید "
"(فقط bazdid + finish؛ هرگز هماهنگی)."
)
def add_arguments(self, parser):
parser.add_argument(
"--dry-run", action="store_true",
help="فقط گزارش کن که چه چیزی تغییر می‌کند؛ هیچ رکوردی ذخیره نمی‌شود.",
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 کن "
"(پیش‌فرض: دست‌نخورده بمانند). فقط اگر مطمئنی این ستون آلوده است.",
help="مقدارهایی که هیچ اقدام بازدید معتبری پشتشان نیست را NULL کن.",
)
parser.add_argument(
"--batch-size", type=int, default=500,
help="اندازهٔ دستهٔ خواندن مشتریان در هر گام (پیش‌فرض ۵۰۰).",
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}بازمحاسبهٔ آخرین بازدید مشتریان آغاز شد."
))
# منبع حقیقت: بزرگ‌ترین زمانِ پایانِ بازدید در اقدام‌های تمام‌شدهٔ هر مشتری.
# (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, samples, pending, suspects = self._plan(exact, clear_orphans, batch_size)
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
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:
with transaction.atomic():
for customer_id, target in pending:
Customer.objects.filter(pk=customer_id).update(
last_bazdid_dt=target
)
self._apply(pending)
self._report(stats, dry_run, clear_orphans)
self._report(stats, samples, suspects, dry_run, exact, clear_orphans,
len(pending), backup_path)
def _report(self, stats, dry_run, clear_orphans):
# ------------------------------------------------------------------
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 ""
self.stdout.write(style.MIGRATE_LABEL(f"\n{label}گزارش بازمحاسبهٔ آخرین بازدید:"))
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['already_ok']:,}")
self.stdout.write(f" نیازمند اصلاح: {stats['updated']:,}")
self.stdout.write(f" → جدیدتر شد (خروج از معوق): {stats['moved_forward']:,}")
self.stdout.write(f" → عقب‌تر شد: {stats['moved_backward']:,}")
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['set_null']:,}")
self.stdout.write(f" پاک‌سازی یتیم: {stats['orphan_cleared']:,}")
else:
self.stdout.write(
f" بدون اقدام ولی دارای مقدار (دست‌نخورده): {stats['kept_orphan']:,}"
f" بدون بازدید ولی دارای مقدار (دست‌نخورده): {stats['kept_orphan']:,}"
)
self.stdout.write(
f" NULL و بدون اقدام (طبیعی): {stats['null_and_no_action']:,}"
)
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(
"\n[حالت شبیه‌سازی] هیچ رکوردی ذخیره نشد. برای اعمال، بدون --dry-run اجرا کن."
f"\n[شبیه‌سازی] {changed:,} تغییر پیشنهادی. هیچ رکوردی ذخیره نشد."
))
elif stats["updated"]:
elif changed:
self.stdout.write(style.SUCCESS(
f"\nانجام شد: {stats['updated']:,} رکورد به‌روزرسانی شد."
f"\nانجام شد: {changed:,} مشتری به‌روزرسانی شد (فقط ستون last_bazdid_dt)."
))
else:
self.stdout.write(style.SUCCESS(
"\nانجام شد: هیچ اصلاحی لازم نبود (داده از قبل درست است)."
"\nانجام شد: هیچ اصلاحی لازم نبود (داده از قبل هم‌راستا است)."
))
if backup_path:
self.stdout.write(f" بکاپ: {backup_path}")