"""استخراج و تجمیع شماره‌های موبایل هر مشتری در ``Customer.sms_primary_phones``. منابع بررسی (به همین ترتیب اولویت برای حذف تکراری‌ها): ``phone1`` → ``phone2`` → ``phone3`` → رکوردهای ``PhoneNumber`` بر اساس ``id``. **امنیت داده:** فیلدهای مبدأ، سایر مشخصات مشتری و رکوردهای ``PhoneNumber`` هرگز ویرایش/حذف نمی‌شوند. تنها ستونی که نوشته می‌شود ``sms_primary_phones`` است. **قالب ذخیره‌شده:** ``+989xxxxxxxxx`` — همان قالب متعارف پروژه که ``admin_panel.sms.phones.stored_primary_normalized`` انتظار دارد و تمام تست‌های موجود (``tests/test_sms.py``، ``tests/test_customers.py``) روی آن قفل شده‌اند. اعتبارسنجی ورودی با استاندارد ۱۱ رقمی ``09xxxxxxxxx`` انجام می‌شود، ولی مقدارِ ذخیره‌شده همان ``+98…`` است؛ در غیر این‌صورت تطبیق «شمارهٔ اصلی» در پنل و ``resolved_recipients`` هیچ‌وقت نمی‌گیرد و داده بی‌اثر می‌شود. **تضمین‌ها:** * پیش‌فرض dry-run است؛ نوشتن فقط با ``--apply``. * هر batch در ``transaction.atomic`` اختصاصی؛ خطا ⇒ فقط همان batch برمی‌گردد. * Keyset pagination (``id__gt``) + checkpoint فایل پس از commit موفق هر batch. * رکوردِ بدون تغییر نوشته نمی‌شود؛ مقدار موجودِ غیرخالی هرگز با «خالی» پاک نمی‌شود. * سرریز طول (> ``max_length``) ⇒ رکورد دست‌نخورده + ثبت در گزارش. اجرا: python manage.py normalize_customer_sms_phones --dry-run python manage.py normalize_customer_sms_phones --apply python manage.py normalize_customer_sms_phones --apply --resume python manage.py normalize_customer_sms_phones --apply --start-id 5000 python manage.py normalize_customer_sms_phones --apply --reset-checkpoint """ import json import os from pathlib import Path from django.conf import settings from django.core.management.base import BaseCommand, CommandError from django.db import transaction from persiantools import digits from admin_panel.models import Customer from admin_panel.sms.phones import normalize_primary_values from integrations.sms.normalize import normalize_mobile DEFAULT_BATCH_SIZE = 100 DEFAULT_CHECKPOINT_NAME = ".sms_normalization_checkpoint.json" # مرز طول از خود مدل خوانده می‌شود تا با تغییر مدل هم‌راستا بماند. FIELD_MAX_LENGTH = Customer._meta.get_field("sms_primary_phones").max_length LEGACY_FIELDS = ("phone1", "phone2", "phone3") # حداکثر تعداد شناسهٔ سرریز که در گزارش چاپ می‌شود (بقیه فقط شمرده می‌شوند). OVERFLOW_SAMPLE = 10 # ارقام «پیش‌شمارهٔ ثابت»: 021، 031، 051، 071، … (برای گزارش، نه تصمیم داده). LANDLINE_SECOND_DIGITS = set("12345678") def digit_string(raw): """فقط ارقامِ یک مقدار ورودی، با تبدیل ارقام فارسی/عربی به انگلیسی.""" if raw is None: return "" text = digits.fa_to_en(digits.ar_to_fa(str(raw).strip())) return "".join(ch for ch in text if ch.isdigit()) def is_landline(raw): """آیا مقدار ورودی شکل شمارهٔ ثابت دارد؟ (فقط برای تفکیک در گزارش) شامل شمارهٔ ثابت محلی ۸رقمی (مثل ``57387608``) و شمارهٔ ثابت با پیش‌شماره (مثل ``02112345678`` / ``03133445566``) می‌شود — همان قاعدهٔ مستندشدهٔ پروژه در ``fix_customer_phones.md``. """ text = digit_string(raw) if len(text) == 8: return True if len(text) not in (10, 11) or not text.startswith("0"): return False return text[1] in LANDLINE_SECOND_DIGITS class Command(BaseCommand): help = ( "استخراج و تجمیع شماره‌های موبایل معتبر هر مشتری در sms_primary_phones " "(پیش‌فرض dry-run؛ اتمیکِ batch به batch با checkpoint قابل ادامه)." ) def add_arguments(self, parser): parser.add_argument( "--batch-size", type=int, default=DEFAULT_BATCH_SIZE, help=f"تعداد مشتریان در هر batch (پیش‌فرض {DEFAULT_BATCH_SIZE}).", ) parser.add_argument( "--dry-run", action="store_true", help="صریحاً حالت شبیه‌سازی (پیش‌فرض همین است؛ همراه --apply مجاز نیست).", ) parser.add_argument( "--apply", action="store_true", help="نوشتن واقعی در دیتابیس. بدون این گزینه فقط شبیه‌سازی می‌شود.", ) parser.add_argument( "--resume", action="store_true", help="ادامه از آخرین شناسهٔ ثبت‌شده در فایل وضعیت.", ) parser.add_argument( "--start-id", type=int, default=None, help="شروع دستی از یک شناسه (مشتریان با id بزرگ‌تر پردازش می‌شوند). " "بر --resume اولویت دارد.", ) parser.add_argument( "--reset-checkpoint", action="store_true", help="پاک‌کردن فایل وضعیت و شروع از ابتدا (در dry-run نادیده گرفته می‌شود).", ) parser.add_argument( "--checkpoint", default=None, help=f"مسیر فایل وضعیت (پیش‌فرض: {DEFAULT_CHECKPOINT_NAME} در ریشهٔ پروژه).", ) # ------------------------------------------------------------------ main def handle(self, *args, **options): batch_size = max(1, options["batch_size"]) apply_changes = options["apply"] checkpoint_path = Path( options["checkpoint"] or (Path(settings.BASE_DIR) / DEFAULT_CHECKPOINT_NAME) ) self.stdout.write( f"حالت: {'اجرای واقعی (--apply)' if apply_changes else 'شبیه‌سازی (dry-run)'} | " f"batch={batch_size} | فایل وضعیت: {checkpoint_path}" ) if options["dry_run"] and apply_changes: raise CommandError("--dry-run و --apply با هم ناسازگارند.") self._handle_reset(checkpoint_path, apply_changes, options["reset_checkpoint"]) last_id, source = self._start_point(checkpoint_path, options) self.stdout.write(f"شروع از مشتریان با id > {last_id} ({source})") counters = { "scanned": 0, "updated": 0, "unchanged": 0, "kept_nonempty": 0, "filtered_landline": 0, "filtered_invalid": 0, "duplicates": 0, "overflow": 0, "batches": 0, } overflow_ids = [] try: last_id = self._run( last_id, batch_size, apply_changes, checkpoint_path, counters, overflow_ids) except KeyboardInterrupt: self.stdout.write(self.style.WARNING( f"متوقف شد (Ctrl+C). آخرین شناسهٔ ثبت‌شده: {last_id}. " "برای ادامه: --apply --resume" )) return except Exception as exc: # noqa: BLE001 - توقف امن با گزارش روشن raise CommandError( f"اجرا متوقف شد؛ batch جاری rollback شد و آخرین شناسهٔ معتبر " f"{last_id} است: {exc}" ) self._print_report(counters, overflow_ids, apply_changes, last_id) # ------------------------------------------------------------ start/lock def _handle_reset(self, checkpoint_path, apply_changes, reset_requested): if not reset_requested: return if not checkpoint_path.exists(): self.stdout.write("فایل وضعیت وجود نداشت؛ چیزی برای پاک‌کردن نبود.") return if not apply_changes: self.stdout.write("--reset-checkpoint در شبیه‌سازی نادیده گرفته شد (فایل وضعیت دست‌نخورده).") return try: checkpoint_path.unlink() except OSError as exc: raise CommandError(f"فایل وضعیت پاک نشد ({checkpoint_path}): {exc}") self.stdout.write(f"فایل وضعیت پاک شد: {checkpoint_path}") def _start_point(self, checkpoint_path, options): """(last_id, توضیح منبع) — اولویت: --start-id سپس --resume سپس ابتدا.""" if options["start_id"] is not None: return max(0, options["start_id"]), "--start-id" if options["resume"]: if not checkpoint_path.exists(): raise CommandError( f"--resume داده شد ولی فایل وضعیت وجود ندارد: {checkpoint_path}" ) data = self._read_checkpoint(checkpoint_path) value = data.get("last_id") if not isinstance(value, int) or value < 0: raise CommandError( f"فایل وضعیت معتبر نیست (last_id={value!r}): {checkpoint_path}" ) return value, "--resume" return 0, "ابتدا" def _read_checkpoint(self, path): try: data = json.loads(path.read_text(encoding="utf-8")) except (OSError, ValueError) as exc: raise CommandError(f"فایل وضعیت خوانده نشد ({path}): {exc}") if not isinstance(data, dict): raise CommandError(f"فایل وضعیت ساختار معتبر ندارد ({path}).") return data def _write_checkpoint(self, path, last_id, batch_size, counters): """نوشتن اتمیک فایل وضعیت (tmp + os.replace) با آخرین شناسهٔ موفق.""" payload = { "last_id": last_id, "batch_size": batch_size, "applied": True, "scanned": counters["scanned"], "updated": counters["updated"], "overflow": counters["overflow"], "batches": counters["batches"], } tmp = path.with_name(path.name + ".tmp") try: tmp.write_text( json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8") os.replace(tmp, path) except OSError as exc: raise CommandError(f"فایل وضعیت نوشته نشد ({path}): {exc}") # ---------------------------------------------------------------- batch def _run(self, last_id, batch_size, apply_changes, checkpoint_path, counters, overflow_ids): """حلقهٔ اصلی: هر batch یک تراکنش اتمیک + checkpoint پس از commit موفق.""" max_id = (Customer.objects.order_by("-id") .values_list("id", flat=True).first()) or 0 if last_id >= max_id: self.stdout.write("مشتری‌ای در دامنهٔ پردازش باقی نمانده است.") return last_id while last_id < max_id: rows = list( Customer.objects .filter(id__gt=last_id) .order_by("id") .prefetch_related("phones") # جلوگیری از N+1 .only("id", "phone1", "phone2", "phone3", "sms_primary_phones") [:batch_size] ) if not rows: break pending = [] for customer in rows: counters["scanned"] += 1 value = self._target_value(customer, counters) current = (customer.sms_primary_phones or "").strip() if value is None: overflow_ids.append(customer.id) # سرریز: دست‌نخورده continue if not value: # مقدار محاسبه‌شده خالی است: مقدار موجودِ مدیر پاک نمی‌شود. if current: counters["kept_nonempty"] += 1 continue if value == current: counters["unchanged"] += 1 continue pending.append((customer, value)) if apply_changes and pending: # یک تراکنش کوتاه برای همین batch: خطا ⇒ فقط همین batch برمی‌گردد. with transaction.atomic(): Customer.objects.bulk_update( [self._stage(customer, value) for customer, value in pending], ["sms_primary_phones"]) counters["updated"] += len(pending) new_last_id = rows[-1].id counters["batches"] += 1 if apply_changes: self._write_checkpoint( checkpoint_path, new_last_id, batch_size, counters) last_id = new_last_id self.stdout.write( f" batch {counters['batches']}: last_id={last_id} | " f"بررسی‌شده={counters['scanned']} | " f"{'به‌روزشده' if apply_changes else 'قابل‌به‌روزرسانی'}={counters['updated']} | " f"سرریز={counters['overflow']}" ) return last_id # -------------------------------------------------------------- helpers def _stage(self, customer, value): """مقدار نهایی را روی نمونه می‌گذارد و همان را برمی‌گرداند (برای bulk_update).""" customer.sms_primary_phones = value return customer def _target_value(self, customer, counters): """مقدار نهایی ``sms_primary_phones`` برای یک مشتری. خروجی: رشتهٔ نهایی، ``""`` (شمارهٔ معتبری نیست) یا ``None`` (سرریز > max_length). """ raw_values = [getattr(customer, field, "") for field in LEGACY_FIELDS] numbers = sorted(customer.phones.all(), key=lambda row: row.id) raw_values.extend(row.phone_number for row in numbers) self._count_filters(raw_values, counters) value = normalize_primary_values(raw_values) if not value: return "" if len(value) > FIELD_MAX_LENGTH: counters["overflow"] += 1 return None return value def _count_filters(self, raw_values, counters): """شمارش شماره‌های فیلترشده (ثابت/نامعتبر) و تکراری‌های حذف‌شده.""" seen = set() duplicates = 0 for raw in raw_values: if raw is None or not str(raw).strip(): continue number = normalize_mobile(raw) if not number: if is_landline(raw): counters["filtered_landline"] += 1 else: counters["filtered_invalid"] += 1 continue if number in seen: duplicates += 1 else: seen.add(number) counters["duplicates"] += duplicates # -------------------------------------------------------------- report def _print_report(self, counters, overflow_ids, apply_changes, last_id): head = ("گزارش شبیه‌سازی (dry-run) — هیچ تغییری ذخیره نشد" if not apply_changes else "گزارش اجرای واقعی") self.stdout.write(self.style.MIGRATE_HEADING(head)) self.stdout.write(f" مشتریان بررسی‌شده: {counters['scanned']}") self.stdout.write( f" مشتریان {'به‌روزشده' if apply_changes else 'قابل‌به‌روزرسانی'}: " f"{counters['updated']}" ) self.stdout.write(f" بدون تغییر: {counters['unchanged']}") self.stdout.write( f" شماره‌های فیلترشده: ثابت={counters['filtered_landline']} | " f"نامعتبر={counters['filtered_invalid']} | " f"جمع={counters['filtered_landline'] + counters['filtered_invalid']}" ) self.stdout.write(f" شماره‌های تکراری حذف‌شده: {counters['duplicates']}") self.stdout.write( f" سرریز طول (>{FIELD_MAX_LENGTH} کاراکتر، دست‌نخورده): {counters['overflow']}" ) if overflow_ids: sample = ", ".join(str(pk) for pk in overflow_ids[:OVERFLOW_SAMPLE]) more = len(overflow_ids) - OVERFLOW_SAMPLE tail = f" و {more} مورد دیگر" if more > 0 else "" self.stdout.write(f" شناسه‌های سرریز: {sample}{tail}") if counters["kept_nonempty"]: self.stdout.write( f" مقدار موجودِ غیرخالی که محافظت شد (شمارهٔ معتبری پیدا نشد): " f"{counters['kept_nonempty']}" ) self.stdout.write(f" batchهای تکمیل‌شده: {counters['batches']}") self.stdout.write(f" آخرین شناسهٔ پردازش‌شده: {last_id}") if not apply_changes: self.stdout.write(self.style.WARNING( "برای اجرای واقعی: --apply (و در صورت توقف قبلی: --apply --resume)" ))