612 lines
33 KiB
Markdown
612 lines
33 KiB
Markdown
# تجمیع شمارههای پیامک مشتریان
|
||
|
||
## چرا؟
|
||
|
||
شمارههای هر مشتری در این پروژه به چند جا پراکندهاند: فیلدهای قدیمی
|
||
`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` را دیدهای (خصوصاً `سرریز طول` و `مقدار موجودِ محافظتشده`)؟
|
||
|