Files
khatibi/normalize_customer_sms_phones.md
T
2026-10-15 11:21:55 +03:30

612 lines
33 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# تجمیع شماره‌های پیامک مشتریان
## چرا؟
شماره‌های هر مشتری در این پروژه به چند جا پراکنده‌اند: فیلدهای قدیمی
`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` را دیده‌ای (خصوصاً `سرریز طول` و `مقدار موجودِ محافظت‌شده`)؟