improve entezar bazdid
This commit is contained in:
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}")
|
||||
Reference in new issue
Block a user