# تجمیع شماره‌های پیامک مشتریان ## چرا؟ شماره‌های هر مشتری در این پروژه به چند جا پراکنده‌اند: فیلدهای قدیمی `Customer.phone1` / `phone2` / `phone3` و رکوردهای جدول `PhoneNumber`. بعضی بدون صفر ابتدایی‌اند (`9133722815`)، بعضی ارقام فارسی/عربی دارند، بعضی با فاصله/خط‌تیره نوشته شده‌اند و بعضی اصلاً موبایل نیستند (شمارهٔ ثابت، «0»، متن آزاد). این دستور همهٔ آن‌ها را می‌خواند، **موبایل‌های معتبر** را استخراج می‌کند و به شکل یک رشتهٔ یکتا و مرتب در `Customer.sms_primary_phones` می‌ریزد. > ⚠️ **قبل از `--apply` این بخش را بخوان:** «هشدار معنای داده» پایین. > رفتار پیش‌فرض دستور **بدون نوشتن** است. | جزء | مسیر | |---|---| | دستور | `admin_panel/management/commands/normalize_customer_sms_phones.py` | | تست‌ها | `tests/test_sms_phone_normalize.py` | | فایل وضعیت (checkpoint) | `.sms_normalization_checkpoint.json` در ریشهٔ پروژه | | مسیر مصرف‌کنندهٔ فیلد | `admin_panel/sms/phones.py` → `admin_panel/sms/recipients.py` | | قالب‌بندی شماره | `integrations/sms/normalize.py` | **هیچ migration لازم نیست** — این دستور مدل را تغییر نمی‌دهد و فقط روی یک ستون موجود می‌نویسد. هیچ پیش‌نیاز دیگری (جدول job، فایل داده، سرویس جانبی) ندارد. --- ## سریع‌ترین مسیر روی Production (اگر همین یک دستور را می‌خواهی) دستور خودبسنده است: فقط `Customer` را وارد می‌کند و به هیچ migration یا مدل جدیدی وابسته نیست. پس روی VPS، **تنها همین سه خط** کافی است: ```bash cd /path/to/khatibi # ۱) شبیه‌سازی (۳۰ ثانیه، صفر نوشتن) — اعداد را ببین docker compose exec web python manage.py normalize_customer_sms_phones --dry-run --batch-size 50 # ۲) بکاپ قبل از نوشتن docker compose exec web python -c " import sqlite3, pathlib, datetime src = pathlib.Path('/app/sqlite/db.sqlite3') dst = src.parent / f'db_backup_{datetime.datetime.now():%Y%m%d_%H%M%S}.sqlite3' s = sqlite3.connect(f'file:{src}?mode=ro', uri=True, timeout=30); d = sqlite3.connect(dst) s.backup(d, pages=250, sleep=0.05); d.close(); s.close(); print('backup:', dst)" # ۳) اجرای واقعی docker compose exec web python manage.py normalize_customer_sms_phones --apply --batch-size 50 ``` سه نکته: 1. **روی VPS `python` میزبان وجود ندارد؛ باید داخل کانتینر بزنی** (`docker compose exec web …`). `docker compose run --rm web …` هم کار می‌کند ولی یک کانتینر اضافه بالا می‌آورد. 2. **migrate لازم نیست.** (`migrate` فقط پیش‌نیاز دستور `fix_customer_names` است، چون آن یکی جدول `CustomerNameFixJob` می‌خواهد.) 3. **`sqlite_db_backup` کار نمی‌کند** مگر `S3_ACCESS_KEY`، `S3_SECRET_KEY`، `S3_ENDPOINT` و `S3_BUCKET` در محیط تنظیم شده باشند — `production.env` این پروژه فقط `DJANGO_SETTINGS_MODULE` دارد. پس همان اسنیپت `sqlite3.backup` بالا (که WAL-safe است) امن‌ترین راه بدون S3 است. اگر وسط کار قطع شد: ```bash docker compose exec web python manage.py normalize_customer_sms_phones --apply --resume ``` > تنها شرط تصمیم‌گیری: بخش **«هشدار معنای داده»** پایین. اگر اثر > افزایشِ گیرندگان پیامک را نمی‌خواهی، مرحلهٔ ۳ را نزن. --- ## ⚠️ هشدار معنای داده (مهم‌ترین نکتهٔ این سند) `Customer.sms_primary_phones` امروز معنایش «گیرندگان پیامک» است: `admin_panel/sms/phones.py::primary_phones()` اگر این فیلد خالی باشد، فقط **شمارهٔ اول** مشتری را برمی‌گرداند؛ و اگر پر باشد، **همهٔ** شماره‌های داخلش را گیرندهٔ پیامک می‌داند. وضعیت فعلی دیتابیس توسعه: | سنجه | مقدار | |---|---| | کل مشتریان | 10,426 | | مشتریانی که الان `sms_primary_phones` دارند | **6** | | بیشترین طول فعلی مقدار | 27 کاراکتر | پس اگر `--apply` بزنی، این ستون برای **~10,084 مشتری** پر می‌شود و از آن لحظه: > **پیامک‌های آیندهٔ هر مشتری به همهٔ شماره‌های معتبرش می‌رود، نه فقط شمارهٔ اول.** این یک خرابی نیست؛ اثر آگاهانهٔ همان کاری است که خواسته شده. ولی اگر نمی‌خواهی تعداد ارسال/هزینه بالا برود، **قبل از `--apply` تصمیم بگیر**: - اگر هدف فقط «یک‌جا کردن شماره‌ها برای بازبینی» است ⇒ همین دستور با `--dry-run` کافی است و نیازی به `--apply` نیست. - اگر می‌خواهی برای مشتریِ بدون انتخاب دستی فقط **شمارهٔ اول** ارسال شود، باید منطق `primary_phones()` تغییر کند (کار جداگانه‌ای است، خارج از این دستور). نکتهٔ تسکین‌دهنده: به‌محض اینکه اپراتور همان مشتری را از پنل ویرایش کند، `customerEdit` مقدار را از تیک‌های فرم بازنویسی می‌کند؛ یعنی پنل همیشه منبع حقیقت نهایی می‌ماند و اجرای دوبارهٔ این دستور آن را خراب نمی‌کند. --- ## چه چیزی تغییر می‌کند و چه چیزی نه **تنها ستونی که نوشته می‌شود `Customer.sms_primary_phones` است.** | مورد | وضعیت | |---|---| | `phone1`, `phone2`, `phone3` | فقط خوانده می‌شوند — **هرگز ویرایش/پاک نمی‌شوند** | | ردیف‌های `PhoneNumber` | فقط خوانده می‌شوند — **هیچ رکوردی حذف/ویرایش نمی‌شود** | | سایر فیلدهای مشتری (نام، آدرس، یادداشت، وضعیت) | دست‌نخورده | | اسکیمای دیتابیس | دست‌نخورده (بدون migration) | | مقدار موجودِ غیرخالی، وقتی شمارهٔ معتبری پیدا نشود | **محافظت می‌شود؛ خالی نمی‌شود** | | مقدار خالی موجود وقتی شمارهٔ معتبر پیدا شود | فقط در این حالت پر می‌شود | سایر فیلدها با یک تست اختصاصی (`test_source_fields_and_phone_rows_are_untouched`) قفل شده‌اند. --- ## قواعد اعتبارسنجی و قالب شماره اعتبارسنجی با تابع مشترک پروژه انجام می‌شود (`integrations/sms/normalize.py::normalize_mobile`) — هیچ منطق موازی و دومی نوشته نشده تا با بقیهٔ سیستم یکسان بماند. | ورودی | معتبر؟ | توضیح | |---|---|---| | `09123456789` | ✅ | قالب استاندارد ۱۱رقمی | | `9123456789` | ✅ | بدون صفر ابتدایی | | `+989123456789` | ✅ | پیش‌شمارهٔ بین‌المللی | | `00989123456789` | ✅ | پیش‌شمارهٔ `0098` | | `989123456789` | ✅ | پیش‌شمارهٔ `98` | | `۰۹۱۲۳۴۵۶۷۸۹` | ✅ | ارقام فارسی | | `٠٩١٢٣٤٥٦٧٨٩` | ✅ | ارقام عربی | | `0912-345-6789`, `(0912) 345 6789` | ✅ | جداکننده‌ها پاک می‌شوند | | `02112345678`, `031-33445566` | ❌ | شمارهٔ ثابت با پیش‌شماره | | `57387608` (۸رقمی) | ❌ | شمارهٔ ثابت محلی | | `12345`, `0`, `اعظمی`, خالی | ❌ | ناقص / متن آزاد | ### قالبِ ذخیره‌شده: `+989xxxxxxxxx` اینجا یک نکتهٔ ظریف و مهم است: - **اعتبارسنجی** بر پایهٔ استاندارد ۱۱رقمی `09xxxxxxxxx` انجام می‌شود (همان چیزی که در صورت مسئله آمده)، - ولی **مقدار ذخیره‌شده** `+989xxxxxxxxx` است. علت، کد موجود پروژه است: `admin_panel/sms/phones.py::stored_primary_normalized` مقدار ذخیره‌شده را با `normalize_mobile()` (یعنی `+989…`) تطبیق می‌دهد. اگر `09…` ذخیره شود: 1. ستارهٔ «★ اصلی» در صفحهٔ مشتری برای هیچ شماره‌ای روشن نمی‌شود (`is_primary` همیشه False)، 2. `resolved_recipients()` بی‌اثر می‌شود و به شمارهٔ اول برمی‌گردد (یعنی کل تجمیع می‌شود دادهٔ مرده)، 3. تست‌های موجود که روی `+989120009999` قفل‌اند می‌شکنند (`tests/test_customers.py:143`، `tests/test_sms.py:1170`). یک تست اختصاصی همین را قفل می‌کند: `test_panel_resolution_reads_back_aggregated_numbers` بررسی می‌کند که خروجی دستور با `resolve_recipients()` خوانده می‌شود. --- ## الگوریتم (برای هر مشتری) **ترتیب اولویت منابع** (برای حذف تکراری‌ها؛ اولین‌بار که یک شماره دیده شود، جایگاهش همان می‌ماند): ``` phone1 → phone2 → phone3 → رکوردهای PhoneNumber بر اساس id صعودی ``` **برای هر مقدار:** 1. مقدار خالی/`None` ⇒ نادیده (شمارش نمی‌شود). 2. تبدیل به موبایل معتبر (`normalize_mobile`) ؛ نامعتبر ⇒ کنار گذاشته و در گزارش شمرده می‌شود (ثابت یا نامعتبر). 3. موبایل تکراری ⇒ حذف می‌شود (با حفظ جایگاه اولیه) و در گزارش شمرده می‌شود. 4. موبایل‌های یکتا با `,` و **بدون فاصله** به هم می‌چسبند: `+989120000001,+989120000002` 5. اگر طول رشته > `max_length` (۱۰۰ کاراکتر، از خود مدل خوانده می‌شود) ⇒ **هیچ truncate‌ای انجام نمی‌شود**؛ رکورد دست‌نخورده می‌ماند و شناسه‌اش در گزارش سرریز می‌آید. 6. اگر نتیجه خالی بود ولی مشتری از قبل مقداری داشت ⇒ مقدار قبلی **پاک نمی‌شود** و در شمارندهٔ «محافظت شد» گزارش می‌شود. 7. اگر نتیجه با مقدار فعلی مو‌به‌مو برابر بود ⇒ **هیچ نوشتنی انجام نمی‌شود**. ### نمونه‌ها | وضعیت مشتری | نتیجهٔ ذخیره‌شده | |---|---| | `phone1=09120000001`, `phone2=09120000002` | `+989120000001,+989120000002` | | `phone1=09120000001`, `phone2=989120000001` | `+989120000001` (تکراری حذف) | | `phone1=۰۹۱۲۰۰۰۰۰۰۱` | `+989120000001` | | `phone1=09120000001`, `phone2=02112345678` | `+989120000001` (ثابت فیلتر شد) | | `phone1=09120000001`, `PhoneNumber=09120000002` | `+989120000001,+989120000002` | | ۱۰ شمارهٔ معتبر (۱۳۹ کاراکتر) | **دست‌نخورده** + ثبت در سرریز | | ۷ شمارهٔ معتبر (۹۷ کاراکتر) | ذخیره می‌شود (زیر سقف) | | `phone1=''` و `PhoneNumber` خالی، مقدار قبلی `+98912…` | دست‌نخورده (محافظت) | --- ## گزینه‌ها | گزینه | پیش‌فرض | کار | |---|---|---| | `--batch-size` | `100` | تعداد مشتری در هر batch (تراکنش اتمیک) | | `--dry-run` | خاموش (اما **رفتار پیش‌فرض همین است**) | گزارش بدون هیچ نوشتنی؛ همراه `--apply` خطا می‌دهد | | `--apply` | خاموش | نوشتن واقعی در دیتابیس + نوشتن فایل وضعیت | | `--resume` | خاموش | ادامه از `last_id` داخل فایل وضعیت | | `--start-id N` | — | شروع دستی از مشتریان `id > N`؛ **بر `--resume` اولویت دارد** | | `--reset-checkpoint` | خاموش | پاک‌کردن فایل وضعیت (در `--dry-run` نادیده گرفته می‌شود) | | `--checkpoint PATH` | `.sms_normalization_checkpoint.json` در ریشهٔ پروژه | مسیر فایل وضعیت | --- ## اجرای محلی (توسعه) ### ۱) شبیه‌سازی — همیشه قدم اول از WSL: ```bash cd /mnt/d/projects/khatibi .venv/Scripts/python.exe manage.py normalize_customer_sms_phones --dry-run ``` از Windows (PowerShell / CMD): ```bat cd D:\projects\khatibi .venv\Scripts\python.exe manage.py normalize_customer_sms_phones --dry-run ``` خروجی نمونهٔ واقعی روی دیتابیس توسعه (۱۰٬۴۲۶ مشتری، `--batch-size 100`): ``` حالت: شبیه‌سازی (dry-run) | batch=100 | فایل وضعیت: D:\Projects\khatibi\.sms_normalization_checkpoint.json شروع از مشتریان با id > 0 (ابتدا) batch 1: last_id=... | بررسی‌شده=100 | قابل‌به‌روزرسانی=... | سرریز=0 ... batch 105: last_id=11427 | بررسی‌شده=10426 | قابل‌به‌روزرسانی=10084 | سرریز=0 گزارش شبیه‌سازی (dry-run) — هیچ تغییری ذخیره نشد مشتریان بررسی‌شده: 10426 مشتریان قابل‌به‌روزرسانی: 10084 بدون تغییر: 4 شماره‌های فیلترشده: ثابت=4864 | نامعتبر=0 | جمع=4864 شماره‌های تکراری حذف‌شده: 13 سرریز طول (>100 کاراکتر، دست‌نخورده): 0 batchهای تکمیل‌شده: 105 آخرین شناسهٔ پردازش‌شده: 11427 برای اجرای واقعی: --apply (و در صورت توقف قبلی: --apply --resume) ``` در `--dry-run`: نه دیتابیس نوشته می‌شود و نه فایل وضعیت ساخته می‌شود. (چک‌شده: SHA-256 دیتابیس پیش و پس یکسان است و `.sms_normalization_checkpoint.json` وجود ندارد.) ### ۲) تنظیم سرعت batch ```bash # پیش‌فرض: ۱۰۰ مشتری در هر تراکنش .venv/Scripts/python.exe manage.py normalize_customer_sms_phones --dry-run # ساعات پرترافیک سرور: batch کوچک‌تر ⇒ تراکنش کوتاه‌تر .venv/Scripts/python.exe manage.py normalize_customer_sms_phones --dry-run --batch-size 50 # اجرای سریع‌تر روی سرور خلوت .venv/Scripts/python.exe manage.py normalize_customer_sms_phones --dry-run --batch-size 500 ``` ### ۳) اجرای واقعی ```bash .venv/Scripts/python.exe manage.py normalize_customer_sms_phones --apply ``` قبلش یک‌بار دیگر «هشدار معنای داده» را بخوان. --- ## اجرا روی سرور Production ### ترتیب دقیق ```bash # ۰) فایل‌ها روی سرور (بدون migration جدید — این دستور اسکیما را عوض نمی‌کند) # فقط کد به‌روز شود؛ entrypoint/docker-compose خودشان migrate می‌کنند. # ۱) بکاپ دیتابیس — بخش «بکاپ» در fix_customer_names.md docker compose exec web python manage.py sqlite_db_backup # یا اگر S3 در دسترس نیست، اسنیپت WAL-safe در همان سند آمده است. # ۲) تأیید مقصد دیتابیس (بدون افشای credential) docker compose exec web python -c " import django, os; django.setup() from django.conf import settings as s print('settings:', os.environ.get('DJANGO_SETTINGS_MODULE')) print('DB:', s.DATABASES['default']['ENGINE'], s.DATABASES['default']['NAME'])" # ۳) شبیه‌سازی روی دیتابیس واقعی — هیچ نوشتنی ندارد docker compose exec web python manage.py normalize_customer_sms_phones --dry-run --batch-size 50 # ۴) اگر اعداد منطقی بود (و اثر پیامکی را پذیرفتی): اجرای واقعی docker compose exec web python manage.py normalize_customer_sms_phones --apply --batch-size 50 # ۵) بررسی خلاصه و صحت docker compose exec web python -c " import django; django.setup() from admin_panel.models import Customer print('دارای شماره:', Customer.objects.exclude(sms_primary_phones='').count())" ``` ### نکته‌ها برای Production - دستور از **تنظیمات فعال Django** می‌خواند (`DJANGO_SETTINGS_MODULE`)، نه از مسیر هاردکدشده. روی سرور `khatibi.production_settings` و `NAME=/app/sqlite/db.sqlite3` (ولوم `khatibi-sqlite-db`) است. - در ساعات کم‌ترافیک اجرا کن. SQLite در هر لحظه یک نویسنده دارد؛ اگر web و worker هم‌زمان بنویسند، `busy_timeout=30000` (در `admin_panel/signals.py`) و اندازهٔ batch کوچک باعث می‌شود کند شود، ولی **سایت متوقف نمی‌شود**. - هیچ migration جدیدی لازم نیست و دستور هیچ migration ای اجرا نمی‌کند. --- ## توقف و ادامه (Resume / Checkpoint) ### فایل وضعیت پیش‌فرض: `.sms_normalization_checkpoint.json` در ریشهٔ پروژه، با این ساختار: ```json { "last_id": 11427, "batch_size": 100, "applied": true, "scanned": 10426, "updated": 10084, "overflow": 0, "batches": 105 } ``` - نام فایل با یک نقطه شروع می‌شود، پس در پوشهٔ پروژه پنهان است (`ls -a` ببینش). - اگر آن را در ریشه نمی‌خواهی (مثلاً ریشهٔ پروژه read-only است)، با `--checkpoint` مسیر دلخواه بده — مثلاً کنار خود دیتابیس: `--checkpoint /app/sqlite/.sms_norm.json`. - **نوشتن فایل اتمیک است** (`tmp` + `os.replace`)؛ قطع برق وسط نوشتن، فایلِ نیمه‌ساخته باقی نمی‌گذارد. - در `--dry-run` این فایل **هرگز** ساخته یا تغییر داده نمی‌شود. ### اتمیک بودن هر batch این‌ها را در یک تراکنش انجام می‌دهد: نوشتن `sms_primary_phones` مشتریان همان batch. و فایل وضعیت **فقط پس از commit موفق** نوشته می‌شود. نتیجهٔ قطعی وسط کار: - batch در حال اجرا ⇒ rollback می‌شود و checkpoint همان batch **جلو نمی‌رود**. - batchهای قبلی ⇒ commit شده و باقی می‌مانند. - اجرای بعدی ⇒ از `last_id` ادامه می‌دهد، بدون جابه‌جایی رکورد و بدون پرش. اگر قطعی در فاصلهٔ «commit» تا «نوشتن فایل وضعیت» رخ دهد، همان batch دوباره پردازش می‌شود. این تراکنش **بی‌خطر است** (idempotent): مقدار یکسان ⇒ هیچ نوشتنی. پس نه داده خراب می‌شود و نه رکوردی جا می‌ماند. ### گزینه‌های ادامه ```bash # ادامهٔ خودکار از فایل وضعیت python manage.py normalize_customer_sms_phones --apply --resume # شروع دستی از یک شناسه (بر --resume اولویت دارد) python manage.py normalize_customer_sms_phones --apply --start-id 5000 # شروع از صفر (پاک‌کردن فایل وضعیت) python manage.py normalize_customer_sms_phones --apply --reset-checkpoint ``` ### سلسله‌مراتب تصمیم نقطهٔ شروع ``` --start-id > --resume > 0 (از ابتدا) ``` بدون هیچ گزینه‌ای، اجرای جدید از ابتدای جدول شروع می‌شود ولی چون مقدارهای قبلاً نوشته‌شده مو‌به‌مو برابرند، **دوباره نوشته نمی‌شوند** (idempotent). یعنی اجرای دوبارهٔ ناخواسته خطرناک نیست، فقط بی‌دلیل جدول را می‌خواند. --- ## تعریف کلیدهای گزارش | کلید | معنا | |---|---| | مشتریان بررسی‌شده | تعداد مشتریانی که در این اجرا از دیتابیس خوانده شدند | | مشتریان به‌روزشده / قابل‌به‌روزرسانی | در `--apply` تعداد رکوردهایی که واقعاً نوشته شدند؛ در dry-run تعداد رکوردهایی که نوشته **می‌شدند** | | بدون تغییر | مقدار محاسبه‌شده مو‌به‌مو با مقدار فعلی برابر بود ⇒ هیچ نوشتنی | | شماره‌های فیلترشده: ثابت | شماره‌های ثابت (پیش‌شماره‌دار یا ۸رقمی مثل `57387608`) | | شماره‌های فیلترشده: نامعتبر | ناقص، `0`، متن آزاد | | شماره‌های تکراری حذف‌شده | موبایل‌های معتبری که قبلاً در همان مشتری دیده شده بودند | | سرریز طول | مشتریانی که رشتهٔ نهایی‌شان > ۱۰۰ کاراکتر می‌شد ⇒ دست‌نخورده + شناسه‌ها چاپ می‌شود | | مقدار موجودِ غیرخالی که محافظت شد | مشتریانی که شمارهٔ معتبر نداشتند ولی مقدار قبلی‌شان پاک **نشد** (فقط اگر > ۰ باشد چاپ می‌شود) | | batchهای تکمیل‌شده | تعداد تراکنش‌های commit‌شده | | آخرین شناسهٔ پردازش‌شده | `last_id` نهایی؛ در `--apply` همان مقدار داخل فایل وضعیت | --- ## عملکرد و بار روی دیتابیس - **Keyset pagination**: `filter(id__gt=last_id).order_by('id')[:batch_size]` — نه offset، نه شمارهٔ ردیف. پس افزودن/حذف مشتری وسط کار باعث پرش یا تکرار نمی‌شود و ترتیب پردازش پایدار است. - **جلوگیری از N+1**: `prefetch_related('phones')` ⇒ **۳ کوئری ثابت در هر batch**، مستقل از تعداد مشتریان و تعداد شماره‌ها. یک تست با `assertNumQueries(3)` این را قفل کرده است. - **حداقل حافظه**: `only('id', 'phone1', 'phone2', 'phone3', 'sms_primary_phones')` — بقیهٔ ستون‌های سنگین (یادداشت، آدرس) خوانده نمی‌شوند و batchها کوچک‌اند. - **هیچ نوشتن غیرضروری**: `bulk_update([...], ['sms_primary_phones'])` فقط برای رکوردهای تغییریافته و فقط همان یک ستون. `save()` کامل صدا زده نمی‌شود، پس فیلدهای `auto_now_add` مثل `create_date` لمس نمی‌شوند. زمانبندی تقریبی: ۱۰٬۴۲۶ مشتری با `--batch-size 100` = ۱۰۵ تراکنش کوچک؛ در محیط توسعه چند ثانیه. --- ## خطاها و اقدام درست | پیام | معنا | اقدام | |---|---|---| | `unsupported operand`/متن خطای غیرمنتظره | خطا وسط batch | batch جاری rollback شد؛ با `--apply --resume` ادامه بده | | `--resume داده شد ولی فایل وضعیت وجود ندارد: …` | فایل پاک شده یا مسیر `--checkpoint` عوض شده | یا بدون `--resume` اجرا کن، یا مسیر درست را با `--checkpoint` بده | | `فایل وضعیت معتبر نیست (last_id=…)` | فایل دست‌کاری/خراب شده | `--reset-checkpoint` یا فایل را پاک کن | | `فایل وضعیت خوانده نشد (…): …` | JSON خراب یا دسترسی فایل | همان بالا | | `فایل وضعیت پاک نشد (…)` | دسترسی نوشتن ندارد | مسیر `--checkpoint` را به جایی قابل‌نوشتن تغییر بده | | `--dry-run و --apply با هم ناسازگارند.` | هر دو فلگ داده شده | فقط یکی را بده | | `KeyboardInterrupt` / پیام «متوقف شد (Ctrl+C)» | توقف دستی | آخرین شناسهٔ معتبر چاپ می‌شود؛ `--apply --resume` | هیچ‌کدام از این خطاها باعث truncate، پاک‌شدن مقدار قبلی یا نیمه‌کاره ماندن یک batch نمی‌شود. --- ## بررسی صحت داده (SQL) روی دیتابیس، بدون اجرای دستور (فقط برای سنجش). دیتابیس را با `mode=ro` باز کن تا هیچ‌چیز تغییر نکند: ```bash .venv/Scripts/python.exe - <<'PY' import sqlite3 con = sqlite3.connect('file:data9/kt.sqlite3?mode=ro', uri=True) q = lambda s: con.execute(s).fetchone()[0] print('کل مشتریان:', q('select count(*) from admin_panel_customer')) print('دارای مقدار:', q("select count(*) from admin_panel_customer where trim(coalesce(sms_primary_phones,''))<>''")) print('چند شماره‌ای:', q("select count(*) from admin_panel_customer where sms_primary_phones like '%,%'")) print('قالب محلی (09…):', q("select count(*) from admin_panel_customer where sms_primary_phones like '09%'")) print('قالب متعارف (+98…):', q("select count(*) from admin_panel_customer where sms_primary_phones like '+98%'")) print('بیشترین طول:', q("select max(length(sms_primary_phones)) from admin_panel_customer")) con.close() PY ``` `قالب محلی (09…)` باید همیشه `0` بماند — اگر عددی غیر از صفر دیدی، یعنی جایی خارج از قواعد این دستور نوشته شده است. بررسی پس از اجرای واقعی (برای دو نمونهٔ مشخص، پیش و پس): ```bash docker compose exec web python -c " import django; django.setup() from admin_panel.models import Customer for c in Customer.objects.exclude(sms_primary_phones='').order_by('-id')[:5]: print(c.id, '|', c.phone1, c.phone2, c.phone3, '->', c.sms_primary_phones)" ``` --- ## بازگردانی (اگر نتیجه را نخواستی) دستور Undo ندارد. بازگردانی از بکاپ: ```bash docker compose stop web worker scheduler delivery docker run --rm -v khatibi-sqlite-db:/data -v "$(pwd)":/backup alpine sh -c \ "cp /backup/db_backup_YYYYmmdd_HHMMSS.sqlite3 /data/db.sqlite3 \ && rm -f /data/db.sqlite3-wal /data/db.sqlite3-shm" docker compose start migrate web worker scheduler delivery ``` اگر فقط می‌خواهی ستون برای همه خالی شود (بدون بازگردانی کل بکاپ) — **این کار تعداد گیرندگان پیامک را به حالت قبل برمی‌گرداند (فقط شمارهٔ اول)** و انتخاب‌های دستی اپراتور را هم پاک می‌کند. عمداً هیچ دستوری برای انجام خودکارش نوشته نشده؛ با آگاهی و دستی در `shell` انجام بده: ```bash # ⚠️ تنها در صورت تصمیم آگاهانه python manage.py shell -c " from admin_panel.models import Customer print(Customer.objects.exclude(sms_primary_phones='').update(sms_primary_phones=''))" ``` --- ## تست‌ها ```bash .venv/Scripts/python.exe manage.py test tests.test_sms_phone_normalize # ۲۷ تست .venv/Scripts/python.exe manage.py test tests # کل مجموعه ``` پوشش تست‌ها: - **قالب‌ها**: `+98`، `0098`، `98`، `9…`، `09…`، ارقام فارسی، ارقام عربی، جداکننده‌ها. - **اعتبارسنجی**: شمارهٔ ثابت پیش‌شماره‌دار، ثابت ۸رقمی، `0`، متن آزاد، خالی. - **ترتیب و تکراری**: اولویت `phone1→phone2→phone3→PhoneNumber.id` و حذف تکراری با حفظ جایگاه. - **ایمنی داده**: فیلدهای مبدأ و ردیف‌های `PhoneNumber` و سایر فیلدهای مشتری دست‌نخورده؛ رکورد بدون تغییر هیچ نوشتنی ندارد؛ مقدار موجودِ غیرخالی با نتیجهٔ خالی پاک نمی‌شود. - **سرریز**: ۱۳۹ کاراکتر ⇒ دست‌نخورده + شناسه در گزارش؛ ۹۷ کاراکتر ⇒ ذخیره. - **batch و checkpoint**: پوشش کامل با batch کوچک، نوشتن فایل پس از هر batch، `resume`، `--start-id`، `--reset-checkpoint`، `resume` بدون فایل ⇒ خطا، فایل خراب ⇒ خطا. - **اتمیک بودن واقعی**: batch دوم با خطا می‌شکند ⇒ batch اول commit می‌ماند، batch دوم rollback می‌شود، checkpoint جلو نمی‌رود، و `--resume` بعدی کار را تمام می‌کند. - **کارایی**: `assertNumQueries(3)` برای هر batch. - **سازگاری با پنل**: خروجی دستور با `resolve_recipients()` خوانده می‌شود. تست‌ها روی دیتابیس تستی Django (SQLite درون‌حافظه) اجرا می‌شوند؛ نه به `data9/kt.sqlite3` دست می‌زنند و نه فایل وضعیت ریشهٔ پروژه را می‌سازند. --- ## نکته‌ها و محدودیت‌ها - **قفل اجرای هم‌زمان ندارد.** دو `--apply` موازی می‌توانند درهم بیامیزند (فایل وضعیت: آخرین نویسنده برنده). برای دستور دستی اپراتور پذیرفتنی است؛ ولی دو نسخه را هم‌زمان روی یک دیتابیس اجرا نکن. برای اطمینان: ```bash # مطمئن شو نمونهٔ دیگری در حال اجرا نیست ps aux | grep -c '[n]ormalize_customer_sms_phones' ``` - **اثر روی پیامک** — بند اول همین سند را ببین؛ مهم‌ترین نکتهٔ تصمیم‌گیری. - **شماره‌های ثابت/نامعتبر از فیلدهای مبدأ حذف نمی‌شوند.** فقط از تجمیع بیرون می‌مانند. تمیزکاری فیلدهای مبدأ کار دستور جداگانهٔ `fix_customer_phones` است. - **`sms_primary_phones` یک `CharField(max_length=100)` است.** با ~۸ شمارهٔ موبایل پر می‌شود. برای مشتریی با شماره‌های بیشتر، دستور عمداً هیچ‌کاری نمی‌کند (سرریز) تا داده را نصفه نریزد. اگر مشتری زیاد سرریز داشت (در گزارش می‌بینی)، افزایش `max_length` نیازمند migration جداگانه است. - **معنای «اصلی» تغییر می‌کند.** اگر فرض قبلی سیستم این بود که این فیلد فقط انتخاب دستیِ مدیر را نگه دارد، از این پس همهٔ شماره‌های معتبر را نگه می‌دارد. - **`--reset-checkpoint` در `--dry-run` عمداً نادیده گرفته می‌شود** تا شبیه‌سازی هیچ اثری روی وضعیت اجراهای واقعی نگذارد. - **بازگشت‌پذیری**: با `--apply` آمار داخل فایل وضعیت ذخیره می‌شود، ولی تاریخچهٔ per-customer نگه داشته نمی‌شود (ستون زمان/audit ندارد). برای برگشت، بکاپ لازم است. - کنسول ویندوز: متن فارسی در خروجی ممکن است `charmap` خطا بدهد؛ مثل بقیهٔ سرویس‌های پروژه از `python -X utf8 manage.py …` استفاده کن. - فایل‌های `-wal` و `-shm` دیتابیس جزء دیتابیس‌اند؛ در بکاپ دستی هر سه را در لحظهٔ بدون نوشتن کپی کن (یا از `sqlite3.Connection.backup` استفاده کن). --- ## چک‌لیست سریع ```bash # ── توسعه ──────────────────────────────────────────────────────────────── cd /mnt/d/projects/khatibi .venv/Scripts/python.exe manage.py test tests.test_sms_phone_normalize .venv/Scripts/python.exe manage.py normalize_customer_sms_phones --dry-run # ── Production ─────────────────────────────────────────────────────────── docker compose exec web python manage.py sqlite_db_backup # ۱) بکاپ docker compose exec web python manage.py normalize_customer_sms_phones --dry-run --batch-size 50 docker compose exec web python manage.py normalize_customer_sms_phones --apply --batch-size 50 # ── پس از توقف ناخواسته ───────────────────────────────────────────────── docker compose exec web python manage.py normalize_customer_sms_phones --apply --resume # ── شروع از صفر (با احتیاط) ───────────────────────────────────────────── docker compose exec web python manage.py normalize_customer_sms_phones --apply --reset-checkpoint ``` سه تصمیم قبل از `--apply`: 1. اثر پیامکی (ارسال به همهٔ شماره‌ها به‌جای شمارهٔ اول) را می‌پذیری؟ 2. بکاپ گرفته‌ای و از سالم بودنش مطمئنی؟ 3. اعداد `--dry-run` را دیده‌ای (خصوصاً `سرریز طول` و `مقدار موجودِ محافظت‌شده`)؟