fix mobile and names

This commit is contained in:
javad committed 2026-10-15 11:21:55 +03:30
1 parent e3a6d1f9d4
commit 7e5c83bea2
12 files changed
+82792 -6

No files matched your search

+642
View File
@@ -0,0 +1,642 @@
# افزودن پیشوند «آقای/خانم» به نام مشتریان
## چرا؟
فیلد `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‌شده |