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

35 KiB
Raw Permalink Blame History

افزودن پیشوند «آقای/خانم» به نام مشتریان

چرا؟

فیلد Customer.name_family (همان «نام» مشتری) سال‌ها بدون پیشوند وارد شده: محمد یاوری، مریم احمدی. برای پیامک و گزارش‌ها بهتر است نام با پیشوند محترمانه بیاید: آقای محمد یاوری، خانم مریم احمدی.

اما این کار فقط وقتی مجاز است که جنسیتِ اولین کلمهٔ نام با اطمینان قابل تعیین باشد. فایل iranianNamesDataset.json (حدود ۲۰ هزار نام، M/F) همین مرجع است.

قاعدهٔ بنیادین: اگر کوچک‌ترین ابهامی در تشخیص جنسیت باشد، هیچ تغییری اعمال نمی‌شود. حدس جنسیت، تطبیق تقریبی (fuzzy) و انتخاب تصادفی ممنوع است.

مسیرهای اصلی:

جزء مسیر
منطق تصمیم (بدون Django) admin_panel/name_fix.py
دستور اجرا admin_panel/management/commands/fix_customer_names.py
مدل وضعیت job CustomerNameFixJob در admin_panel/models/system.py
مهاجرت admin_panel/migrations/0072_customernamefixjob.py
تست‌ها tests/test_customer_name_fix.py
فایل داده iranianNamesDataset.json در ریشهٔ پروژه (/app/iranianNamesDataset.json در کانتینر)

سریع‌ترین مسیر روی Production (خلاصهٔ اجرایی)

روی VPS، python میزبان وجود ندارد؛ همه‌چیز داخل کانتینر web اجرا می‌شود:

cd /path/to/khatibi

# ۱) تأیید وجود فایل داده داخل ایمیج (پیش‌نیاز قطعی این دستور)
docker compose exec web ls -l /app/iranianNamesDataset.json
docker compose exec web sha256sum /app/iranianNamesDataset.json

# ۲) اعمال migration (فقط این دستور به جدول CustomerNameFixJob نیاز دارد)
docker compose run --rm migrate

# ۳) بکاپ دیتابیس
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 fix_customer_names --dry-run --batch-size 200 --samples 5

# ۵) اجرای واقعی (هر دو فلگ لازم است)
docker compose exec web python manage.py fix_customer_names --apply --confirm-production --batch-size 200

چرا این ترتیب؟

مرحله چرا
۱ دستور مسیر پیش‌فرض را از settings.BASE_DIR می‌سازد. اگر فایل داخل ایمیج نباشد، دستور با «فایل داده پیدا نشد» رد می‌شود.
۲ fix_customer_names پیش از هر نوشتن، وجود جدول admin_panel_customernamefixjob را چک می‌کند و بدون آن خطا می‌دهد. (روی این پروژه entrypoint.sh و سرویس migrate هم خودشان اجرا می‌کنند، ولی صریح زدن ضرری ندارد.)
۳ دستور Undo ندارد؛ برگشت فقط با بکاپ ممکن است.
۴ پیش‌فرض بدون نوشتن است؛ اعداد (خصوصاً تعداد قابل‌اصلاح و نام‌های مبهم) را می‌بینی.
۵ نوشتن نیازمند تأیید صریح اپراتور است، نه حدس از DEBUG یا نام محیط.

✅ فایل JSON داخل ایمیج کپی می‌شود (تضمین‌شده)

Dockerfile حالا صریحاً این فایل را کپی می‌کند (خط ۶۳):

COPY --chown=django:django iranianNamesDataset.json /app/iranianNamesDataset.json

یعنی مسیر پیش‌فرض دستور (settings.BASE_DIR = /app) همیشه داخل ایمیج موجود است و به COPY . . یا محتوای .dockerignore وابسته نیست.

عواقب این انتخاب:

  • اگر فایل در build context نباشد، build شکست می‌خورد (fail-fast) — این رفتار مطلوب است: خطا همان‌جا در CI دیده می‌شود، نه بعداً روی سرور با «فایل داده پیدا نشد».
  • روی سرورهای میزبان (VPS/CI) باید فایل در build context حاضر باشد. اگر Coolify/CI ایمیج را از گیت می‌سازد، فایل باید commit شده باشد (الان git status آن را ?? نشان می‌دهد، یعنی هنوز track نشده).

برای تست سریع بدون commit و بدون rebuild:

# الف) کپی دستی داخل کانتینر در حال اجرا (تا بازسازی بعدی کانتینر پایدار است)
docker compose cp iranianNamesDataset.json web:/app/iranianNamesDataset.json

# ب) bind-mount از میزبان (فایل هرجا باشد، با هر rebuild هم می‌ماند)
#   docker-compose.override.yml
#   services:
#     web:
#       volumes:
#         - ./iranianNamesDataset.json:/app/iranianNamesDataset.json:ro
docker compose exec web python manage.py fix_customer_names --dry-run \
    --json-path /app/iranianNamesDataset.json

برای تأیید اینکه ایمیج ساخته‌شده واقعاً فایل را دارد:

docker compose exec web ls -l /app/iranianNamesDataset.json
docker compose exec web sha256sum /app/iranianNamesDataset.json   # باید با hash گزارش دستور یکی باشد

چون jobها با SHA-256 فایل شناسایی می‌شوند، فایل باید بایت‌به‌بایت همان چیزی باشد که dry-run دید. اگر فایل را عوض کردی، ادامهٔ job قدیمی رد می‌شود و باید --new-job بدهی.

اگر وسط کار قطع شد

# الف) ادامهٔ خودکار: فقط همان دستور را دوباره بزن.
#    خودش آخرین job ناتمام با همان hash و همان نسخهٔ الگوریتم را پیدا و ادامه می‌دهد.
docker compose exec web python manage.py fix_customer_names --apply --confirm-production --batch-size 200

# ب) ادامهٔ صریح یک job مشخص
docker compose exec web python manage.py fix_customer_names --apply --confirm-production --resume 3

# ج) اگر شمارهٔ job را نمی‌دانی:
docker compose exec web python manage.py shell -c "
from admin_panel.models import CustomerNameFixJob as J
print(list(J.objects.values('id','status','last_processed_pk','initial_max_pk','stats')))"

اگر job قبلی completed باشد، اجرای دوباره خطا می‌دهد و خودکار job جدید نمی‌سازد (تا داده دوباره دست نخورد). برای اجرای عمدی از صفر: --new-job.

بررسی نتیجه

docker compose exec web python manage.py shell -c "
from admin_panel.models import Customer
print('آقای:', Customer.objects.filter(name_family__startswith='آقای ').count())
print('خانم:', Customer.objects.filter(name_family__startswith='خانم ').count())"

فقط فیلد name_family مشتریان واجد شرایط. هیچ فیلدی به مدل مشتری اضافه نشده، هیچ جنسیتی ذخیره نمی‌شود، و تلفن/آدرس/یادداشت/وضعیت حساب دست‌نخورده می‌مانند. جدول PhoneNumber هم لمس نمی‌شود.

الگوریتم تصمیم (به همین ترتیب)

# بررسی نتیجه
۱ نام خالی / فقط فاصله / NULL بدون تغییر
۲ اولین کلمه آقا، آقای یا خانم بدون تغییر (پیشوند تکرار نمی‌شود)
۳ نام تک‌کلمه‌ای بدون تغییر (ممکن است فقط فامیل باشد)
۴ اولین کلمه در فایل پیدا نشود بدون تغییر
۵ جنسیت اولین کلمه مبهم/نامعتبر باشد بدون تغییر
۶ وگرنه افزودن قطعی پیشوند

جست‌وجو فقط روی اولین کلمه انجام می‌شود؛ از فامیل و کلمات بعدی برای حدس استفاده نمی‌شود.

نمونه‌های رفتاری

مقدار فعلی نتیجه
محمد یاوری آقای محمد یاوری
مریم احمدی خانم مریم احمدی
یاوری بدون تغییر — تک‌کلمه‌ای
محمد بدون تغییر — تک‌کلمه‌ای
آقا محمد یاوری بدون تغییر
آقای محمد یاوری بدون تغییر
خانم مریم احمدی بدون تغییر
نام ناشناخته احمدی بدون تغییر — ناشناخته
آرش محمدی بدون تغییر — جنسیت متناقض در فایل
محمد یاوری آقای محمد یاوری (فاصله‌های سر/ته فقط در این حالت حذف می‌شوند)
نام ناشناخته احمدی بدون تغییر، با حفظ فاصله‌های اصلی

نکته: حذف فاصله‌های سر/ته به‌تنهایی هیچ‌وقت باعث تغییر نمی‌شود؛ فقط وقتی تصمیم قطعی به افزودن پیشوند گرفته شد، خروجی از نام strip()شده ساخته می‌شود. فاصله‌های داخلی دست‌نخورده می‌مانند (محمد یاوری → آقای محمد یاوری).

نرمال‌سازی (فقط برای کلید جست‌وجو)

NFC + حذف فاصلهٔ سر/ته + یکسان‌سازی ي→ی و ك→ک. مقدارِ ذخیره‌شدهٔ مشتری حرف‌به‌حرف همان مقدار اصلی است (علي رضایی → آقای علي رضایی).

برخورد با نام‌های تکراری و متناقض

یک نام فقط وقتی قابل استفاده است که همهٔ رکوردهای همان کلید نرمال‌شده جنسیت معتبر (M/F) و یکسان داشته باشند:

وضعیت در فایل تصمیم
چند رکورد، همه M یا همه F قابل استفاده
همان نام هم M هم F مبهم ⇒ بدون تغییر
جنسیت ناشناخته (D، MF، FF، خالی) غیرقابل استفاده ⇒ بدون تغییر
رکورد ناقص/Names غیر رشته/نام خالی رد می‌شود؛ کل آن نام کنار گذاشته می‌شود
یکی‌شدن دو کلید با جنسیت متناقض پس از نرمال‌سازی مبهم ⇒ بدون تغییر

«اولین رکورد» یا «پرتکرارترین جنسیت» بدون دلیل معتبر انتخاب نمی‌شود؛ تناقض یعنی نام کنار گذاشته می‌شود.

وضعیت فایل دادهٔ فعلی پروژه:

SHA-256: f5953a216a0b71e59e20249f5bfa6a380a2c3179c6b15e4f0e60616b94948114
رکورد کل: 19881 | کلید قابل‌استفاده: 19870 | کلید غیرقابل‌استفاده: 5
جنسیت نامعتبر: 5  (فروزان/فروزن/فروزنده = D، گوهربانو = FF، مهروز = MF)
نام تکراری: 6، همه هم‌جنسیت (بدون تناقض)

گزینه‌های دستور

گزینه پیش‌فرض کار
--json-path iranianNamesDataset.json در ریشهٔ پروژه مسیر فایل داده
--batch-size 200 تعداد مشتری در هر batch
--dry-run خاموش (اما رفتار پیش‌فرض همین است) گزارش بدون هیچ نوشتنی؛ همراه --apply ناسازگار است
--apply خاموش نوشتن واقعی؛ بدون آن اجرا فقط گزارش است
--confirm-production خاموش تأیید صریح اپراتور؛ همراه --apply الزامی
--resume <JOB_ID> — ادامهٔ یک job مشخص
--new-job خاموش شروع عمدی job جدید، حتی اگر job تکمیل‌شده‌ای برای همین فایل باشد
--samples 5 تعداد نمونهٔ «قبل/بعد» در گزارش

پیش‌فرض دستور بدون نوشتن است. هیچ‌کدام از این‌ها تغییر نمی‌دهند: نام مشتری، رکورد job، ساختار دیتابیس، فایل JSON. حتی migration هم خودکار اجرا نمی‌شود.

اجرای محلی (توسعه)

۱) اول همیشه شبیه‌سازی

از WSL:

cd /mnt/d/projects/khatibi
.venv/Scripts/python.exe manage.py fix_customer_names --dry-run --samples 5

از Windows (PowerShell / CMD):

cd D:\projects\khatibi
.venv\Scripts\python.exe manage.py fix_customer_names --dry-run --samples 5

خروجی نمونه (دادهٔ واقعی همین پروژه، ۱۰٬۴۲۶ مشتری):

مقصد دیتابیس: alias=default engine=django.db.backends.sqlite3
  NAME: D:\Projects\khatibi\data9\kt.sqlite3
  (فقط نام/مسیر دیتابیس چاپ می‌شود؛ credential چاپ نمی‌شود.)
اعتبارسنجی فایل داده
  مسیر: D:\Projects\khatibi\iranianNamesDataset.json
  SHA-256: f5953a21…
  رکورد کل: 19881 | کلید قابل‌استفاده: 19870 | کلید غیرقابل‌استفاده: 5
  ردشده → malformed: 0 | نام خالی: 0 | جنسیت نامعتبر: 5
  نام تکراری: 6 (هم‌جنسیت: 6 | متناقض: 0)
گزارش dry-run — هیچ تغییری نوشته نشد
  مشتریان بررسی‌شده: 10426
  قابل اصلاح / اصلاح‌شده: 7303
  بدون تغییر: 3123
  تک‌کلمه‌ای: 1576 | دارای پیشوند: 89 | خالی/نامعتبر: 0
  ناشناخته در JSON: 1457 | جنسیت مبهم/نامعتبر: 1
  batchهای تکمیل‌شده: 53
  نمونهٔ تغییرات (قبل → بعد):
    «مجتبی سالاری» → «آقای مجتبی سالاری»
    «مرتضی محمد باقری » → «آقای مرتضی محمد باقری»
    «محمد فصاحت » → «آقای محمد فصاحت»
برای اجرای واقعی: --apply --confirm-production

۲) اجرای واقعی (فقط روی مقصد دلخواه)

.venv/Scripts/python.exe manage.py fix_customer_names --apply --confirm-production

بدون --confirm-production:

CommandError: برای نوشتن واقعی، هم --apply و هم --confirm-production لازم است.

اجرا روی سرور Production

پیش‌نیاز: migration باید اعمال شده باشد. روی این پروژه migration سرویس وب خودکار اجرا می‌شود (entrypoint.sh و سرویس migrate در docker-compose.yml)، ولی برای اطمینان:

docker compose run --rm migrate          # یا: python manage.py migrate

اگر جدول job نباشد، دستور پیش از هر تغییری خطا می‌دهد و هیچ نامی را تغییر نمی‌دهد:

CommandError: جدول «admin_panel_customernamefixjob» وجود ندارد.
ابتدا migration را اعمال کن: python manage.py migrate

ترتیب دقیق اجرا

خلاصهٔ اجرایی و کامل این ترتیب در بخش «سریع‌ترین مسیر روی Production» بالای همین سند آمده؛ این همان نسخهٔ جزئی‌تر با تأکید بر فایل داده:

# ۰) فایل داده داخل کانتینر — تأیید وجود و هش
docker compose exec web ls -l /app/iranianNamesDataset.json
docker compose exec web sha256sum /app/iranianNamesDataset.json

# ۱) اعمال migration (لازم؛ جدول CustomerNameFixJob را می‌سازد)
docker compose run --rm migrate

# ۲) بکاپ دیتابیس (بخش «بکاپ پیش از اجرای واقعی» را ببین)
docker compose exec web python manage.py sqlite_db_backup   # نیازمند env های S3
#    اگر S3 تنظیم نیست، اسنیپت WAL-safe بخش «سریع‌ترین مسیر» را بزن.

# ۳) شبیه‌سازی روی دیتابیس واقعی — هیچ نوشتنی ندارد
docker compose exec web python manage.py fix_customer_names --dry-run --batch-size 200 --samples 5

# ۴) اگر اعداد منطقی بود: اجرای واقعی
docker compose exec web python manage.py fix_customer_names --apply --confirm-production --batch-size 200

# ۵) بررسی خلاصه و وضعیت job
docker compose exec web python manage.py shell -c "from admin_panel.models import CustomerNameFixJob as J; print(list(J.objects.values('id','status','source_hash','last_processed_pk','initial_max_pk','stats')))"

چرا docker compose exec web و نه python manage.py؟ روی VPS پایتون پروژه فقط داخل کانتینر است و دیتابیس هم در ولوم /app/sqlite همان کانتینر قرار دارد. اجرای python manage.py روی میزبان یا به دیتابیس اشتباه وصل می‌شود یا اصلاً وجود ندارد.

اطمینان از انتخاب دیتابیس درست

دستور از تنظیمات فعال Django (DJANGO_SETTINGS_MODULE) استفاده می‌کند و هیچ مسیر SQLite ای هاردکد نشده است. پیش از هر کاری، alias + engine + مسیر دیتابیس را چاپ می‌کند (بدون credential):

مقصد دیتابیس: alias=default engine=django.db.backends.sqlite3
  NAME: /app/sqlite/db.sqlite3
  توجه: ماژول تنظیمات production فعال است.
  • روی سرور، DJANGO_SETTINGS_MODULE=khatibi.production_settings و NAME=/app/sqlite/db.sqlite3 (ولوم khatibi-sqlite-db در کانتینر) است.
  • در توسعه، khatibi.settings و data9/kt.sqlite3.
  • اگر مسیر چاپ‌شده همان چیزی نیست که انتظار داری، همان‌جا Ctrl+C بزن؛ هیچ داده‌ای تا آن لحظه نوشته نشده است.
  • تشخیص production بر پایهٔ نام ماژول تنظیمات است، نه DEBUG. (در این پروژه production_settings.py مقدار DEBUG=True دارد؛ پس اتکا به DEBUG غلط بود و عمداً از آن استفاده نشده.)

بکاپ پیش از اجرای واقعی

دستور آمادهٔ همین پروژه روی سرور (لینوکس/کانتینر): sqlite_db_backup — از متد بومی SQLite (Connection.backup) با اتصال mode=ro استفاده می‌کند و بنابراین با حالت WAL سازگار است. دو شرط دارد: فقط روی لینوکس اجرا می‌شود (fcntl) و متغیرهای S3 را لازم دارد (S3_ACCESS_KEY, S3_SECRET_KEY, S3_ENDPOINT, S3_BUCKET).

# داخل کانتینر (لینوکس) — نتیجه در S3 ذخیره و اعتبارسنجی می‌شود
docker compose exec web python manage.py sqlite_db_backup

اگر S3 در دسترس نیست یا روی ویندوز/WSL کار می‌کنی، همین بکاپ WAL-safe را مستقیم بگیر (همه‌جا کار می‌کند و به env نیاز ندارد):

# WSL
.venv/Scripts/python.exe - <<'PY'
import sqlite3, pathlib, datetime
src = pathlib.Path('data9/kt.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)   # کپی سطح C، سازگار با WAL
d.close(); s.close()
print("backup:", dst, dst.stat().st_size, "bytes")
PY

کپی سادهٔ فایل SQLite در حین اجرای برنامه بکاپ معتبر نیست. دیتابیس در حالت WAL اجرا می‌شود (entrypoint.sh)؛ اگر فقط db.sqlite3 را cp کنی، تراکنش‌های موجود در db.sqlite3-wal از دست می‌روند و ممکن است فایل خراب بازیابی شود. اگر دستی بکاپ می‌گیری، باید هر سه فایل (db.sqlite3، -wal، -shm) در لحظهٔ بدون نوشتن کپی شوند — و بهترین راه همان sqlite3.Connection.backup بالا است.

نکات مکمل:

  • فضای دیسک: قبل از بکاپ حداقل ~۱.۵ برابر حجم دیتابیس خالی باشد (دیتابیس فعلی ≈۷۹ مگابایت).
  • وضعیت WAL: PRAGMA journal_mode; باید wal برگرداند.
  • دسترسی فایل: کاربر اجراکنندهٔ کانتینر باید روی /app/sqlite و روی فایل JSON دسترسی خواندن/نوشتن داشته باشد.
  • فایل iranianNamesDataset.json هرگز تغییر داده، بازنویسی یا حذف نمی‌شود؛ دستور فقط آن را می‌خواند.

ادامهٔ job پس از crash / قطع برق

هر batch شامل سه چیز است که در یک تراکنش اتمیک ثبت می‌شوند:

  1. تغییرات name_family مشتریان واجد شرایط همان batch،
  2. last_processed_pk (checkpoint)،
  3. heartbeat_at و آمار تجمیعی.

پس یا کل batch ثبت شده یا هیچ‌کدام. دامنهٔ job در لحظهٔ آغاز با بزرگ‌ترین primary key مشتریان قفل می‌شود (initial_max_pk)؛ مشتریان جدیدتر وارد دامنه نمی‌شوند.

گزینه‌های ادامه

وضعیت رفتار پیش‌فرض راه‌حل
job ناتمام با همان فایل و همان نسخهٔ الگوریتم خودکار ادامه می‌یابد فقط همان دستور --apply --confirm-production را دوباره بزن
می‌خواهی صریحاً همان job را ادامه دهی — --apply --confirm-production --resume <JOB_ID>
job قبلی completed است خطا؛ خودکار job جدید نمی‌سازد --apply --confirm-production --new-job
فایل عوض شده (hash متفاوت) توقف، بدون هیچ تغییری --new-job با فایل جدید
نسخهٔ الگوریتم عوض شده توقف --new-job

پیام خطاهای مربوطه:

CommandError: hash فایل ورودی با job#3 نمی‌خواند؛ ادامه رد شد و هیچ تغییری اعمال نمی‌شود.
CommandError: job#3 قبلاً تکمیل شده است؛ برای اجرای دوباره --new-job بده.
CommandError: job#3 همین حالا در حال اجراست (heartbeat تازه). اجرای هم‌زمان مجاز نیست.

دستورهای دقیق ادامه / شروع مجدد

# ادامهٔ خودکار آخرین job ناتمامِ همین فایل (ساده‌ترین راه)
python manage.py fix_customer_names --apply --confirm-production

# ادامهٔ صریح یک job مشخص
python manage.py fix_customer_names --apply --confirm-production --resume 3

# شروع عمدی job جدید (پس از تکمیل job قبلی یا با فایل جدید)
python manage.py fix_customer_names --apply --confirm-production --new-job

# شروع job جدید با فایل دیگر و حجم batch کوچک‌تر (ساعات پرترافیک)
python manage.py fix_customer_names --apply --confirm-production --new-job \
    --json-path /app/data/iranianNamesDataset_v2.json --batch-size 50

اجرای دوبارهٔ بی‌خطر: با --new-job روی دادهٔ اصلاح‌شده، changed = 0 می‌شود، چون نام‌های دارای پیشوند دیگر رد می‌شوند.

قفل و اجرای هم‌زمان

  • جلوگیری از اجرای هم‌زمان با heartbeat انجام می‌شود، نه select_for_update(). (روی SQLite قفل ردیفی واقعی وجود ندارد؛ اتکا به select_for_update گمراه‌کننده بود.)
  • هر batch مقدار heartbeat_at را به‌روز می‌کند. jobی که status=running دارد و heartbeat آن تازه است (< LOCK_TTL = 15 دقیقه)، اجرای دوم را مسدود می‌کند.
  • اگر سرور وسط کار خاموش شود، heartbeat کهنه می‌ماند و اجرای بعدی قفل را به‌صورت کنترل‌شده آزاد می‌کند و به‌عنوان failed علامت می‌زند:
job#1 ناتمام رها شده بود و اکنون به‌عنوان failed آزاد شد.

پس از آن، همان دستور از last_processed_pk ادامه می‌دهد. قفل، دائمی نمی‌شود.

SQLite، قفل دیتابیس و بار روی سایت

  • batchهای کوچک (--batch-size 200) با تراکنش‌های کوتاه؛ هیچ تراکنش واحدی روی ۱۰٬۴۲۶ مشتری ساخته نمی‌شود.
  • خطای database is locked: بازآزمایی حداکثر 4 بار با backoff نمایی (۰.۵s، ۱s، ۲s) به‌همراه jitter. retry نامحدود وجود ندارد.
  • خطاهای غیرقابل‌بازیابی فوراً بالا می‌آیند؛ batch جاری rollback می‌شود، checkpoint جلو نمی‌رود و job با failed ثبت می‌شود.
  • PRAGMA busy_timeout=30000 موجود (در admin_panel/signals.py) دست‌نخورده است. journal_mode=WAL و synchronous=FULL هم تغییر داده نشده‌اند.
  • محدودیت باقی‌مانده: SQLite در هر لحظه فقط یک نویسنده دارد. بهتر است دستور را در ساعات کم‌ترافیک اجرا کنی؛ اگر web/worker هم‌زمان بنویسند، ممکن است چند batch با backoff کندتر شوند (نه خطا). سایت متوقف نمی‌شود.

مدل وضعیت job

CustomerNameFixJob — فقط وضعیت و checkpoint. هیچ اطلاعاتی از جنسیت یا هویت مشتری در آن ذخیره نمی‌شود.

فیلد معنا
source_hash SHA-256 فایل ورودی؛ مبنای تشخیص «همان فایل»
algorithm_version نسخهٔ الگوریتم تصمیم (ALGORITHM_VERSION)
status running / paused / completed / failed
batch_size اندازهٔ batch هنگام آغاز
initial_max_pk سقف دامنه (بیشترین pk مشتریان در آغاز)
last_processed_pk checkpoint؛ آخرین pk پردازش‌شده
stats آمار تجمیعی (JSON)
note توضیح وضعیت/خطا
started_at / updated_at / finished_at / heartbeat_at زمان‌ها

شرط خواندن هر batch: last_processed_pk < pk <= initial_max_pk، مرتب بر اساس pk. هیچ‌وقت از offset یا شمارهٔ ردیف استفاده نمی‌شود (offset با افزودن/حذف رکورد جابه‌جا می‌شود و باعث پرش/تکرار می‌شود).

بکاپ/بازیابی خودکار نیست

دستور بکاپ نمی‌گیرد و تغییرات را «undo» نمی‌کند. برای برگشت، بکاپِ قبل از اجرا را بازیابی کن:

# ۱) توقف سرویس‌هایی که می‌نویسند
docker compose stop web worker scheduler delivery

# ۲) جایگزینی فایل دیتابیس در ولوم، با پاک کردن WAL/SHM قدیمی
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"

# ۳) بالا آوردن دوباره (سرویس migrate اول اجرا می‌شود)
docker compose start migrate web worker scheduler delivery

اگر از sqlite_db_backup استفاده کرده‌ای، فایل بکاپ در S3 است؛ اول آن را با aws s3 cp/کلاینت S3 دانلود کن و بعد مراحل بالا را اجرا کن.

خطاهای رایج و اقدام درست

خطا معنا اقدام
فایل داده پیدا نشد: … مسیر --json-path غلط مسیر را با ls تأیید کن
JSON نامعتبر است: … فایل خراب است هیچ تغییری اعمال نشد؛ فایل درست را بگذار
کلید «RECORDS» در فایل JSON وجود ندارد ساختار فایل عوض شده فایل را اصلاح کن — هیچ نامی تغییر نمی‌کند
جدول … وجود ندارد migration اعمال نشده python manage.py migrate
hash فایل ورودی … نمی‌خواند فایل عوض شده یا فایل قبلی را برگردان، یا --new-job
همین حالا در حال اجراست یک اجرای دیگر فعال است صبر کن یا --resume همان job را بده
database is locked (پس از ۴ تلاش) فشار نوشتن هم‌زمان دوباره اجرا کن؛ از checkpoint ادامه می‌دهد
برای نوشتن واقعی، هم --apply و هم --confirm-production لازم است تأیید ناقص هر دو گزینه را بده

بررسی صحت داده (SQL، پیش از/پس از اجرا)

-- وضعیت jobها
SELECT id, status, source_hash, last_processed_pk, initial_max_pk,
       started_at, finished_at
FROM admin_panel_customernamefixjob ORDER BY id;

-- مشتریانی که پیشوند ندارند ولی نامشان چندکلمه‌ای است (کاندید بالقوه)
SELECT COUNT(*) FROM admin_panel_customer
WHERE TRIM(name_family) LIKE '% %'
  AND name_family NOT LIKE 'آقا %'
  AND name_family NOT LIKE 'آقای %'
  AND name_family NOT LIKE 'خانم %';

-- تعدادی که پیشوند گرفته‌اند (نتیجهٔ اجرای واقعی)
SELECT COUNT(*) FROM admin_panel_customer WHERE name_family LIKE 'آقای %';
SELECT COUNT(*) FROM admin_panel_customer WHERE name_family LIKE 'خانم %';

تست‌ها

.venv/Scripts/python.exe manage.py test tests.test_customer_name_fix   # ۳۰ تست
.venv/Scripts/python.exe manage.py test tests                          # کل مجموعه
.venv/Scripts/python.exe manage.py makemigrations --check --dry-run     # باید «No changes detected» بدهد

تست‌ها فقط روی دیتابیس تستی Django (SQLite درون‌حافظه) و با فایل JSON ساختگیِ موقت اجرا می‌شوند؛ نه به data9/kt.sqlite3 دست می‌زنند و نه به فایل دادهٔ واقعی.

نکته‌ها و محدودیت‌ها

  • نام تک‌کلمه‌ای هیچ‌وقت تغییر نمی‌کند — حتی اگر در فایل با جنسیت قطعی باشد؛ چون ممکن است فقط فامیل باشد.
  • حرف اضافه/کم کردن ممنوع است: نه fuzzy matching، نه حذف حروف، نه حدس از فامیل، نه انتخاب تصادفی جنسیت.
  • نام‌های دارای پیشوند دوباره پیشوند نمی‌گیرند (idempotent).
  • دستور save() کامل صدا نمی‌زند؛ روی Customer سیگنال و منطق اجباری در save() وجود ندارد، پس bulk_update(['name_family']) امن است و فقط همان یک ستون را می‌نویسد (فیلدهای auto_now_add مثل create_date لمس نمی‌شوند).
  • منطق روی name_family اجرا می‌شود، نه name — این نام واقعی فیلد در این پروژه است.
  • ۵ نام موجود در فایل جنسیت نامعتبر دارند (D/FF/MF) و عمداً کنار گذاشته می‌شوند؛ مشتریانی با آن نام‌ها بدون پیشوند می‌مانند.
  • نام‌هایی که در فایل نیستند (۱۴۵۷ مورد در دادهٔ فعلی) پیشوند نمی‌گیرند. اگر بعداً فایل کامل‌تری آمد، با --json-path فایل جدید و --new-job می‌توان دور تازه‌ای اجرا کرد.
  • برای تغییر قواعد تصمیم یا نرمال‌سازی، ALGORITHM_VERSION در admin_panel/name_fix.py را بالا ببر تا jobهای قدیمی ناخواسته ادامه داده نشوند.
  • کنسول ویندوز: متن فارسی در خروجی ممکن است charmap خطا بدهد؛ مثل بقیهٔ سرویس‌های پروژه از python -X utf8 manage.py … استفاده کن.

چک‌لیست سریع (کپی/پیست)

# ── توسعه (WSL) ──────────────────────────────────────────────
.venv/Scripts/python.exe manage.py test tests.test_customer_name_fix
.venv/Scripts/python.exe manage.py fix_customer_names --dry-run --samples 5

# ── Production ───────────────────────────────────────────────
docker compose run --rm migrate                          # ۱) مهاجرت
docker compose exec web python manage.py sqlite_db_backup # ۲) بکاپ
docker compose exec web python manage.py fix_customer_names --dry-run --samples 10
docker compose exec web python manage.py fix_customer_names --apply --confirm-production
docker compose exec web python manage.py fix_customer_names --apply --confirm-production --resume <JOB_ID>

گزارش نهایی یک اجرای واقعی:

گزارش اجرای واقعی job#1 (completed)
  مشتریان بررسی‌شده: 10426
  قابل اصلاح / اصلاح‌شده: 7303
  بدون تغییر: 3123
  تک‌کلمه‌ای: 1576 | دارای پیشوند: 89 | خالی/نامعتبر: 0
  ناشناخته در JSON: 1457 | جنسیت مبهم/نامعتبر: 1
  batchهای تکمیل‌شده: 53
  شناسهٔ job: 1 | شروع: … | پایان: …
  نمونهٔ تغییرات (قبل → بعد):
    «مجتبی سالاری» → «آقای مجتبی سالاری»

تعریف کلیدهای آماری

کلید معنا
scanned کل مشتریان بررسی‌شده در دامنهٔ job
changed نام‌هایی که پیشوند گرفتند (یا در dry-run، می‌گیرند)
unchanged scanned - changed
single_word تک‌کلمه‌ای ⇒ دست‌نخورده
has_prefix از قبل آقا/آقای/خانم ⇒ دست‌نخورده
empty_invalid خالی/فقط فاصله ⇒ دست‌نخورده
unknown_name اولین کلمه در فایل نیست ⇒ دست‌نخورده
ambiguous_gender جنسیت متناقض/نامعتبر ⇒ دست‌نخورده
batches تعداد batchهای commit‌شده