fix mobile and names
This commit is contained in:
1 parent
e3a6d1f9d4
commit
7e5c83bea2
12 files changed
+82792
-6
No files matched your search
@@ -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` را دیدهای (خصوصاً `سرریز طول` و `مقدار موجودِ محافظتشده`)؟
|
||||
|
||||
Reference in new issue
Block a user