33 KiB
تجمیع شمارههای پیامک مشتریان
چرا؟
شمارههای هر مشتری در این پروژه به چند جا پراکندهاند: فیلدهای قدیمی
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، تنها همین سه خط کافی است:
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
سه نکته:
- روی VPS
pythonمیزبان وجود ندارد؛ باید داخل کانتینر بزنی (docker compose exec web …).docker compose run --rm web …هم کار میکند ولی یک کانتینر اضافه بالا میآورد. - migrate لازم نیست. (
migrateفقط پیشنیاز دستورfix_customer_namesاست، چون آن یکی جدولCustomerNameFixJobمیخواهد.) sqlite_db_backupکار نمیکند مگرS3_ACCESS_KEY،S3_SECRET_KEY،S3_ENDPOINTوS3_BUCKETدر محیط تنظیم شده باشند —production.envاین پروژه فقطDJANGO_SETTINGS_MODULEدارد. پس همان اسنیپتsqlite3.backupبالا (که WAL-safe است) امنترین راه بدون S3 است.
اگر وسط کار قطع شد:
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… ذخیره شود:
- ستارهٔ «★ اصلی» در صفحهٔ مشتری برای هیچ شمارهای روشن نمیشود
(
is_primaryهمیشه False)، resolved_recipients()بیاثر میشود و به شمارهٔ اول برمیگردد (یعنی کل تجمیع میشود دادهٔ مرده)،- تستهای موجود که روی
+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 صعودی
برای هر مقدار:
- مقدار خالی/
None⇒ نادیده (شمارش نمیشود). - تبدیل به موبایل معتبر (
normalize_mobile) ؛ نامعتبر ⇒ کنار گذاشته و در گزارش شمرده میشود (ثابت یا نامعتبر). - موبایل تکراری ⇒ حذف میشود (با حفظ جایگاه اولیه) و در گزارش شمرده میشود.
- موبایلهای یکتا با
,و بدون فاصله به هم میچسبند:+989120000001,+989120000002 - اگر طول رشته >
max_length(۱۰۰ کاراکتر، از خود مدل خوانده میشود) ⇒ هیچ truncateای انجام نمیشود؛ رکورد دستنخورده میماند و شناسهاش در گزارش سرریز میآید. - اگر نتیجه خالی بود ولی مشتری از قبل مقداری داشت ⇒ مقدار قبلی پاک نمیشود و در شمارندهٔ «محافظت شد» گزارش میشود.
- اگر نتیجه با مقدار فعلی موبهمو برابر بود ⇒ هیچ نوشتنی انجام نمیشود.
نمونهها
| وضعیت مشتری | نتیجهٔ ذخیرهشده |
|---|---|
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:
cd /mnt/d/projects/khatibi
.venv/Scripts/python.exe manage.py normalize_customer_sms_phones --dry-run
از Windows (PowerShell / CMD):
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
# پیشفرض: ۱۰۰ مشتری در هر تراکنش
.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
۳) اجرای واقعی
.venv/Scripts/python.exe manage.py normalize_customer_sms_phones --apply
قبلش یکبار دیگر «هشدار معنای داده» را بخوان.
اجرا روی سرور Production
ترتیب دقیق
# ۰) فایلها روی سرور (بدون 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 در ریشهٔ پروژه، با این ساختار:
{
"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): مقدار یکسان ⇒ هیچ نوشتنی. پس نه داده خراب میشود و نه رکوردی جا میماند.
گزینههای ادامه
# ادامهٔ خودکار از فایل وضعیت
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 باز کن
تا هیچچیز تغییر نکند:
.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 بماند — اگر عددی غیر از صفر دیدی، یعنی جایی
خارج از قواعد این دستور نوشته شده است.
بررسی پس از اجرای واقعی (برای دو نمونهٔ مشخص، پیش و پس):
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 ندارد. بازگردانی از بکاپ:
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 انجام بده:
# ⚠️ تنها در صورت تصمیم آگاهانه
python manage.py shell -c "
from admin_panel.models import Customer
print(Customer.objects.exclude(sms_primary_phones='').update(sms_primary_phones=''))"
تستها
.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موازی میتوانند درهم بیامیزند (فایل وضعیت: آخرین نویسنده برنده). برای دستور دستی اپراتور پذیرفتنی است؛ ولی دو نسخه را همزمان روی یک دیتابیس اجرا نکن. برای اطمینان:# مطمئن شو نمونهٔ دیگری در حال اجرا نیست 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استفاده کن).
چکلیست سریع
# ── توسعه ────────────────────────────────────────────────────────────────
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:
- اثر پیامکی (ارسال به همهٔ شمارهها بهجای شمارهٔ اول) را میپذیری؟
- بکاپ گرفتهای و از سالم بودنش مطمئنی؟
- اعداد
--dry-runرا دیدهای (خصوصاًسرریز طولومقدار موجودِ محافظتشده)؟