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

643 lines
35 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# افزودن پیشوند «آقای/خانم» به نام مشتریان
## چرا؟
فیلد `Customer.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` اجرا می‌شود:
```bash
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` حالا **صریحاً** این فایل را کپی می‌کند (خط ۶۳):
```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:
```bash
# الف) کپی دستی داخل کانتینر در حال اجرا (تا بازسازی بعدی کانتینر پایدار است)
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
```
برای تأیید اینکه ایمیج ساخته‌شده واقعاً فایل را دارد:
```bash
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` بدهی.
### اگر وسط کار قطع شد
```bash
# الف) ادامهٔ خودکار: فقط همان دستور را دوباره بزن.
# خودش آخرین 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`.
### بررسی نتیجه
```bash
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:
```bash
cd /mnt/d/projects/khatibi
.venv/Scripts/python.exe manage.py fix_customer_names --dry-run --samples 5
```
از Windows (PowerShell / CMD):
```bat
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
```
### ۲) اجرای واقعی (فقط روی مقصد دلخواه)
```bash
.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`)،
ولی برای اطمینان:
```bash
docker compose run --rm migrate # یا: python manage.py migrate
```
اگر جدول job نباشد، دستور **پیش از هر تغییری** خطا می‌دهد و هیچ نامی را
تغییر نمی‌دهد:
```
CommandError: جدول «admin_panel_customernamefixjob» وجود ندارد.
ابتدا migration را اعمال کن: python manage.py migrate
```
### ترتیب دقیق اجرا
خلاصهٔ اجرایی و کامل این ترتیب در بخش
**«سریع‌ترین مسیر روی Production»** بالای همین سند آمده؛ این همان نسخهٔ
جزئی‌تر با تأکید بر فایل داده:
```bash
# ۰) فایل داده داخل کانتینر — تأیید وجود و هش
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`).
```bash
# داخل کانتینر (لینوکس) — نتیجه در S3 ذخیره و اعتبارسنجی می‌شود
docker compose exec web python manage.py sqlite_db_backup
```
اگر S3 در دسترس نیست یا روی ویندوز/WSL کار می‌کنی، همین بکاپ WAL-safe را
مستقیم بگیر (همه‌جا کار می‌کند و به env نیاز ندارد):
```bash
# 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 تازه). اجرای هم‌زمان مجاز نیست.
```
### دستورهای دقیق ادامه / شروع مجدد
```bash
# ادامهٔ خودکار آخرین 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» نمی‌کند. برای برگشت، بکاپِ قبل از اجرا
را بازیابی کن:
```bash
# ۱) توقف سرویس‌هایی که می‌نویسند
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، پیش از/پس از اجرا)
```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 'خانم %';
```
## تست‌ها
```bash
.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 …` استفاده کن.
## چک‌لیست سریع (کپی/پیست)
```bash
# ── توسعه (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‌شده |