fix mobile and names

This commit is contained in:
javad committed 2026-10-15 11:21:55 +03:30
1 parent e3a6d1f9d4
commit 7e5c83bea2
12 files changed
+82792 -6

No files matched your search

+611
View File
@@ -0,0 +1,611 @@
# تجمیع شماره‌های پیامک مشتریان
## چرا؟
شماره‌های هر مشتری در این پروژه به چند جا پراکنده‌اند: فیلدهای قدیمی
`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` را دیده‌ای (خصوصاً `سرریز طول` و `مقدار موجودِ محافظت‌شده`)؟