# افزودن پیشوند «آقای/خانم» به نام مشتریان ## چرا؟ فیلد `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 مشخص | | `--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 قبلی `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#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‌شده |