fix mobile and names
This commit is contained in:
1 parent
e3a6d1f9d4
commit
7e5c83bea2
12 files changed
+82792
-6
No files matched your search
@@ -56,6 +56,12 @@ RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \
|
|||||||
# کپی کردن و واگذاری مالکیت محیط مجازی به کاربر django
|
# کپی کردن و واگذاری مالکیت محیط مجازی به کاربر django
|
||||||
COPY --from=builder --chown=django:django /opt/venv /opt/venv
|
COPY --from=builder --chown=django:django /opt/venv /opt/venv
|
||||||
|
|
||||||
|
# کپی صریح فایل دادهٔ نامهای ایرانی (پیشنیاز دستور fix_customer_names).
|
||||||
|
# صریح نوشته شده تا حتی اگر context ساخت یا .dockerignore در آینده عوض شود،
|
||||||
|
# فایل بیسروصدا از ایمیج بیرون نیفتد. اگر فایل در context نباشد، build با خطای
|
||||||
|
# روشن شکست میخورد (fail-fast) — بهتر از اجرای دستور روی سرور و خطای «فایل پیدا نشد».
|
||||||
|
COPY --chown=django:django iranianNamesDataset.json /app/iranianNamesDataset.json
|
||||||
|
|
||||||
# کپی کردن تمام کدهای پروژه با مالکیت کاربر محدود شده
|
# کپی کردن تمام کدهای پروژه با مالکیت کاربر محدود شده
|
||||||
COPY --chown=django:django . .
|
COPY --chown=django:django . .
|
||||||
|
|
||||||
|
|||||||
+13
-6
@@ -1,10 +1,10 @@
|
|||||||
Project Root: khatibi
|
Project Root: khatibi
|
||||||
Generated At: 2026-10-11T12:20:45+00:00
|
Generated At: 2026-10-15T06:59:41+00:00
|
||||||
Python Files: 171
|
Python Files: 175
|
||||||
Django Files: 12
|
Django Files: 12
|
||||||
JavaScript/TypeScript Files: 14
|
JavaScript/TypeScript Files: 14
|
||||||
HTML Files: 58
|
HTML Files: 58
|
||||||
Total Files: 682
|
Total Files: 689
|
||||||
Total Directories: 76
|
Total Directories: 76
|
||||||
|
|
||||||
khatibi
|
khatibi
|
||||||
@@ -38,6 +38,7 @@ khatibi
|
|||||||
│ ├── management
|
│ ├── management
|
||||||
│ │ ├── commands
|
│ │ ├── commands
|
||||||
│ │ │ ├── __init__.py
|
│ │ │ ├── __init__.py
|
||||||
|
│ │ │ ├── fix_customer_names.py
|
||||||
│ │ │ ├── fix_customer_phones.py
|
│ │ │ ├── fix_customer_phones.py
|
||||||
│ │ │ ├── gt_esh.py
|
│ │ │ ├── gt_esh.py
|
||||||
│ │ │ ├── recalc_last_bazdid.py
|
│ │ │ ├── recalc_last_bazdid.py
|
||||||
@@ -119,6 +120,7 @@ khatibi
|
|||||||
│ │ ├── 0069_smstemplate_auto_send.py
|
│ │ ├── 0069_smstemplate_auto_send.py
|
||||||
│ │ ├── 0070_sms_types_seven.py
|
│ │ ├── 0070_sms_types_seven.py
|
||||||
│ │ ├── 0071_sms_settings_permission.py
|
│ │ ├── 0071_sms_settings_permission.py
|
||||||
|
│ │ ├── 0072_customernamefixjob.py
|
||||||
│ │ └── __init__.py
|
│ │ └── __init__.py
|
||||||
│ ├── models
|
│ ├── models
|
||||||
│ │ ├── __init__.py
|
│ │ ├── __init__.py
|
||||||
@@ -302,6 +304,7 @@ khatibi
|
|||||||
│ ├── admin.py
|
│ ├── admin.py
|
||||||
│ ├── apps.py
|
│ ├── apps.py
|
||||||
│ ├── context_processor.py
|
│ ├── context_processor.py
|
||||||
|
│ ├── name_fix.py
|
||||||
│ ├── services.py
|
│ ├── services.py
|
||||||
│ ├── signals.py
|
│ ├── signals.py
|
||||||
│ ├── tests.py
|
│ ├── tests.py
|
||||||
@@ -312,9 +315,7 @@ khatibi
|
|||||||
│ ├── db2 (2).sqlite32
|
│ ├── db2 (2).sqlite32
|
||||||
│ ├── db2.sqlite32
|
│ ├── db2.sqlite32
|
||||||
│ ├── db222.sqlite3
|
│ ├── db222.sqlite3
|
||||||
│ ├── kt.sqlite3
|
│ └── kt.sqlite3
|
||||||
│ ├── kt.sqlite3-shm
|
|
||||||
│ └── kt.sqlite3-wal
|
|
||||||
├── graphify-out
|
├── graphify-out
|
||||||
│ ├── 2026-10-09
|
│ ├── 2026-10-09
|
||||||
│ │ ├── .graphify_analysis.json
|
│ │ ├── .graphify_analysis.json
|
||||||
@@ -428,6 +429,7 @@ khatibi
|
|||||||
│ │ │ ├── 5d994876cad71133f4bf804932fb2604ffdc8bb8cc434ca1434667ef789144e7.json
|
│ │ │ ├── 5d994876cad71133f4bf804932fb2604ffdc8bb8cc434ca1434667ef789144e7.json
|
||||||
│ │ │ ├── 5e19aa3aba5509cd48a9a93b39db071cfc2c2728ccae46b34caa0e214cc6be01.json
|
│ │ │ ├── 5e19aa3aba5509cd48a9a93b39db071cfc2c2728ccae46b34caa0e214cc6be01.json
|
||||||
│ │ │ ├── 5f050da3769e151e52138ee1ea5dcbcb6160cb57952c75665e607628d0e08d67.json
|
│ │ │ ├── 5f050da3769e151e52138ee1ea5dcbcb6160cb57952c75665e607628d0e08d67.json
|
||||||
|
│ │ │ ├── 5f858ab6d9f90e0f41617273b6f132af3f76f520deafb8d612adce373875e768.json
|
||||||
│ │ │ ├── 60002a01d6fc22147e2994d1a1a447d5e577c02aed2443789af20571ec2d2a19.json
|
│ │ │ ├── 60002a01d6fc22147e2994d1a1a447d5e577c02aed2443789af20571ec2d2a19.json
|
||||||
│ │ │ ├── 60e00560038df059b1696e54dcf055cd658677d6e1201570a8500531addc0ac6.json
|
│ │ │ ├── 60e00560038df059b1696e54dcf055cd658677d6e1201570a8500531addc0ac6.json
|
||||||
│ │ │ ├── 62d88411a61ee9d3b60dda6c4febf33539b8b9e8226b5d2b9205f8e2b0c4478a.json
|
│ │ │ ├── 62d88411a61ee9d3b60dda6c4febf33539b8b9e8226b5d2b9205f8e2b0c4478a.json
|
||||||
@@ -471,6 +473,7 @@ khatibi
|
|||||||
│ │ │ ├── 81d6d00c4c975b3289ac212d355856c3d6e60bccde0780fe2e3fc8cac31f3c19.json
|
│ │ │ ├── 81d6d00c4c975b3289ac212d355856c3d6e60bccde0780fe2e3fc8cac31f3c19.json
|
||||||
│ │ │ ├── 82833f6235cb6559bad38e93372d42284eb4e9097e90519337266113da2495bf.json
|
│ │ │ ├── 82833f6235cb6559bad38e93372d42284eb4e9097e90519337266113da2495bf.json
|
||||||
│ │ │ ├── 828dacee98d6f71fcf53c8a051b3bf1bfc9d94c272a79ed8539801e3fb90b3df.json
|
│ │ │ ├── 828dacee98d6f71fcf53c8a051b3bf1bfc9d94c272a79ed8539801e3fb90b3df.json
|
||||||
|
│ │ │ ├── 82916053b0e3cfa286347ea0a7101f2d06678bd28b8b8d8fe955d11bcb7a43aa.json
|
||||||
│ │ │ ├── 837801d7d0e218e49de562a7e03711e75941d42ce5cc2f77552bc6293a45a4e8.json
|
│ │ │ ├── 837801d7d0e218e49de562a7e03711e75941d42ce5cc2f77552bc6293a45a4e8.json
|
||||||
│ │ │ ├── 84281ba94e70a71d52b5179cf2e933f8412da3a9eaf5e7177b815f8fadd0afc5.json
|
│ │ │ ├── 84281ba94e70a71d52b5179cf2e933f8412da3a9eaf5e7177b815f8fadd0afc5.json
|
||||||
│ │ │ ├── 846ba1087a1ee6b5cda2ebdf22d0bc61ad9175b21aba29783429277288dbfaaf.json
|
│ │ │ ├── 846ba1087a1ee6b5cda2ebdf22d0bc61ad9175b21aba29783429277288dbfaaf.json
|
||||||
@@ -513,6 +516,7 @@ khatibi
|
|||||||
│ │ │ ├── b82d5f6840b4930eb62fdb1c7b3a58124fff805e529ede7684516a65322fcae0.json
|
│ │ │ ├── b82d5f6840b4930eb62fdb1c7b3a58124fff805e529ede7684516a65322fcae0.json
|
||||||
│ │ │ ├── b9f8ea31056fe0c0fc40a84640c58f29895312c65bf694567487204d8f1170e9.json
|
│ │ │ ├── b9f8ea31056fe0c0fc40a84640c58f29895312c65bf694567487204d8f1170e9.json
|
||||||
│ │ │ ├── bb140cfe12cfb3e32dc9815393035fc33c7649c2faeeccb82cdee56a930ea635.json
|
│ │ │ ├── bb140cfe12cfb3e32dc9815393035fc33c7649c2faeeccb82cdee56a930ea635.json
|
||||||
|
│ │ │ ├── bb74cda4073a6da7a4b60fecfb3bb40796a51ceafcbdd128d60e5ce23fa4f319.json
|
||||||
│ │ │ ├── bc24ddb05a8aa685edc1376cbd110423adf05bcc488fb97a30fe286e7e74c2da.json
|
│ │ │ ├── bc24ddb05a8aa685edc1376cbd110423adf05bcc488fb97a30fe286e7e74c2da.json
|
||||||
│ │ │ ├── bc6f93c7938b3565d50cdfc4abe4fd62f0739fd6299f35d7c50075cd860cab58.json
|
│ │ │ ├── bc6f93c7938b3565d50cdfc4abe4fd62f0739fd6299f35d7c50075cd860cab58.json
|
||||||
│ │ │ ├── bcea7c584e5031a9c5734144f8169d016ef1ed316e6f4827609509ba2a79d3eb.json
|
│ │ │ ├── bcea7c584e5031a9c5734144f8169d016ef1ed316e6f4827609509ba2a79d3eb.json
|
||||||
@@ -730,6 +734,7 @@ khatibi
|
|||||||
├── tests
|
├── tests
|
||||||
│ ├── __init__.py
|
│ ├── __init__.py
|
||||||
│ ├── factories.py
|
│ ├── factories.py
|
||||||
|
│ ├── test_customer_name_fix.py
|
||||||
│ ├── test_customers.py
|
│ ├── test_customers.py
|
||||||
│ ├── test_factors.py
|
│ ├── test_factors.py
|
||||||
│ ├── test_inventory.py
|
│ ├── test_inventory.py
|
||||||
@@ -750,9 +755,11 @@ khatibi
|
|||||||
├── Dockerfile
|
├── Dockerfile
|
||||||
├── entrypoint.sh
|
├── entrypoint.sh
|
||||||
├── financial_audit_20260816_151225.csv
|
├── financial_audit_20260816_151225.csv
|
||||||
|
├── fix_customer_names.md
|
||||||
├── fix_customer_phones.md
|
├── fix_customer_phones.md
|
||||||
├── GEMINI.md
|
├── GEMINI.md
|
||||||
├── ghost_hunter.py
|
├── ghost_hunter.py
|
||||||
|
├── iranianNamesDataset.json
|
||||||
├── khatibi.zip
|
├── khatibi.zip
|
||||||
├── local.env
|
├── local.env
|
||||||
├── manage.py
|
├── manage.py
|
||||||
|
|||||||
@@ -0,0 +1,484 @@
|
|||||||
|
"""افزودن قطعیِ پیشوند «آقای/خانم» به ابتدای نام مشتریان، بر پایهٔ فایل نامهای ایرانی.
|
||||||
|
|
||||||
|
سیاستها (کاملاً محافظهکارانه):
|
||||||
|
|
||||||
|
* **پیشفرض بدون نوشتن است** (dry-run). نوشتن فقط با ``--apply`` ممکن است.
|
||||||
|
* برای اجرای واقعی، علاوه بر ``--apply``، تأیید صریح ``--confirm-production`` لازم است.
|
||||||
|
* فقط فیلد ``Customer.name_family`` و آن هم تنها در موارد قطعی تغییر میکند.
|
||||||
|
* هیچ جنسیتی ذخیره نمیشود؛ هیچ فیلدی به مدل مشتری اضافه نشده است.
|
||||||
|
|
||||||
|
اجرا:
|
||||||
|
|
||||||
|
python manage.py fix_customer_names --dry-run
|
||||||
|
python manage.py fix_customer_names --apply --confirm-production
|
||||||
|
|
||||||
|
ادامهٔ job متوقفشده (پیشفرض: خودکار آخرین job ناتمام با همان hash ادامه مییابد):
|
||||||
|
|
||||||
|
python manage.py fix_customer_names --apply --confirm-production --resume <JOB_ID>
|
||||||
|
|
||||||
|
شروع عمدی job جدید (مثلاً پس از تکمیل job قبلی یا تغییر فایل):
|
||||||
|
|
||||||
|
python manage.py fix_customer_names --apply --confirm-production --new-job
|
||||||
|
|
||||||
|
جزئیات ایمنی:
|
||||||
|
|
||||||
|
* فایل JSON در هر اجرا فقط یک بار خوانده و parse میشود و هرگز نوشته نمیشود.
|
||||||
|
* تغییرات نام و checkpoint هر batch در یک تراکنش اتمیک روی همان دیتابیس ثبت میشوند.
|
||||||
|
* دامنهٔ job با بیشترین pk مشتریان در لحظهٔ آغاز قفل میشود؛ مشتریان جدیدتر وارد نمیشوند.
|
||||||
|
* قفل بر پایهٔ heartbeat است و پس از crash (پس از ``LOCK_TTL``) قابل بازیابی است.
|
||||||
|
* خطای «database is locked» با backoff نمایی + jitter و تعداد محدود retry میشود.
|
||||||
|
* فرمان هیچ migration خودکاری اجرا نمیکند.
|
||||||
|
"""
|
||||||
|
import random
|
||||||
|
import time
|
||||||
|
from datetime import timedelta
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from django.conf import settings
|
||||||
|
from django.core.management.base import BaseCommand, CommandError
|
||||||
|
from django.db import DEFAULT_DB_ALIAS, connections, transaction
|
||||||
|
from django.db.models import Max
|
||||||
|
from django.db.utils import OperationalError
|
||||||
|
from django.utils import timezone
|
||||||
|
|
||||||
|
from admin_panel.models import Customer, CustomerNameFixJob
|
||||||
|
from admin_panel.name_fix import (ALGORITHM_VERSION, DatasetError,
|
||||||
|
decide_prefix, load_name_index)
|
||||||
|
|
||||||
|
DEFAULT_JSON_FILENAME = "iranianNamesDataset.json"
|
||||||
|
|
||||||
|
# مدت اعتبار قفل/heartbeat. batchها کوچکاند، پس این مقدار سخاوتمندانه است.
|
||||||
|
LOCK_TTL = timedelta(minutes=15)
|
||||||
|
MAX_DB_RETRIES = 4
|
||||||
|
BACKOFF_BASE_SECONDS = 0.5
|
||||||
|
|
||||||
|
# کلیدهای آماری که در ``stats`` مدل job ذخیره و بین اجراها منتقل میشوند.
|
||||||
|
STATS_KEYS = (
|
||||||
|
"scanned", "changed", "unchanged", "single_word", "has_prefix",
|
||||||
|
"empty_invalid", "unknown_name", "ambiguous_gender", "batches",
|
||||||
|
)
|
||||||
|
|
||||||
|
# نگاشت «دلیل تصمیم» به «کلید آماری».
|
||||||
|
REASON_TO_STAT = {
|
||||||
|
"male": "changed",
|
||||||
|
"female": "changed",
|
||||||
|
"single_word": "single_word",
|
||||||
|
"has_prefix": "has_prefix",
|
||||||
|
"empty": "empty_invalid",
|
||||||
|
"unknown_name": "unknown_name",
|
||||||
|
"ambiguous_gender": "ambiguous_gender",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class Command(BaseCommand):
|
||||||
|
help = (
|
||||||
|
"افزودن پیشوند «آقای/خانم» به نام مشتریان بر پایهٔ فایل نامهای ایرانی؛ "
|
||||||
|
"پیشفرض dry-run است و نوشتن نیازمند --apply --confirm-production."
|
||||||
|
)
|
||||||
|
|
||||||
|
def add_arguments(self, parser):
|
||||||
|
parser.add_argument(
|
||||||
|
"--json-path", default=str(Path(settings.BASE_DIR) / DEFAULT_JSON_FILENAME),
|
||||||
|
help=f"مسیر فایل دادهٔ نامها (پیشفرض: {DEFAULT_JSON_FILENAME} در ریشهٔ پروژه).",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--batch-size", type=int, default=200,
|
||||||
|
help="تعداد مشتریان در هر batch (پیشفرض ۲۰۰).",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--dry-run", action="store_true",
|
||||||
|
help="صریحاً حالت شبیهسازی (پیشفرض همین است؛ همراه --apply مجاز نیست).",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--apply", action="store_true",
|
||||||
|
help="نوشتن واقعی در دیتابیس. بدون این گزینه فقط گزارش (dry-run) تولید میشود.",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--confirm-production", action="store_true",
|
||||||
|
help="تأیید صریح اپراتور برای نوشتن واقعی؛ همراه --apply الزامی است.",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--resume", type=int, default=None,
|
||||||
|
help="ادامهٔ یک job مشخص با شناسه (pk) آن.",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--new-job", action="store_true",
|
||||||
|
help="شروع عمدی یک job جدید (حتی اگر job ناتمام یا تکمیلشدهای وجود داشته باشد).",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--samples", type=int, default=5,
|
||||||
|
help="تعداد نمونهٔ «قبل/بعد» برای نمایش در گزارش (پیشفرض ۵).",
|
||||||
|
)
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------ main
|
||||||
|
def handle(self, *args, **options):
|
||||||
|
alias = DEFAULT_DB_ALIAS
|
||||||
|
batch_size = max(1, options["batch_size"])
|
||||||
|
samples_wanted = max(0, options["samples"])
|
||||||
|
apply_changes = options["apply"]
|
||||||
|
|
||||||
|
json_path = self._resolve_json_path(options["json_path"])
|
||||||
|
self._announce_target(alias)
|
||||||
|
|
||||||
|
try:
|
||||||
|
name_index = load_name_index(json_path)
|
||||||
|
except DatasetError as exc:
|
||||||
|
raise CommandError(f"فایل داده رد شد و هیچ تغییری اعمال نشد: {exc}")
|
||||||
|
self._print_dataset_report(name_index)
|
||||||
|
|
||||||
|
if options["dry_run"] and apply_changes:
|
||||||
|
raise CommandError("--dry-run و --apply با هم ناسازگارند.")
|
||||||
|
|
||||||
|
if not apply_changes:
|
||||||
|
counters, samples = self._run_readonly(
|
||||||
|
name_index, alias, batch_size, samples_wanted)
|
||||||
|
self._print_summary(counters, samples, dry_run=True, job=None)
|
||||||
|
return
|
||||||
|
|
||||||
|
if not options["confirm_production"]:
|
||||||
|
raise CommandError(
|
||||||
|
"برای نوشتن واقعی، هم --apply و هم --confirm-production لازم است. "
|
||||||
|
"بدون --apply اجرا حالت dry-run است."
|
||||||
|
)
|
||||||
|
self._ensure_job_table(alias)
|
||||||
|
|
||||||
|
job, resumed = self._claim_job(
|
||||||
|
alias, name_index, batch_size, options["resume"], options["new_job"])
|
||||||
|
counters = self._counters_from(job.stats)
|
||||||
|
note = "ادامهٔ job" if resumed else "job جدید"
|
||||||
|
self.stdout.write(self.style.MIGRATE_HEADING(
|
||||||
|
f"job#{job.pk} {note}؛ از pk>{job.last_processed_pk} تا pk<={job.initial_max_pk} "
|
||||||
|
f"(batch={batch_size})"
|
||||||
|
))
|
||||||
|
|
||||||
|
try:
|
||||||
|
counters, samples = self._apply(
|
||||||
|
name_index, alias, batch_size, job, counters, samples_wanted)
|
||||||
|
except KeyboardInterrupt:
|
||||||
|
self._finalize(alias, job, counters, CustomerNameFixJob.Status.PAUSED,
|
||||||
|
note="توقف با Ctrl+C؛ قابل ادامه با --resume")
|
||||||
|
self.stdout.write(self.style.WARNING(
|
||||||
|
f"job#{job.pk} متوقف شد؛ برای ادامه: --apply --confirm-production --resume {job.pk}"
|
||||||
|
))
|
||||||
|
return
|
||||||
|
except Exception as exc: # noqa: BLE001 - هر خطای غیرمنتظره: توقف امن
|
||||||
|
self._finalize(alias, job, counters, CustomerNameFixJob.Status.FAILED,
|
||||||
|
note=f"خطا: {type(exc).__name__}")
|
||||||
|
raise CommandError(
|
||||||
|
f"اجرای job#{job.pk} با خطا متوقف شد و batch جاری rollback شد: {exc}"
|
||||||
|
)
|
||||||
|
|
||||||
|
self._finalize(alias, job, counters, CustomerNameFixJob.Status.COMPLETED,
|
||||||
|
note="تکمیل شد")
|
||||||
|
self._print_summary(counters, samples, dry_run=False, job=job)
|
||||||
|
|
||||||
|
# -------------------------------------------------------------- helpers
|
||||||
|
def _resolve_json_path(self, raw_path):
|
||||||
|
if not raw_path:
|
||||||
|
raise CommandError("مسیر فایل داده مشخص نشده است.")
|
||||||
|
path = Path(raw_path).expanduser()
|
||||||
|
if not path.is_file():
|
||||||
|
raise CommandError(f"فایل داده پیدا نشد: {path}")
|
||||||
|
return path
|
||||||
|
|
||||||
|
def _announce_target(self, alias):
|
||||||
|
"""مقصد دیتابیس را بدون افشای credential نمایش میدهد."""
|
||||||
|
conf = settings.DATABASES.get(alias, {})
|
||||||
|
engine = conf.get("ENGINE", "?")
|
||||||
|
name = str(conf.get("NAME", ""))
|
||||||
|
settings_module = getattr(settings, "SETTINGS_MODULE", "")
|
||||||
|
self.stdout.write(f"مقصد دیتابیس: alias={alias} engine={engine}")
|
||||||
|
self.stdout.write(f" NAME: {name}")
|
||||||
|
if "production_settings" in settings_module:
|
||||||
|
self.stdout.write(self.style.WARNING(
|
||||||
|
" توجه: ماژول تنظیمات production فعال است."))
|
||||||
|
self.stdout.write(" (فقط نام/مسیر دیتابیس چاپ میشود؛ credential چاپ نمیشود.)")
|
||||||
|
|
||||||
|
def _print_dataset_report(self, name_index):
|
||||||
|
rep = name_index.report
|
||||||
|
self.stdout.write(self.style.MIGRATE_HEADING("اعتبارسنجی فایل داده"))
|
||||||
|
self.stdout.write(f" مسیر: {name_index.path}")
|
||||||
|
self.stdout.write(f" SHA-256: {name_index.sha256}")
|
||||||
|
self.stdout.write(
|
||||||
|
f" رکورد کل: {rep['records_total']} | کلید قابلاستفاده: {rep['usable_keys']} | "
|
||||||
|
f"کلید غیرقابلاستفاده: {rep['unusable_keys']}"
|
||||||
|
)
|
||||||
|
self.stdout.write(
|
||||||
|
f" ردشده → malformed: {rep['rejected_malformed']} | "
|
||||||
|
f"نام خالی: {rep['rejected_empty_name']} | "
|
||||||
|
f"جنسیت نامعتبر: {rep['rejected_unknown_gender']}"
|
||||||
|
)
|
||||||
|
self.stdout.write(
|
||||||
|
f" نام تکراری: {rep['duplicate_keys']} "
|
||||||
|
f"(همجنسیت: {rep['duplicate_same_gender']} | متناقض: {rep['duplicate_conflict']})"
|
||||||
|
)
|
||||||
|
|
||||||
|
def _ensure_job_table(self, alias):
|
||||||
|
table = CustomerNameFixJob._meta.db_table
|
||||||
|
tables = connections[alias].introspection.table_names()
|
||||||
|
if table not in tables:
|
||||||
|
raise CommandError(
|
||||||
|
f"جدول «{table}» وجود ندارد. ابتدا migration را اعمال کن: "
|
||||||
|
f"python manage.py migrate"
|
||||||
|
)
|
||||||
|
|
||||||
|
def _empty_counters(self):
|
||||||
|
return {key: 0 for key in STATS_KEYS}
|
||||||
|
|
||||||
|
def _counters_from(self, stats):
|
||||||
|
counters = self._empty_counters()
|
||||||
|
if isinstance(stats, dict):
|
||||||
|
for key in STATS_KEYS:
|
||||||
|
value = stats.get(key)
|
||||||
|
if isinstance(value, int):
|
||||||
|
counters[key] = value
|
||||||
|
return counters
|
||||||
|
|
||||||
|
def _tally(self, counters, decision):
|
||||||
|
counters["scanned"] += 1
|
||||||
|
counters[REASON_TO_STAT[decision.reason]] += 1
|
||||||
|
|
||||||
|
def _finish_counters(self, counters):
|
||||||
|
counters["unchanged"] = counters["scanned"] - counters["changed"]
|
||||||
|
return counters
|
||||||
|
|
||||||
|
def _collect_sample(self, samples, raw_name, new_name, wanted):
|
||||||
|
if wanted and len(samples) < wanted:
|
||||||
|
samples.append((raw_name, new_name))
|
||||||
|
|
||||||
|
# ------------------------------------------------------------ execution
|
||||||
|
def _run_readonly(self, name_index, alias, batch_size, samples_wanted):
|
||||||
|
"""اسکن فقط-خواندنی همهٔ مشتریان؛ هیچ نوشتنی (نه نام، نه job) رخ نمیدهد."""
|
||||||
|
counters = self._empty_counters()
|
||||||
|
samples = []
|
||||||
|
qs = (Customer.objects.using(alias)
|
||||||
|
.only("id", "name_family").order_by("pk"))
|
||||||
|
in_batch = 0
|
||||||
|
for customer in qs.iterator(chunk_size=batch_size):
|
||||||
|
decision = decide_prefix(customer.name_family, name_index)
|
||||||
|
self._tally(counters, decision)
|
||||||
|
if decision.new_name is not None:
|
||||||
|
self._collect_sample(samples, customer.name_family,
|
||||||
|
decision.new_name, samples_wanted)
|
||||||
|
in_batch += 1
|
||||||
|
if in_batch >= batch_size:
|
||||||
|
counters["batches"] += 1
|
||||||
|
in_batch = 0
|
||||||
|
if in_batch:
|
||||||
|
counters["batches"] += 1
|
||||||
|
return self._finish_counters(counters), samples
|
||||||
|
|
||||||
|
def _apply(self, name_index, alias, batch_size, job, totals, samples_wanted):
|
||||||
|
"""پردازش batchبهbatch با checkpoint پایدار بر پایهٔ primary key."""
|
||||||
|
samples = []
|
||||||
|
max_pk = job.initial_max_pk or 0
|
||||||
|
last_pk = job.last_processed_pk or 0
|
||||||
|
|
||||||
|
while last_pk < max_pk:
|
||||||
|
rows = list(
|
||||||
|
Customer.objects.using(alias)
|
||||||
|
.filter(pk__gt=last_pk, pk__lte=max_pk)
|
||||||
|
.order_by("pk").only("id", "name_family")[:batch_size]
|
||||||
|
)
|
||||||
|
if not rows:
|
||||||
|
break
|
||||||
|
|
||||||
|
batch_counters = self._empty_counters()
|
||||||
|
changes = []
|
||||||
|
for customer in rows:
|
||||||
|
decision = decide_prefix(customer.name_family, name_index)
|
||||||
|
self._tally(batch_counters, decision)
|
||||||
|
if decision.new_name is not None:
|
||||||
|
# فقط فیلد نام تغییر میکند؛ سایر فیلدها دستنخورده میمانند.
|
||||||
|
changes.append(Customer(pk=customer.pk, name_family=decision.new_name))
|
||||||
|
self._collect_sample(samples, customer.name_family,
|
||||||
|
decision.new_name, samples_wanted)
|
||||||
|
|
||||||
|
new_last_pk = rows[-1].pk
|
||||||
|
batch_counters["batches"] = 1
|
||||||
|
self._commit_batch_with_retry(
|
||||||
|
alias, changes, new_last_pk, batch_counters, job, totals)
|
||||||
|
last_pk = new_last_pk
|
||||||
|
|
||||||
|
return self._finish_counters(totals), samples
|
||||||
|
|
||||||
|
def _commit_batch(self, alias, changes, new_last_pk, batch_counters, job, totals):
|
||||||
|
"""تغییرات نام + checkpoint + heartbeat را در یک تراکنش اتمیک ثبت میکند."""
|
||||||
|
merged = dict(totals)
|
||||||
|
for key in STATS_KEYS:
|
||||||
|
merged[key] = merged[key] + batch_counters[key]
|
||||||
|
merged = self._finish_counters(merged)
|
||||||
|
now = timezone.now()
|
||||||
|
with transaction.atomic(using=alias):
|
||||||
|
if changes:
|
||||||
|
Customer.objects.using(alias).bulk_update(changes, ["name_family"])
|
||||||
|
CustomerNameFixJob.objects.using(alias).filter(pk=job.pk).update(
|
||||||
|
last_processed_pk=new_last_pk,
|
||||||
|
stats=merged,
|
||||||
|
heartbeat_at=now,
|
||||||
|
updated_at=now,
|
||||||
|
status=CustomerNameFixJob.Status.RUNNING,
|
||||||
|
)
|
||||||
|
# فقط پس از commit موفق، آمار در حافظه بهروز میشود (retry آمار را دوباره اضافه نمیکند).
|
||||||
|
totals.update(merged)
|
||||||
|
|
||||||
|
def _commit_batch_with_retry(self, alias, changes, new_last_pk,
|
||||||
|
batch_counters, job, totals):
|
||||||
|
for attempt in range(MAX_DB_RETRIES):
|
||||||
|
try:
|
||||||
|
self._commit_batch(alias, changes, new_last_pk,
|
||||||
|
batch_counters, job, totals)
|
||||||
|
return
|
||||||
|
except OperationalError as exc:
|
||||||
|
locked = "locked" in str(exc).lower()
|
||||||
|
if not locked or attempt == MAX_DB_RETRIES - 1:
|
||||||
|
raise
|
||||||
|
delay = BACKOFF_BASE_SECONDS * (2 ** attempt)
|
||||||
|
delay += random.uniform(0, delay / 2) # jitter
|
||||||
|
self.stderr.write(
|
||||||
|
f"دیتابیس قفل شد؛ تلاش {attempt + 2}/{MAX_DB_RETRIES} پس از {delay:.2f}s…"
|
||||||
|
)
|
||||||
|
time.sleep(delay)
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------------------------ job / lock
|
||||||
|
def _claim_job(self, alias, name_index, batch_size, resume_id, force_new):
|
||||||
|
"""قفل را میگیرد و job مناسب (جدید یا قابلادامه) را RETURN میکند.
|
||||||
|
|
||||||
|
خروجی: ``(job, resumed)``. در صورت اجرای همزمان یا تناقض hash،
|
||||||
|
``CommandError`` پرتاب میشود و هیچ تغییری اعمال نمیگردد.
|
||||||
|
"""
|
||||||
|
Job = CustomerNameFixJob
|
||||||
|
now = timezone.now()
|
||||||
|
digest = name_index.sha256
|
||||||
|
|
||||||
|
active = (Job.objects.using(alias)
|
||||||
|
.filter(status=Job.Status.RUNNING).order_by("-pk").first())
|
||||||
|
if active is not None:
|
||||||
|
fresh = (active.heartbeat_at is not None
|
||||||
|
and (now - active.heartbeat_at) < LOCK_TTL)
|
||||||
|
if fresh and active.pk != resume_id:
|
||||||
|
raise CommandError(
|
||||||
|
f"job#{active.pk} همین حالا در حال اجراست (heartbeat تازه). "
|
||||||
|
"اجرای همزمان مجاز نیست."
|
||||||
|
)
|
||||||
|
if not fresh:
|
||||||
|
# قفل کهنه (crash/قطع برق): آزادسازی کنترلشده
|
||||||
|
Job.objects.using(alias).filter(pk=active.pk).update(
|
||||||
|
status=Job.Status.FAILED, finished_at=now, updated_at=now,
|
||||||
|
note="قفل کهنه؛ آزادسازی خودکار پس از crash")
|
||||||
|
self.stderr.write(
|
||||||
|
f"job#{active.pk} ناتمام رها شده بود و اکنون بهعنوان failed آزاد شد.")
|
||||||
|
|
||||||
|
if resume_id is not None:
|
||||||
|
job = Job.objects.using(alias).filter(pk=resume_id).first()
|
||||||
|
if job is None:
|
||||||
|
raise CommandError(f"job#{resume_id} پیدا نشد.")
|
||||||
|
self._check_hash(job, digest)
|
||||||
|
if job.status == Job.Status.COMPLETED:
|
||||||
|
raise CommandError(
|
||||||
|
f"job#{job.pk} قبلاً تکمیل شده است؛ برای اجرای دوباره --new-job بده.")
|
||||||
|
resumed = True
|
||||||
|
elif force_new:
|
||||||
|
job = self._create_job(alias, digest, batch_size, now)
|
||||||
|
resumed = False
|
||||||
|
else:
|
||||||
|
job = (Job.objects.using(alias)
|
||||||
|
.filter(source_hash=digest, algorithm_version=ALGORITHM_VERSION)
|
||||||
|
.exclude(status=Job.Status.COMPLETED).order_by("-pk").first())
|
||||||
|
if job is not None:
|
||||||
|
resumed = True
|
||||||
|
else:
|
||||||
|
done = Job.objects.using(alias).filter(
|
||||||
|
source_hash=digest, algorithm_version=ALGORITHM_VERSION,
|
||||||
|
status=Job.Status.COMPLETED).exists()
|
||||||
|
if done:
|
||||||
|
raise CommandError(
|
||||||
|
"روی همین فایل یک job تکمیلشده وجود دارد؛ برای اجرای دوبارهٔ "
|
||||||
|
"کنترلشده --new-job بده."
|
||||||
|
)
|
||||||
|
job = self._create_job(alias, digest, batch_size, now)
|
||||||
|
resumed = False
|
||||||
|
|
||||||
|
Job.objects.using(alias).filter(pk=job.pk).update(
|
||||||
|
status=Job.Status.RUNNING, heartbeat_at=now, updated_at=now)
|
||||||
|
job.refresh_from_db(using=alias)
|
||||||
|
return job, resumed
|
||||||
|
|
||||||
|
def _check_hash(self, job, digest):
|
||||||
|
if job.source_hash != digest:
|
||||||
|
raise CommandError(
|
||||||
|
f"hash فایل ورودی با job#{job.pk} نمیخواند؛ ادامه رد شد و هیچ تغییری "
|
||||||
|
"اعمال نمیشود. برای فایل جدید یک job مستقل اجرا کن."
|
||||||
|
)
|
||||||
|
if job.algorithm_version != ALGORITHM_VERSION:
|
||||||
|
raise CommandError(
|
||||||
|
f"نسخهٔ الگوریتم job#{job.pk} ({job.algorithm_version}) با نسخهٔ فعلی "
|
||||||
|
f"({ALGORITHM_VERSION}) نمیخواند؛ ادامه رد شد."
|
||||||
|
)
|
||||||
|
|
||||||
|
def _create_job(self, alias, digest, batch_size, now):
|
||||||
|
Job = CustomerNameFixJob
|
||||||
|
initial_max_pk = (Customer.objects.using(alias)
|
||||||
|
.aggregate(m=Max("id"))["m"]) or 0
|
||||||
|
return Job.objects.using(alias).create(
|
||||||
|
source_hash=digest,
|
||||||
|
algorithm_version=ALGORITHM_VERSION,
|
||||||
|
status=Job.Status.RUNNING,
|
||||||
|
batch_size=batch_size,
|
||||||
|
initial_max_pk=initial_max_pk,
|
||||||
|
last_processed_pk=0,
|
||||||
|
stats=self._finish_counters(self._empty_counters()),
|
||||||
|
heartbeat_at=now,
|
||||||
|
note="",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _finalize(self, alias, job, counters, status, note):
|
||||||
|
"""وضعیت نهایی/میانی job را با روش اتمیک ثبت میکند."""
|
||||||
|
job.refresh_from_db(using=alias)
|
||||||
|
now = timezone.now()
|
||||||
|
persisted = self._finish_counters(dict(counters))
|
||||||
|
finished = status in (CustomerNameFixJob.Status.COMPLETED,
|
||||||
|
CustomerNameFixJob.Status.FAILED)
|
||||||
|
finished_at = now if finished else job.finished_at
|
||||||
|
with transaction.atomic(using=alias):
|
||||||
|
CustomerNameFixJob.objects.using(alias).filter(pk=job.pk).update(
|
||||||
|
status=status, stats=persisted, updated_at=now, heartbeat_at=now,
|
||||||
|
finished_at=finished_at, note=note[:255])
|
||||||
|
job.status = status
|
||||||
|
job.stats = persisted
|
||||||
|
job.finished_at = finished_at
|
||||||
|
|
||||||
|
# -------------------------------------------------------------- summary
|
||||||
|
def _print_summary(self, counters, samples, dry_run, job):
|
||||||
|
if dry_run:
|
||||||
|
head = "گزارش dry-run — هیچ تغییری نوشته نشد"
|
||||||
|
else:
|
||||||
|
head = f"گزارش اجرای واقعی job#{job.pk} ({job.status})"
|
||||||
|
self.stdout.write(self.style.MIGRATE_HEADING(head))
|
||||||
|
self.stdout.write(f" مشتریان بررسیشده: {counters['scanned']}")
|
||||||
|
self.stdout.write(f" قابل اصلاح / اصلاحشده: {counters['changed']}")
|
||||||
|
self.stdout.write(f" بدون تغییر: {counters['unchanged']}")
|
||||||
|
self.stdout.write(
|
||||||
|
f" تککلمهای: {counters['single_word']} | "
|
||||||
|
f"دارای پیشوند: {counters['has_prefix']} | "
|
||||||
|
f"خالی/نامعتبر: {counters['empty_invalid']}"
|
||||||
|
)
|
||||||
|
self.stdout.write(
|
||||||
|
f" ناشناخته در JSON: {counters['unknown_name']} | "
|
||||||
|
f"جنسیت مبهم/نامعتبر: {counters['ambiguous_gender']}"
|
||||||
|
)
|
||||||
|
self.stdout.write(f" batchهای تکمیلشده: {counters['batches']}")
|
||||||
|
if job is not None:
|
||||||
|
self.stdout.write(
|
||||||
|
f" شناسهٔ job: {job.pk} | شروع: {job.started_at} | پایان: {job.finished_at}"
|
||||||
|
)
|
||||||
|
if samples:
|
||||||
|
self.stdout.write(" نمونهٔ تغییرات (قبل → بعد):")
|
||||||
|
for before, after in samples:
|
||||||
|
self.stdout.write(f" «{before}» → «{after}»")
|
||||||
|
if dry_run:
|
||||||
|
self.stdout.write(self.style.WARNING(
|
||||||
|
"برای اجرای واقعی: --apply --confirm-production"))
|
||||||
|
|
||||||
@@ -0,0 +1,367 @@
|
|||||||
|
"""استخراج و تجمیع شمارههای موبایل هر مشتری در ``Customer.sms_primary_phones``.
|
||||||
|
|
||||||
|
منابع بررسی (به همین ترتیب اولویت برای حذف تکراریها):
|
||||||
|
``phone1`` → ``phone2`` → ``phone3`` → رکوردهای ``PhoneNumber`` بر اساس ``id``.
|
||||||
|
|
||||||
|
**امنیت داده:** فیلدهای مبدأ، سایر مشخصات مشتری و رکوردهای ``PhoneNumber`` هرگز
|
||||||
|
ویرایش/حذف نمیشوند. تنها ستونی که نوشته میشود ``sms_primary_phones`` است.
|
||||||
|
|
||||||
|
**قالب ذخیرهشده:** ``+989xxxxxxxxx`` — همان قالب متعارف پروژه که
|
||||||
|
``admin_panel.sms.phones.stored_primary_normalized`` انتظار دارد و تمام تستهای
|
||||||
|
موجود (``tests/test_sms.py``، ``tests/test_customers.py``) روی آن قفل شدهاند.
|
||||||
|
اعتبارسنجی ورودی با استاندارد ۱۱ رقمی ``09xxxxxxxxx`` انجام میشود، ولی مقدارِ
|
||||||
|
ذخیرهشده همان ``+98…`` است؛ در غیر اینصورت تطبیق «شمارهٔ اصلی» در پنل و
|
||||||
|
``resolved_recipients`` هیچوقت نمیگیرد و داده بیاثر میشود.
|
||||||
|
|
||||||
|
**تضمینها:**
|
||||||
|
|
||||||
|
* پیشفرض dry-run است؛ نوشتن فقط با ``--apply``.
|
||||||
|
* هر batch در ``transaction.atomic`` اختصاصی؛ خطا ⇒ فقط همان batch برمیگردد.
|
||||||
|
* Keyset pagination (``id__gt``) + checkpoint فایل پس از commit موفق هر batch.
|
||||||
|
* رکوردِ بدون تغییر نوشته نمیشود؛ مقدار موجودِ غیرخالی هرگز با «خالی» پاک نمیشود.
|
||||||
|
* سرریز طول (> ``max_length``) ⇒ رکورد دستنخورده + ثبت در گزارش.
|
||||||
|
|
||||||
|
اجرا:
|
||||||
|
|
||||||
|
python manage.py normalize_customer_sms_phones --dry-run
|
||||||
|
python manage.py normalize_customer_sms_phones --apply
|
||||||
|
python manage.py normalize_customer_sms_phones --apply --resume
|
||||||
|
python manage.py normalize_customer_sms_phones --apply --start-id 5000
|
||||||
|
python manage.py normalize_customer_sms_phones --apply --reset-checkpoint
|
||||||
|
"""
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from django.conf import settings
|
||||||
|
from django.core.management.base import BaseCommand, CommandError
|
||||||
|
from django.db import transaction
|
||||||
|
from persiantools import digits
|
||||||
|
|
||||||
|
from admin_panel.models import Customer
|
||||||
|
from admin_panel.sms.phones import normalize_primary_values
|
||||||
|
from integrations.sms.normalize import normalize_mobile
|
||||||
|
|
||||||
|
DEFAULT_BATCH_SIZE = 100
|
||||||
|
DEFAULT_CHECKPOINT_NAME = ".sms_normalization_checkpoint.json"
|
||||||
|
|
||||||
|
# مرز طول از خود مدل خوانده میشود تا با تغییر مدل همراستا بماند.
|
||||||
|
FIELD_MAX_LENGTH = Customer._meta.get_field("sms_primary_phones").max_length
|
||||||
|
LEGACY_FIELDS = ("phone1", "phone2", "phone3")
|
||||||
|
|
||||||
|
# حداکثر تعداد شناسهٔ سرریز که در گزارش چاپ میشود (بقیه فقط شمرده میشوند).
|
||||||
|
OVERFLOW_SAMPLE = 10
|
||||||
|
|
||||||
|
# ارقام «پیششمارهٔ ثابت»: 021، 031، 051، 071، … (برای گزارش، نه تصمیم داده).
|
||||||
|
LANDLINE_SECOND_DIGITS = set("12345678")
|
||||||
|
|
||||||
|
|
||||||
|
def digit_string(raw):
|
||||||
|
"""فقط ارقامِ یک مقدار ورودی، با تبدیل ارقام فارسی/عربی به انگلیسی."""
|
||||||
|
if raw is None:
|
||||||
|
return ""
|
||||||
|
text = digits.fa_to_en(digits.ar_to_fa(str(raw).strip()))
|
||||||
|
return "".join(ch for ch in text if ch.isdigit())
|
||||||
|
|
||||||
|
|
||||||
|
def is_landline(raw):
|
||||||
|
"""آیا مقدار ورودی شکل شمارهٔ ثابت دارد؟ (فقط برای تفکیک در گزارش)
|
||||||
|
|
||||||
|
شامل شمارهٔ ثابت محلی ۸رقمی (مثل ``57387608``) و شمارهٔ ثابت با پیششماره
|
||||||
|
(مثل ``02112345678`` / ``03133445566``) میشود — همان قاعدهٔ مستندشدهٔ پروژه
|
||||||
|
در ``fix_customer_phones.md``.
|
||||||
|
"""
|
||||||
|
text = digit_string(raw)
|
||||||
|
if len(text) == 8:
|
||||||
|
return True
|
||||||
|
if len(text) not in (10, 11) or not text.startswith("0"):
|
||||||
|
return False
|
||||||
|
return text[1] in LANDLINE_SECOND_DIGITS
|
||||||
|
|
||||||
|
|
||||||
|
class Command(BaseCommand):
|
||||||
|
help = (
|
||||||
|
"استخراج و تجمیع شمارههای موبایل معتبر هر مشتری در sms_primary_phones "
|
||||||
|
"(پیشفرض dry-run؛ اتمیکِ batch به batch با checkpoint قابل ادامه)."
|
||||||
|
)
|
||||||
|
|
||||||
|
def add_arguments(self, parser):
|
||||||
|
parser.add_argument(
|
||||||
|
"--batch-size", type=int, default=DEFAULT_BATCH_SIZE,
|
||||||
|
help=f"تعداد مشتریان در هر batch (پیشفرض {DEFAULT_BATCH_SIZE}).",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--dry-run", action="store_true",
|
||||||
|
help="صریحاً حالت شبیهسازی (پیشفرض همین است؛ همراه --apply مجاز نیست).",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--apply", action="store_true",
|
||||||
|
help="نوشتن واقعی در دیتابیس. بدون این گزینه فقط شبیهسازی میشود.",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--resume", action="store_true",
|
||||||
|
help="ادامه از آخرین شناسهٔ ثبتشده در فایل وضعیت.",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--start-id", type=int, default=None,
|
||||||
|
help="شروع دستی از یک شناسه (مشتریان با id بزرگتر پردازش میشوند). "
|
||||||
|
"بر --resume اولویت دارد.",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--reset-checkpoint", action="store_true",
|
||||||
|
help="پاککردن فایل وضعیت و شروع از ابتدا (در dry-run نادیده گرفته میشود).",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--checkpoint", default=None,
|
||||||
|
help=f"مسیر فایل وضعیت (پیشفرض: {DEFAULT_CHECKPOINT_NAME} در ریشهٔ پروژه).",
|
||||||
|
)
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------ main
|
||||||
|
def handle(self, *args, **options):
|
||||||
|
batch_size = max(1, options["batch_size"])
|
||||||
|
apply_changes = options["apply"]
|
||||||
|
checkpoint_path = Path(
|
||||||
|
options["checkpoint"] or (Path(settings.BASE_DIR) / DEFAULT_CHECKPOINT_NAME)
|
||||||
|
)
|
||||||
|
|
||||||
|
self.stdout.write(
|
||||||
|
f"حالت: {'اجرای واقعی (--apply)' if apply_changes else 'شبیهسازی (dry-run)'} | "
|
||||||
|
f"batch={batch_size} | فایل وضعیت: {checkpoint_path}"
|
||||||
|
)
|
||||||
|
if options["dry_run"] and apply_changes:
|
||||||
|
raise CommandError("--dry-run و --apply با هم ناسازگارند.")
|
||||||
|
self._handle_reset(checkpoint_path, apply_changes, options["reset_checkpoint"])
|
||||||
|
last_id, source = self._start_point(checkpoint_path, options)
|
||||||
|
self.stdout.write(f"شروع از مشتریان با id > {last_id} ({source})")
|
||||||
|
|
||||||
|
counters = {
|
||||||
|
"scanned": 0, "updated": 0, "unchanged": 0, "kept_nonempty": 0,
|
||||||
|
"filtered_landline": 0, "filtered_invalid": 0, "duplicates": 0,
|
||||||
|
"overflow": 0, "batches": 0,
|
||||||
|
}
|
||||||
|
overflow_ids = []
|
||||||
|
|
||||||
|
try:
|
||||||
|
last_id = self._run(
|
||||||
|
last_id, batch_size, apply_changes, checkpoint_path,
|
||||||
|
counters, overflow_ids)
|
||||||
|
except KeyboardInterrupt:
|
||||||
|
self.stdout.write(self.style.WARNING(
|
||||||
|
f"متوقف شد (Ctrl+C). آخرین شناسهٔ ثبتشده: {last_id}. "
|
||||||
|
"برای ادامه: --apply --resume"
|
||||||
|
))
|
||||||
|
return
|
||||||
|
except Exception as exc: # noqa: BLE001 - توقف امن با گزارش روشن
|
||||||
|
raise CommandError(
|
||||||
|
f"اجرا متوقف شد؛ batch جاری rollback شد و آخرین شناسهٔ معتبر "
|
||||||
|
f"{last_id} است: {exc}"
|
||||||
|
)
|
||||||
|
|
||||||
|
self._print_report(counters, overflow_ids, apply_changes, last_id)
|
||||||
|
|
||||||
|
# ------------------------------------------------------------ start/lock
|
||||||
|
def _handle_reset(self, checkpoint_path, apply_changes, reset_requested):
|
||||||
|
if not reset_requested:
|
||||||
|
return
|
||||||
|
if not checkpoint_path.exists():
|
||||||
|
self.stdout.write("فایل وضعیت وجود نداشت؛ چیزی برای پاککردن نبود.")
|
||||||
|
return
|
||||||
|
if not apply_changes:
|
||||||
|
self.stdout.write("--reset-checkpoint در شبیهسازی نادیده گرفته شد (فایل وضعیت دستنخورده).")
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
checkpoint_path.unlink()
|
||||||
|
except OSError as exc:
|
||||||
|
raise CommandError(f"فایل وضعیت پاک نشد ({checkpoint_path}): {exc}")
|
||||||
|
self.stdout.write(f"فایل وضعیت پاک شد: {checkpoint_path}")
|
||||||
|
|
||||||
|
def _start_point(self, checkpoint_path, options):
|
||||||
|
"""(last_id, توضیح منبع) — اولویت: --start-id سپس --resume سپس ابتدا."""
|
||||||
|
if options["start_id"] is not None:
|
||||||
|
return max(0, options["start_id"]), "--start-id"
|
||||||
|
if options["resume"]:
|
||||||
|
if not checkpoint_path.exists():
|
||||||
|
raise CommandError(
|
||||||
|
f"--resume داده شد ولی فایل وضعیت وجود ندارد: {checkpoint_path}"
|
||||||
|
)
|
||||||
|
data = self._read_checkpoint(checkpoint_path)
|
||||||
|
value = data.get("last_id")
|
||||||
|
if not isinstance(value, int) or value < 0:
|
||||||
|
raise CommandError(
|
||||||
|
f"فایل وضعیت معتبر نیست (last_id={value!r}): {checkpoint_path}"
|
||||||
|
)
|
||||||
|
return value, "--resume"
|
||||||
|
return 0, "ابتدا"
|
||||||
|
|
||||||
|
def _read_checkpoint(self, path):
|
||||||
|
try:
|
||||||
|
data = json.loads(path.read_text(encoding="utf-8"))
|
||||||
|
except (OSError, ValueError) as exc:
|
||||||
|
raise CommandError(f"فایل وضعیت خوانده نشد ({path}): {exc}")
|
||||||
|
if not isinstance(data, dict):
|
||||||
|
raise CommandError(f"فایل وضعیت ساختار معتبر ندارد ({path}).")
|
||||||
|
return data
|
||||||
|
|
||||||
|
def _write_checkpoint(self, path, last_id, batch_size, counters):
|
||||||
|
"""نوشتن اتمیک فایل وضعیت (tmp + os.replace) با آخرین شناسهٔ موفق."""
|
||||||
|
payload = {
|
||||||
|
"last_id": last_id,
|
||||||
|
"batch_size": batch_size,
|
||||||
|
"applied": True,
|
||||||
|
"scanned": counters["scanned"],
|
||||||
|
"updated": counters["updated"],
|
||||||
|
"overflow": counters["overflow"],
|
||||||
|
"batches": counters["batches"],
|
||||||
|
}
|
||||||
|
tmp = path.with_name(path.name + ".tmp")
|
||||||
|
try:
|
||||||
|
tmp.write_text(
|
||||||
|
json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8")
|
||||||
|
os.replace(tmp, path)
|
||||||
|
except OSError as exc:
|
||||||
|
raise CommandError(f"فایل وضعیت نوشته نشد ({path}): {exc}")
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------- batch
|
||||||
|
def _run(self, last_id, batch_size, apply_changes, checkpoint_path,
|
||||||
|
counters, overflow_ids):
|
||||||
|
"""حلقهٔ اصلی: هر batch یک تراکنش اتمیک + checkpoint پس از commit موفق."""
|
||||||
|
max_id = (Customer.objects.order_by("-id")
|
||||||
|
.values_list("id", flat=True).first()) or 0
|
||||||
|
if last_id >= max_id:
|
||||||
|
self.stdout.write("مشتریای در دامنهٔ پردازش باقی نمانده است.")
|
||||||
|
return last_id
|
||||||
|
|
||||||
|
while last_id < max_id:
|
||||||
|
rows = list(
|
||||||
|
Customer.objects
|
||||||
|
.filter(id__gt=last_id)
|
||||||
|
.order_by("id")
|
||||||
|
.prefetch_related("phones") # جلوگیری از N+1
|
||||||
|
.only("id", "phone1", "phone2", "phone3", "sms_primary_phones")
|
||||||
|
[:batch_size]
|
||||||
|
)
|
||||||
|
if not rows:
|
||||||
|
break
|
||||||
|
|
||||||
|
pending = []
|
||||||
|
for customer in rows:
|
||||||
|
counters["scanned"] += 1
|
||||||
|
value = self._target_value(customer, counters)
|
||||||
|
current = (customer.sms_primary_phones or "").strip()
|
||||||
|
if value is None:
|
||||||
|
overflow_ids.append(customer.id) # سرریز: دستنخورده
|
||||||
|
continue
|
||||||
|
if not value:
|
||||||
|
# مقدار محاسبهشده خالی است: مقدار موجودِ مدیر پاک نمیشود.
|
||||||
|
if current:
|
||||||
|
counters["kept_nonempty"] += 1
|
||||||
|
continue
|
||||||
|
if value == current:
|
||||||
|
counters["unchanged"] += 1
|
||||||
|
continue
|
||||||
|
pending.append((customer, value))
|
||||||
|
|
||||||
|
if apply_changes and pending:
|
||||||
|
# یک تراکنش کوتاه برای همین batch: خطا ⇒ فقط همین batch برمیگردد.
|
||||||
|
with transaction.atomic():
|
||||||
|
Customer.objects.bulk_update(
|
||||||
|
[self._stage(customer, value) for customer, value in pending],
|
||||||
|
["sms_primary_phones"])
|
||||||
|
counters["updated"] += len(pending)
|
||||||
|
|
||||||
|
new_last_id = rows[-1].id
|
||||||
|
counters["batches"] += 1
|
||||||
|
if apply_changes:
|
||||||
|
self._write_checkpoint(
|
||||||
|
checkpoint_path, new_last_id, batch_size, counters)
|
||||||
|
last_id = new_last_id
|
||||||
|
self.stdout.write(
|
||||||
|
f" batch {counters['batches']}: last_id={last_id} | "
|
||||||
|
f"بررسیشده={counters['scanned']} | "
|
||||||
|
f"{'بهروزشده' if apply_changes else 'قابلبهروزرسانی'}={counters['updated']} | "
|
||||||
|
f"سرریز={counters['overflow']}"
|
||||||
|
)
|
||||||
|
return last_id
|
||||||
|
|
||||||
|
|
||||||
|
# -------------------------------------------------------------- helpers
|
||||||
|
def _stage(self, customer, value):
|
||||||
|
"""مقدار نهایی را روی نمونه میگذارد و همان را برمیگرداند (برای bulk_update)."""
|
||||||
|
customer.sms_primary_phones = value
|
||||||
|
return customer
|
||||||
|
|
||||||
|
def _target_value(self, customer, counters):
|
||||||
|
"""مقدار نهایی ``sms_primary_phones`` برای یک مشتری.
|
||||||
|
|
||||||
|
خروجی: رشتهٔ نهایی، ``""`` (شمارهٔ معتبری نیست) یا ``None`` (سرریز > max_length).
|
||||||
|
"""
|
||||||
|
raw_values = [getattr(customer, field, "") for field in LEGACY_FIELDS]
|
||||||
|
numbers = sorted(customer.phones.all(), key=lambda row: row.id)
|
||||||
|
raw_values.extend(row.phone_number for row in numbers)
|
||||||
|
|
||||||
|
self._count_filters(raw_values, counters)
|
||||||
|
value = normalize_primary_values(raw_values)
|
||||||
|
if not value:
|
||||||
|
return ""
|
||||||
|
if len(value) > FIELD_MAX_LENGTH:
|
||||||
|
counters["overflow"] += 1
|
||||||
|
return None
|
||||||
|
return value
|
||||||
|
|
||||||
|
def _count_filters(self, raw_values, counters):
|
||||||
|
"""شمارش شمارههای فیلترشده (ثابت/نامعتبر) و تکراریهای حذفشده."""
|
||||||
|
seen = set()
|
||||||
|
duplicates = 0
|
||||||
|
for raw in raw_values:
|
||||||
|
if raw is None or not str(raw).strip():
|
||||||
|
continue
|
||||||
|
number = normalize_mobile(raw)
|
||||||
|
if not number:
|
||||||
|
if is_landline(raw):
|
||||||
|
counters["filtered_landline"] += 1
|
||||||
|
else:
|
||||||
|
counters["filtered_invalid"] += 1
|
||||||
|
continue
|
||||||
|
if number in seen:
|
||||||
|
duplicates += 1
|
||||||
|
else:
|
||||||
|
seen.add(number)
|
||||||
|
counters["duplicates"] += duplicates
|
||||||
|
|
||||||
|
# -------------------------------------------------------------- report
|
||||||
|
def _print_report(self, counters, overflow_ids, apply_changes, last_id):
|
||||||
|
head = ("گزارش شبیهسازی (dry-run) — هیچ تغییری ذخیره نشد"
|
||||||
|
if not apply_changes else "گزارش اجرای واقعی")
|
||||||
|
self.stdout.write(self.style.MIGRATE_HEADING(head))
|
||||||
|
self.stdout.write(f" مشتریان بررسیشده: {counters['scanned']}")
|
||||||
|
self.stdout.write(
|
||||||
|
f" مشتریان {'بهروزشده' if apply_changes else 'قابلبهروزرسانی'}: "
|
||||||
|
f"{counters['updated']}"
|
||||||
|
)
|
||||||
|
self.stdout.write(f" بدون تغییر: {counters['unchanged']}")
|
||||||
|
self.stdout.write(
|
||||||
|
f" شمارههای فیلترشده: ثابت={counters['filtered_landline']} | "
|
||||||
|
f"نامعتبر={counters['filtered_invalid']} | "
|
||||||
|
f"جمع={counters['filtered_landline'] + counters['filtered_invalid']}"
|
||||||
|
)
|
||||||
|
self.stdout.write(f" شمارههای تکراری حذفشده: {counters['duplicates']}")
|
||||||
|
self.stdout.write(
|
||||||
|
f" سرریز طول (>{FIELD_MAX_LENGTH} کاراکتر، دستنخورده): {counters['overflow']}"
|
||||||
|
)
|
||||||
|
if overflow_ids:
|
||||||
|
sample = ", ".join(str(pk) for pk in overflow_ids[:OVERFLOW_SAMPLE])
|
||||||
|
more = len(overflow_ids) - OVERFLOW_SAMPLE
|
||||||
|
tail = f" و {more} مورد دیگر" if more > 0 else ""
|
||||||
|
self.stdout.write(f" شناسههای سرریز: {sample}{tail}")
|
||||||
|
if counters["kept_nonempty"]:
|
||||||
|
self.stdout.write(
|
||||||
|
f" مقدار موجودِ غیرخالی که محافظت شد (شمارهٔ معتبری پیدا نشد): "
|
||||||
|
f"{counters['kept_nonempty']}"
|
||||||
|
)
|
||||||
|
self.stdout.write(f" batchهای تکمیلشده: {counters['batches']}")
|
||||||
|
self.stdout.write(f" آخرین شناسهٔ پردازششده: {last_id}")
|
||||||
|
if not apply_changes:
|
||||||
|
self.stdout.write(self.style.WARNING(
|
||||||
|
"برای اجرای واقعی: --apply (و در صورت توقف قبلی: --apply --resume)"
|
||||||
|
))
|
||||||
|
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
# Generated by Django 3.2.25 on 2026-10-15 06:07
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('admin_panel', '0071_sms_settings_permission'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='CustomerNameFixJob',
|
||||||
|
fields=[
|
||||||
|
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
|
('source_hash', models.CharField(max_length=64)),
|
||||||
|
('algorithm_version', models.CharField(default='1', max_length=16)),
|
||||||
|
('status', models.CharField(choices=[('running', 'در حال اجرا'), ('paused', 'متوقف\u200cشده (قابل ادامه)'), ('completed', 'تکمیل\u200cشده'), ('failed', 'ناموفق')], default='paused', max_length=16)),
|
||||||
|
('batch_size', models.PositiveIntegerField(default=200)),
|
||||||
|
('initial_max_pk', models.BigIntegerField(blank=True, null=True)),
|
||||||
|
('last_processed_pk', models.BigIntegerField(default=0)),
|
||||||
|
('stats', models.JSONField(blank=True, default=dict)),
|
||||||
|
('note', models.CharField(blank=True, default='', max_length=255)),
|
||||||
|
('started_at', models.DateTimeField(auto_now_add=True)),
|
||||||
|
('updated_at', models.DateTimeField(auto_now=True)),
|
||||||
|
('finished_at', models.DateTimeField(blank=True, null=True)),
|
||||||
|
('heartbeat_at', models.DateTimeField(blank=True, null=True)),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'verbose_name': 'job اصلاح نام مشتری',
|
||||||
|
'verbose_name_plural': 'jobهای اصلاح نام مشتری',
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -1,6 +1,48 @@
|
|||||||
from django.db import models
|
from django.db import models
|
||||||
|
|
||||||
|
|
||||||
|
class CustomerNameFixJob(models.Model):
|
||||||
|
"""وضعیت و checkpoint عملیات «افزودن پیشوند آقای/خانم به نام مشتریان».
|
||||||
|
|
||||||
|
این مدل **فقط** برای مدیریت/بازیابی job است و هیچ اطلاعاتی دربارهٔ جنسیت یا
|
||||||
|
هویت مشتری نگه نمیدارد. مدل ``Customer`` هیچ تغییر ساختاری نمیگیرد.
|
||||||
|
"""
|
||||||
|
|
||||||
|
class Status(models.TextChoices):
|
||||||
|
RUNNING = "running", "در حال اجرا"
|
||||||
|
PAUSED = "paused", "متوقفشده (قابل ادامه)"
|
||||||
|
COMPLETED = "completed", "تکمیلشده"
|
||||||
|
FAILED = "failed", "ناموفق"
|
||||||
|
|
||||||
|
# شناسهٔ فایل ورودی: اگر hash تغییر کند، ادامهٔ job رد میشود.
|
||||||
|
source_hash = models.CharField(max_length=64)
|
||||||
|
algorithm_version = models.CharField(max_length=16, default="1")
|
||||||
|
status = models.CharField(max_length=16, choices=Status.choices, default=Status.PAUSED)
|
||||||
|
batch_size = models.PositiveIntegerField(default=200)
|
||||||
|
|
||||||
|
# دامنهٔ job: بیشترین primary key مشتریان در لحظهٔ آغاز. مشتریان جدیدتر
|
||||||
|
# (pk > initial_max_pk) عمداً خارج از دامنه میمانند.
|
||||||
|
initial_max_pk = models.BigIntegerField(null=True, blank=True)
|
||||||
|
# checkpoint: آخرین pk پردازششده (بهصورت اتمیک با تغییرات همان batch ثبت میشود).
|
||||||
|
last_processed_pk = models.BigIntegerField(default=0)
|
||||||
|
|
||||||
|
stats = models.JSONField(default=dict, blank=True)
|
||||||
|
note = models.CharField(max_length=255, blank=True, default="")
|
||||||
|
|
||||||
|
started_at = models.DateTimeField(auto_now_add=True)
|
||||||
|
updated_at = models.DateTimeField(auto_now=True)
|
||||||
|
finished_at = models.DateTimeField(null=True, blank=True)
|
||||||
|
# ضربان قفل: برای تشخیص job رهاشده پس از crash و جلوگیری از اجرای همزمان.
|
||||||
|
heartbeat_at = models.DateTimeField(null=True, blank=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
verbose_name = "job اصلاح نام مشتری"
|
||||||
|
verbose_name_plural = "jobهای اصلاح نام مشتری"
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return f"job#{self.pk} {self.status} hash={self.source_hash[:8]} pk<={self.last_processed_pk}"
|
||||||
|
|
||||||
|
|
||||||
class CacheTable(models.Model):
|
class CacheTable(models.Model):
|
||||||
key = models.CharField(max_length=50, unique=True)
|
key = models.CharField(max_length=50, unique=True)
|
||||||
value = models.CharField(max_length=400)
|
value = models.CharField(max_length=400)
|
||||||
|
|||||||
@@ -0,0 +1,205 @@
|
|||||||
|
"""منطق محضِ افزودن پیشوند «آقای/خانم» به نام مشتریان بر پایهٔ ``iranianNamesDataset.json``.
|
||||||
|
|
||||||
|
این ماژول هیچ چیزی در دیتابیس تغییر نمیدهد و به Django وابسته نیست؛ فقط:
|
||||||
|
|
||||||
|
* فایل JSON را **یک بار** میخواند و SHA-256 و index جستوجو میسازد.
|
||||||
|
* برای هر نام تصمیم میگیرد که آیا پیشوند اضافه شود یا نه (سیاست کاملاً محافظهکارانه).
|
||||||
|
|
||||||
|
قواعد تصمیم (به ترتیب):
|
||||||
|
|
||||||
|
1. نام تهی/فقط فاصله → دستنخورده.
|
||||||
|
2. اولین کلمه یکی از ``آقا``/``آقای``/``خانم`` → دستنخورده (پیشوند تکرار نمیشود).
|
||||||
|
3. نام تککلمهای → دستنخورده (ممکن است فقط نام خانوادگی باشد).
|
||||||
|
4. اولین کلمه در index نباشد → دستنخورده.
|
||||||
|
5. جنسیت اولین کلمه مبهم/نامعتبر باشد → دستنخورده.
|
||||||
|
6. در غیر اینصورت پیشوند قطعی افزوده میشود.
|
||||||
|
|
||||||
|
نرمالسازی صرفاً برای کلید جستوجو است (NFC + یکسانسازی ``ي/ی`` و ``ك/ک``).
|
||||||
|
از fuzzy matching، حذف حروف یا حدس جنسیت استفاده نمیشود.
|
||||||
|
"""
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
import unicodedata
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Dict, Optional
|
||||||
|
|
||||||
|
# نسخهٔ الگوریتم؛ با هر تغییر در قواعد تصمیم یا نرمالسازی باید افزایش یابد تا
|
||||||
|
# jobهای قبلی بهاشتباه ادامه داده نشوند.
|
||||||
|
ALGORITHM_VERSION = "1"
|
||||||
|
|
||||||
|
PREFIX_MALE = "آقای"
|
||||||
|
PREFIX_FEMALE = "خانم"
|
||||||
|
|
||||||
|
# پیشوندهایی که اگر ابتدای نام باشند، نام دستنخورده میماند.
|
||||||
|
KNOWN_PREFIXES = frozenset({"آقا", "آقای", "خانم"})
|
||||||
|
|
||||||
|
# تنها مقادیر معتبر Gender در فایل داده. هر مقدار دیگری «نامعتبر» شمرده میشود.
|
||||||
|
GENDER_TO_PREFIX = {"M": PREFIX_MALE, "F": PREFIX_FEMALE}
|
||||||
|
|
||||||
|
_ARABIC_YEH = "\u064a"
|
||||||
|
_PERSIAN_YEH = "\u06cc"
|
||||||
|
_ARABIC_KAF = "\u0643"
|
||||||
|
_PERSIAN_KAF = "\u06a9"
|
||||||
|
|
||||||
|
|
||||||
|
class DatasetError(ValueError):
|
||||||
|
"""ساختار فایل داده خراب است؛ هیچ تغییری مجاز نیست."""
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_name_key(value) -> str:
|
||||||
|
"""کلید جستوجو: NFC + حذف فاصلهٔ ابتدا/انتها + یکسانسازی ي/ی و ك/ک."""
|
||||||
|
if not isinstance(value, str):
|
||||||
|
return ""
|
||||||
|
text = unicodedata.normalize("NFC", value).strip()
|
||||||
|
return text.replace(_ARABIC_YEH, _PERSIAN_YEH).replace(_ARABIC_KAF, _PERSIAN_KAF)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class NameIndex:
|
||||||
|
"""نتیجهٔ خواندن فایل داده: index قابلاستفاده + گزارش اعتبارسنجی."""
|
||||||
|
|
||||||
|
path: str
|
||||||
|
sha256: str
|
||||||
|
index: Dict[str, str] = field(default_factory=dict) # کلید نرمالشده → 'M'/'F'
|
||||||
|
ambiguous: frozenset = frozenset() # کلیدهایی که قطعی نیستند
|
||||||
|
report: Dict[str, int] = field(default_factory=dict)
|
||||||
|
|
||||||
|
def lookup(self, key: str) -> Optional[str]:
|
||||||
|
"""جنسیت قطعیِ یک کلید نرمالشده، یا ``None`` اگر ناشناخته/مبهم باشد."""
|
||||||
|
return self.index.get(key)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Decision:
|
||||||
|
"""تصمیم برای یک نام. ``new_name`` تنها هنگام تصمیم قطعی پر میشود."""
|
||||||
|
|
||||||
|
new_name: Optional[str]
|
||||||
|
reason: str # male | female | single_word | has_prefix | empty | unknown_name | ambiguous_gender
|
||||||
|
|
||||||
|
|
||||||
|
def validate_dataset(doc) -> list:
|
||||||
|
"""اعتبارسنجی ساختار کلی JSON. آرایهٔ ``RECORDS`` را برمیگرداند.
|
||||||
|
|
||||||
|
در صورت خرابی ساختار (JSON غیر دیکشنری، نبودِ ``RECORDS`` یا لیست نبودن آن)
|
||||||
|
``DatasetError`` پرتاب میشود؛ در این حالت هیچ نام مشتری نباید تغییر کند.
|
||||||
|
"""
|
||||||
|
if not isinstance(doc, dict):
|
||||||
|
raise DatasetError("ریشهٔ فایل JSON یک شیء (object) نیست.")
|
||||||
|
if "RECORDS" not in doc:
|
||||||
|
raise DatasetError("کلید «RECORDS» در فایل JSON وجود ندارد.")
|
||||||
|
records = doc["RECORDS"]
|
||||||
|
if not isinstance(records, list):
|
||||||
|
raise DatasetError("مقدار «RECORDS» یک آرایه (list) نیست.")
|
||||||
|
return records
|
||||||
|
|
||||||
|
|
||||||
|
def build_name_index(records, *, path="", sha256="") -> NameIndex:
|
||||||
|
"""از آرایهٔ رکوردها، index محافظهکارانه و گزارش اعتبارسنجی میسازد.
|
||||||
|
|
||||||
|
سیاست: یک کلید نرمالشده تنها وقتی قابلاستفاده است که **همهٔ** رکوردهایش
|
||||||
|
جنسیت معتبر (``M``/``F``) و یکسان داشته باشند. هر رکورد نامعتبر، جنسیت
|
||||||
|
ناشناخته، یا تناقض ``M``/``F`` ⇒ کلید کاملاً کنار گذاشته میشود.
|
||||||
|
"""
|
||||||
|
groups: Dict[str, Dict[str, object]] = {}
|
||||||
|
report = {
|
||||||
|
"records_total": 0,
|
||||||
|
"rejected_malformed": 0, # رکورد غیردیکشنری یا Names/Gender غیررشتهای
|
||||||
|
"rejected_empty_name": 0, # نام خالی پس از strip
|
||||||
|
"rejected_unknown_gender": 0, # مقدار Gender خارج از M/F
|
||||||
|
"duplicate_keys": 0,
|
||||||
|
"duplicate_same_gender": 0,
|
||||||
|
"duplicate_conflict": 0,
|
||||||
|
"usable_keys": 0,
|
||||||
|
"unusable_keys": 0,
|
||||||
|
}
|
||||||
|
|
||||||
|
for record in records:
|
||||||
|
report["records_total"] += 1
|
||||||
|
if not isinstance(record, dict):
|
||||||
|
report["rejected_malformed"] += 1
|
||||||
|
continue
|
||||||
|
names = record.get("Names")
|
||||||
|
gender = record.get("Gender")
|
||||||
|
if not isinstance(names, str) or not isinstance(gender, str):
|
||||||
|
report["rejected_malformed"] += 1
|
||||||
|
continue
|
||||||
|
key = normalize_name_key(names)
|
||||||
|
if not key:
|
||||||
|
report["rejected_empty_name"] += 1
|
||||||
|
continue
|
||||||
|
|
||||||
|
entry = groups.setdefault(key, {"gender": None, "invalid": False, "count": 0})
|
||||||
|
entry["count"] += 1
|
||||||
|
if gender in GENDER_TO_PREFIX:
|
||||||
|
if entry["gender"] is not None and entry["gender"] != gender:
|
||||||
|
entry["invalid"] = True # تناقض M/F
|
||||||
|
else:
|
||||||
|
entry["gender"] = gender
|
||||||
|
else:
|
||||||
|
entry["invalid"] = True # جنسیت ناشناخته/نامعتبر
|
||||||
|
report["rejected_unknown_gender"] += 1
|
||||||
|
|
||||||
|
index: Dict[str, str] = {}
|
||||||
|
ambiguous = set()
|
||||||
|
for key, entry in groups.items():
|
||||||
|
if entry["count"] > 1:
|
||||||
|
report["duplicate_keys"] += 1
|
||||||
|
if entry["invalid"]:
|
||||||
|
report["duplicate_conflict"] += 1
|
||||||
|
else:
|
||||||
|
report["duplicate_same_gender"] += 1
|
||||||
|
if entry["invalid"] or entry["gender"] is None:
|
||||||
|
ambiguous.add(key)
|
||||||
|
else:
|
||||||
|
index[key] = entry["gender"]
|
||||||
|
|
||||||
|
report["usable_keys"] = len(index)
|
||||||
|
report["unusable_keys"] = len(ambiguous)
|
||||||
|
return NameIndex(path=path, sha256=sha256, index=index,
|
||||||
|
ambiguous=frozenset(ambiguous), report=report)
|
||||||
|
|
||||||
|
|
||||||
|
def load_name_index(path) -> NameIndex:
|
||||||
|
"""فایل را **یک بار** میخواند، hash و index میسازد.
|
||||||
|
|
||||||
|
برای محاسبهٔ SHA-256 فایل جداگانه دوباره خوانده نمیشود.
|
||||||
|
"""
|
||||||
|
resolved = Path(path)
|
||||||
|
raw = resolved.read_bytes() # تنها خواندن فایل
|
||||||
|
digest = hashlib.sha256(raw).hexdigest()
|
||||||
|
try:
|
||||||
|
doc = json.loads(raw.decode("utf-8"))
|
||||||
|
except UnicodeDecodeError as exc:
|
||||||
|
raise DatasetError(f"فایل JSON با UTF-8 خوانده نشد: {exc}") from exc
|
||||||
|
except json.JSONDecodeError as exc:
|
||||||
|
raise DatasetError(f"JSON نامعتبر است: {exc}") from exc
|
||||||
|
|
||||||
|
records = validate_dataset(doc)
|
||||||
|
return build_name_index(records, path=str(resolved), sha256=digest)
|
||||||
|
|
||||||
|
|
||||||
|
def decide_prefix(raw_name, name_index: NameIndex) -> Decision:
|
||||||
|
"""تصمیم برای یک مقدار ``name_family``. هیچگاه خودِ مقدار را تغییر نمیدهد."""
|
||||||
|
if not isinstance(raw_name, str):
|
||||||
|
return Decision(None, "empty")
|
||||||
|
|
||||||
|
stripped = raw_name.strip()
|
||||||
|
if not stripped:
|
||||||
|
return Decision(None, "empty")
|
||||||
|
|
||||||
|
tokens = stripped.split()
|
||||||
|
if tokens[0] in KNOWN_PREFIXES:
|
||||||
|
return Decision(None, "has_prefix")
|
||||||
|
if len(tokens) < 2:
|
||||||
|
return Decision(None, "single_word")
|
||||||
|
|
||||||
|
key = normalize_name_key(tokens[0])
|
||||||
|
gender = name_index.lookup(key)
|
||||||
|
if gender is None:
|
||||||
|
reason = "ambiguous_gender" if key in name_index.ambiguous else "unknown_name"
|
||||||
|
return Decision(None, reason)
|
||||||
|
|
||||||
|
prefix = GENDER_TO_PREFIX[gender]
|
||||||
|
return Decision(f"{prefix} {stripped}", "male" if gender == "M" else "female")
|
||||||
|
|
||||||
@@ -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شده |
|
||||||
|
|
||||||
|
|
||||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,611 @@
|
|||||||
|
# تجمیع شمارههای پیامک مشتریان
|
||||||
|
|
||||||
|
## چرا؟
|
||||||
|
|
||||||
|
شمارههای هر مشتری در این پروژه به چند جا پراکندهاند: فیلدهای قدیمی
|
||||||
|
`Customer.phone1` / `phone2` / `phone3` و رکوردهای جدول `PhoneNumber`. بعضی
|
||||||
|
بدون صفر ابتداییاند (`9133722815`)، بعضی ارقام فارسی/عربی دارند، بعضی با
|
||||||
|
فاصله/خطتیره نوشته شدهاند و بعضی اصلاً موبایل نیستند (شمارهٔ ثابت، «0»، متن
|
||||||
|
آزاد).
|
||||||
|
|
||||||
|
این دستور همهٔ آنها را میخواند، **موبایلهای معتبر** را استخراج میکند و به
|
||||||
|
شکل یک رشتهٔ یکتا و مرتب در `Customer.sms_primary_phones` میریزد.
|
||||||
|
|
||||||
|
> ⚠️ **قبل از `--apply` این بخش را بخوان:** «هشدار معنای داده» پایین.
|
||||||
|
> رفتار پیشفرض دستور **بدون نوشتن** است.
|
||||||
|
|
||||||
|
| جزء | مسیر |
|
||||||
|
|---|---|
|
||||||
|
| دستور | `admin_panel/management/commands/normalize_customer_sms_phones.py` |
|
||||||
|
| تستها | `tests/test_sms_phone_normalize.py` |
|
||||||
|
| فایل وضعیت (checkpoint) | `.sms_normalization_checkpoint.json` در ریشهٔ پروژه |
|
||||||
|
| مسیر مصرفکنندهٔ فیلد | `admin_panel/sms/phones.py` → `admin_panel/sms/recipients.py` |
|
||||||
|
| قالببندی شماره | `integrations/sms/normalize.py` |
|
||||||
|
|
||||||
|
**هیچ migration لازم نیست** — این دستور مدل را تغییر نمیدهد و فقط روی یک ستون
|
||||||
|
موجود مینویسد. هیچ پیشنیاز دیگری (جدول job، فایل داده، سرویس جانبی) ندارد.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## سریعترین مسیر روی Production (اگر همین یک دستور را میخواهی)
|
||||||
|
|
||||||
|
دستور خودبسنده است: فقط `Customer` را وارد میکند و به هیچ migration یا مدل
|
||||||
|
جدیدی وابسته نیست. پس روی VPS، **تنها همین سه خط** کافی است:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /path/to/khatibi
|
||||||
|
|
||||||
|
# ۱) شبیهسازی (۳۰ ثانیه، صفر نوشتن) — اعداد را ببین
|
||||||
|
docker compose exec web python manage.py normalize_customer_sms_phones --dry-run --batch-size 50
|
||||||
|
|
||||||
|
# ۲) بکاپ قبل از نوشتن
|
||||||
|
docker compose exec web python -c "
|
||||||
|
import sqlite3, pathlib, datetime
|
||||||
|
src = pathlib.Path('/app/sqlite/db.sqlite3')
|
||||||
|
dst = src.parent / f'db_backup_{datetime.datetime.now():%Y%m%d_%H%M%S}.sqlite3'
|
||||||
|
s = sqlite3.connect(f'file:{src}?mode=ro', uri=True, timeout=30); d = sqlite3.connect(dst)
|
||||||
|
s.backup(d, pages=250, sleep=0.05); d.close(); s.close(); print('backup:', dst)"
|
||||||
|
|
||||||
|
# ۳) اجرای واقعی
|
||||||
|
docker compose exec web python manage.py normalize_customer_sms_phones --apply --batch-size 50
|
||||||
|
```
|
||||||
|
|
||||||
|
سه نکته:
|
||||||
|
|
||||||
|
1. **روی VPS `python` میزبان وجود ندارد؛ باید داخل کانتینر بزنی**
|
||||||
|
(`docker compose exec web …`). `docker compose run --rm web …` هم کار میکند
|
||||||
|
ولی یک کانتینر اضافه بالا میآورد.
|
||||||
|
2. **migrate لازم نیست.** (`migrate` فقط پیشنیاز دستور `fix_customer_names`
|
||||||
|
است، چون آن یکی جدول `CustomerNameFixJob` میخواهد.)
|
||||||
|
3. **`sqlite_db_backup` کار نمیکند** مگر `S3_ACCESS_KEY`، `S3_SECRET_KEY`،
|
||||||
|
`S3_ENDPOINT` و `S3_BUCKET` در محیط تنظیم شده باشند — `production.env` این
|
||||||
|
پروژه فقط `DJANGO_SETTINGS_MODULE` دارد. پس همان اسنیپت `sqlite3.backup`
|
||||||
|
بالا (که WAL-safe است) امنترین راه بدون S3 است.
|
||||||
|
|
||||||
|
اگر وسط کار قطع شد:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose exec web python manage.py normalize_customer_sms_phones --apply --resume
|
||||||
|
```
|
||||||
|
|
||||||
|
> تنها شرط تصمیمگیری: بخش **«هشدار معنای داده»** پایین. اگر اثر
|
||||||
|
> افزایشِ گیرندگان پیامک را نمیخواهی، مرحلهٔ ۳ را نزن.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ⚠️ هشدار معنای داده (مهمترین نکتهٔ این سند)
|
||||||
|
|
||||||
|
`Customer.sms_primary_phones` امروز معنایش «گیرندگان پیامک» است:
|
||||||
|
`admin_panel/sms/phones.py::primary_phones()` اگر این فیلد خالی باشد، فقط
|
||||||
|
**شمارهٔ اول** مشتری را برمیگرداند؛ و اگر پر باشد، **همهٔ** شمارههای داخلش را
|
||||||
|
گیرندهٔ پیامک میداند.
|
||||||
|
|
||||||
|
وضعیت فعلی دیتابیس توسعه:
|
||||||
|
|
||||||
|
| سنجه | مقدار |
|
||||||
|
|---|---|
|
||||||
|
| کل مشتریان | 10,426 |
|
||||||
|
| مشتریانی که الان `sms_primary_phones` دارند | **6** |
|
||||||
|
| بیشترین طول فعلی مقدار | 27 کاراکتر |
|
||||||
|
|
||||||
|
پس اگر `--apply` بزنی، این ستون برای **~10,084 مشتری** پر میشود و از آن لحظه:
|
||||||
|
|
||||||
|
> **پیامکهای آیندهٔ هر مشتری به همهٔ شمارههای معتبرش میرود، نه فقط شمارهٔ اول.**
|
||||||
|
|
||||||
|
این یک خرابی نیست؛ اثر آگاهانهٔ همان کاری است که خواسته شده. ولی اگر
|
||||||
|
نمیخواهی تعداد ارسال/هزینه بالا برود، **قبل از `--apply` تصمیم بگیر**:
|
||||||
|
|
||||||
|
- اگر هدف فقط «یکجا کردن شمارهها برای بازبینی» است ⇒ همین دستور با `--dry-run`
|
||||||
|
کافی است و نیازی به `--apply` نیست.
|
||||||
|
- اگر میخواهی برای مشتریِ بدون انتخاب دستی فقط **شمارهٔ اول** ارسال شود، باید
|
||||||
|
منطق `primary_phones()` تغییر کند (کار جداگانهای است، خارج از این دستور).
|
||||||
|
|
||||||
|
نکتهٔ تسکیندهنده: بهمحض اینکه اپراتور همان مشتری را از پنل ویرایش کند،
|
||||||
|
`customerEdit` مقدار را از تیکهای فرم بازنویسی میکند؛ یعنی پنل همیشه منبع
|
||||||
|
حقیقت نهایی میماند و اجرای دوبارهٔ این دستور آن را خراب نمیکند.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## چه چیزی تغییر میکند و چه چیزی نه
|
||||||
|
|
||||||
|
**تنها ستونی که نوشته میشود `Customer.sms_primary_phones` است.**
|
||||||
|
|
||||||
|
| مورد | وضعیت |
|
||||||
|
|---|---|
|
||||||
|
| `phone1`, `phone2`, `phone3` | فقط خوانده میشوند — **هرگز ویرایش/پاک نمیشوند** |
|
||||||
|
| ردیفهای `PhoneNumber` | فقط خوانده میشوند — **هیچ رکوردی حذف/ویرایش نمیشود** |
|
||||||
|
| سایر فیلدهای مشتری (نام، آدرس، یادداشت، وضعیت) | دستنخورده |
|
||||||
|
| اسکیمای دیتابیس | دستنخورده (بدون migration) |
|
||||||
|
| مقدار موجودِ غیرخالی، وقتی شمارهٔ معتبری پیدا نشود | **محافظت میشود؛ خالی نمیشود** |
|
||||||
|
| مقدار خالی موجود وقتی شمارهٔ معتبر پیدا شود | فقط در این حالت پر میشود |
|
||||||
|
|
||||||
|
سایر فیلدها با یک تست اختصاصی
|
||||||
|
(`test_source_fields_and_phone_rows_are_untouched`) قفل شدهاند.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## قواعد اعتبارسنجی و قالب شماره
|
||||||
|
|
||||||
|
اعتبارسنجی با تابع مشترک پروژه انجام میشود
|
||||||
|
(`integrations/sms/normalize.py::normalize_mobile`) — هیچ منطق موازی و دومی
|
||||||
|
نوشته نشده تا با بقیهٔ سیستم یکسان بماند.
|
||||||
|
|
||||||
|
| ورودی | معتبر؟ | توضیح |
|
||||||
|
|---|---|---|
|
||||||
|
| `09123456789` | ✅ | قالب استاندارد ۱۱رقمی |
|
||||||
|
| `9123456789` | ✅ | بدون صفر ابتدایی |
|
||||||
|
| `+989123456789` | ✅ | پیششمارهٔ بینالمللی |
|
||||||
|
| `00989123456789` | ✅ | پیششمارهٔ `0098` |
|
||||||
|
| `989123456789` | ✅ | پیششمارهٔ `98` |
|
||||||
|
| `۰۹۱۲۳۴۵۶۷۸۹` | ✅ | ارقام فارسی |
|
||||||
|
| `٠٩١٢٣٤٥٦٧٨٩` | ✅ | ارقام عربی |
|
||||||
|
| `0912-345-6789`, `(0912) 345 6789` | ✅ | جداکنندهها پاک میشوند |
|
||||||
|
| `02112345678`, `031-33445566` | ❌ | شمارهٔ ثابت با پیششماره |
|
||||||
|
| `57387608` (۸رقمی) | ❌ | شمارهٔ ثابت محلی |
|
||||||
|
| `12345`, `0`, `اعظمی`, خالی | ❌ | ناقص / متن آزاد |
|
||||||
|
|
||||||
|
### قالبِ ذخیرهشده: `+989xxxxxxxxx`
|
||||||
|
|
||||||
|
اینجا یک نکتهٔ ظریف و مهم است:
|
||||||
|
|
||||||
|
- **اعتبارسنجی** بر پایهٔ استاندارد ۱۱رقمی `09xxxxxxxxx` انجام میشود (همان
|
||||||
|
چیزی که در صورت مسئله آمده)،
|
||||||
|
- ولی **مقدار ذخیرهشده** `+989xxxxxxxxx` است.
|
||||||
|
|
||||||
|
علت، کد موجود پروژه است: `admin_panel/sms/phones.py::stored_primary_normalized`
|
||||||
|
مقدار ذخیرهشده را با `normalize_mobile()` (یعنی `+989…`) تطبیق میدهد. اگر
|
||||||
|
`09…` ذخیره شود:
|
||||||
|
|
||||||
|
1. ستارهٔ «★ اصلی» در صفحهٔ مشتری برای هیچ شمارهای روشن نمیشود
|
||||||
|
(`is_primary` همیشه False)،
|
||||||
|
2. `resolved_recipients()` بیاثر میشود و به شمارهٔ اول برمیگردد
|
||||||
|
(یعنی کل تجمیع میشود دادهٔ مرده)،
|
||||||
|
3. تستهای موجود که روی `+989120009999` قفلاند میشکنند
|
||||||
|
(`tests/test_customers.py:143`، `tests/test_sms.py:1170`).
|
||||||
|
|
||||||
|
یک تست اختصاصی همین را قفل میکند:
|
||||||
|
`test_panel_resolution_reads_back_aggregated_numbers` بررسی میکند که خروجی
|
||||||
|
دستور با `resolve_recipients()` خوانده میشود.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## الگوریتم (برای هر مشتری)
|
||||||
|
|
||||||
|
**ترتیب اولویت منابع** (برای حذف تکراریها؛ اولینبار که یک شماره دیده شود،
|
||||||
|
جایگاهش همان میماند):
|
||||||
|
|
||||||
|
```
|
||||||
|
phone1 → phone2 → phone3 → رکوردهای PhoneNumber بر اساس id صعودی
|
||||||
|
```
|
||||||
|
|
||||||
|
**برای هر مقدار:**
|
||||||
|
|
||||||
|
1. مقدار خالی/`None` ⇒ نادیده (شمارش نمیشود).
|
||||||
|
2. تبدیل به موبایل معتبر (`normalize_mobile`) ؛ نامعتبر ⇒ کنار گذاشته و در
|
||||||
|
گزارش شمرده میشود (ثابت یا نامعتبر).
|
||||||
|
3. موبایل تکراری ⇒ حذف میشود (با حفظ جایگاه اولیه) و در گزارش شمرده میشود.
|
||||||
|
4. موبایلهای یکتا با `,` و **بدون فاصله** به هم میچسبند:
|
||||||
|
`+989120000001,+989120000002`
|
||||||
|
5. اگر طول رشته > `max_length` (۱۰۰ کاراکتر، از خود مدل خوانده میشود) ⇒
|
||||||
|
**هیچ truncateای انجام نمیشود**؛ رکورد دستنخورده میماند و شناسهاش در
|
||||||
|
گزارش سرریز میآید.
|
||||||
|
6. اگر نتیجه خالی بود ولی مشتری از قبل مقداری داشت ⇒ مقدار قبلی **پاک نمیشود**
|
||||||
|
و در شمارندهٔ «محافظت شد» گزارش میشود.
|
||||||
|
7. اگر نتیجه با مقدار فعلی موبهمو برابر بود ⇒ **هیچ نوشتنی انجام نمیشود**.
|
||||||
|
|
||||||
|
### نمونهها
|
||||||
|
|
||||||
|
| وضعیت مشتری | نتیجهٔ ذخیرهشده |
|
||||||
|
|---|---|
|
||||||
|
| `phone1=09120000001`, `phone2=09120000002` | `+989120000001,+989120000002` |
|
||||||
|
| `phone1=09120000001`, `phone2=989120000001` | `+989120000001` (تکراری حذف) |
|
||||||
|
| `phone1=۰۹۱۲۰۰۰۰۰۰۱` | `+989120000001` |
|
||||||
|
| `phone1=09120000001`, `phone2=02112345678` | `+989120000001` (ثابت فیلتر شد) |
|
||||||
|
| `phone1=09120000001`, `PhoneNumber=09120000002` | `+989120000001,+989120000002` |
|
||||||
|
| ۱۰ شمارهٔ معتبر (۱۳۹ کاراکتر) | **دستنخورده** + ثبت در سرریز |
|
||||||
|
| ۷ شمارهٔ معتبر (۹۷ کاراکتر) | ذخیره میشود (زیر سقف) |
|
||||||
|
| `phone1=''` و `PhoneNumber` خالی، مقدار قبلی `+98912…` | دستنخورده (محافظت) |
|
||||||
|
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## گزینهها
|
||||||
|
|
||||||
|
| گزینه | پیشفرض | کار |
|
||||||
|
|---|---|---|
|
||||||
|
| `--batch-size` | `100` | تعداد مشتری در هر batch (تراکنش اتمیک) |
|
||||||
|
| `--dry-run` | خاموش (اما **رفتار پیشفرض همین است**) | گزارش بدون هیچ نوشتنی؛ همراه `--apply` خطا میدهد |
|
||||||
|
| `--apply` | خاموش | نوشتن واقعی در دیتابیس + نوشتن فایل وضعیت |
|
||||||
|
| `--resume` | خاموش | ادامه از `last_id` داخل فایل وضعیت |
|
||||||
|
| `--start-id N` | — | شروع دستی از مشتریان `id > N`؛ **بر `--resume` اولویت دارد** |
|
||||||
|
| `--reset-checkpoint` | خاموش | پاککردن فایل وضعیت (در `--dry-run` نادیده گرفته میشود) |
|
||||||
|
| `--checkpoint PATH` | `.sms_normalization_checkpoint.json` در ریشهٔ پروژه | مسیر فایل وضعیت |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## اجرای محلی (توسعه)
|
||||||
|
|
||||||
|
### ۱) شبیهسازی — همیشه قدم اول
|
||||||
|
|
||||||
|
از WSL:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /mnt/d/projects/khatibi
|
||||||
|
.venv/Scripts/python.exe manage.py normalize_customer_sms_phones --dry-run
|
||||||
|
```
|
||||||
|
|
||||||
|
از Windows (PowerShell / CMD):
|
||||||
|
|
||||||
|
```bat
|
||||||
|
cd D:\projects\khatibi
|
||||||
|
.venv\Scripts\python.exe manage.py normalize_customer_sms_phones --dry-run
|
||||||
|
```
|
||||||
|
|
||||||
|
خروجی نمونهٔ واقعی روی دیتابیس توسعه (۱۰٬۴۲۶ مشتری، `--batch-size 100`):
|
||||||
|
|
||||||
|
```
|
||||||
|
حالت: شبیهسازی (dry-run) | batch=100 | فایل وضعیت: D:\Projects\khatibi\.sms_normalization_checkpoint.json
|
||||||
|
شروع از مشتریان با id > 0 (ابتدا)
|
||||||
|
batch 1: last_id=... | بررسیشده=100 | قابلبهروزرسانی=... | سرریز=0
|
||||||
|
...
|
||||||
|
batch 105: last_id=11427 | بررسیشده=10426 | قابلبهروزرسانی=10084 | سرریز=0
|
||||||
|
گزارش شبیهسازی (dry-run) — هیچ تغییری ذخیره نشد
|
||||||
|
مشتریان بررسیشده: 10426
|
||||||
|
مشتریان قابلبهروزرسانی: 10084
|
||||||
|
بدون تغییر: 4
|
||||||
|
شمارههای فیلترشده: ثابت=4864 | نامعتبر=0 | جمع=4864
|
||||||
|
شمارههای تکراری حذفشده: 13
|
||||||
|
سرریز طول (>100 کاراکتر، دستنخورده): 0
|
||||||
|
batchهای تکمیلشده: 105
|
||||||
|
آخرین شناسهٔ پردازششده: 11427
|
||||||
|
برای اجرای واقعی: --apply (و در صورت توقف قبلی: --apply --resume)
|
||||||
|
```
|
||||||
|
|
||||||
|
در `--dry-run`: نه دیتابیس نوشته میشود و نه فایل وضعیت ساخته میشود. (چکشده:
|
||||||
|
SHA-256 دیتابیس پیش و پس یکسان است و `.sms_normalization_checkpoint.json` وجود
|
||||||
|
ندارد.)
|
||||||
|
|
||||||
|
### ۲) تنظیم سرعت batch
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# پیشفرض: ۱۰۰ مشتری در هر تراکنش
|
||||||
|
.venv/Scripts/python.exe manage.py normalize_customer_sms_phones --dry-run
|
||||||
|
|
||||||
|
# ساعات پرترافیک سرور: batch کوچکتر ⇒ تراکنش کوتاهتر
|
||||||
|
.venv/Scripts/python.exe manage.py normalize_customer_sms_phones --dry-run --batch-size 50
|
||||||
|
|
||||||
|
# اجرای سریعتر روی سرور خلوت
|
||||||
|
.venv/Scripts/python.exe manage.py normalize_customer_sms_phones --dry-run --batch-size 500
|
||||||
|
```
|
||||||
|
|
||||||
|
### ۳) اجرای واقعی
|
||||||
|
|
||||||
|
```bash
|
||||||
|
.venv/Scripts/python.exe manage.py normalize_customer_sms_phones --apply
|
||||||
|
```
|
||||||
|
|
||||||
|
قبلش یکبار دیگر «هشدار معنای داده» را بخوان.
|
||||||
|
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## اجرا روی سرور Production
|
||||||
|
|
||||||
|
### ترتیب دقیق
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# ۰) فایلها روی سرور (بدون migration جدید — این دستور اسکیما را عوض نمیکند)
|
||||||
|
# فقط کد بهروز شود؛ entrypoint/docker-compose خودشان migrate میکنند.
|
||||||
|
|
||||||
|
# ۱) بکاپ دیتابیس — بخش «بکاپ» در fix_customer_names.md
|
||||||
|
docker compose exec web python manage.py sqlite_db_backup
|
||||||
|
# یا اگر S3 در دسترس نیست، اسنیپت WAL-safe در همان سند آمده است.
|
||||||
|
|
||||||
|
# ۲) تأیید مقصد دیتابیس (بدون افشای credential)
|
||||||
|
docker compose exec web python -c "
|
||||||
|
import django, os; django.setup()
|
||||||
|
from django.conf import settings as s
|
||||||
|
print('settings:', os.environ.get('DJANGO_SETTINGS_MODULE'))
|
||||||
|
print('DB:', s.DATABASES['default']['ENGINE'], s.DATABASES['default']['NAME'])"
|
||||||
|
|
||||||
|
# ۳) شبیهسازی روی دیتابیس واقعی — هیچ نوشتنی ندارد
|
||||||
|
docker compose exec web python manage.py normalize_customer_sms_phones --dry-run --batch-size 50
|
||||||
|
|
||||||
|
# ۴) اگر اعداد منطقی بود (و اثر پیامکی را پذیرفتی): اجرای واقعی
|
||||||
|
docker compose exec web python manage.py normalize_customer_sms_phones --apply --batch-size 50
|
||||||
|
|
||||||
|
# ۵) بررسی خلاصه و صحت
|
||||||
|
docker compose exec web python -c "
|
||||||
|
import django; django.setup()
|
||||||
|
from admin_panel.models import Customer
|
||||||
|
print('دارای شماره:', Customer.objects.exclude(sms_primary_phones='').count())"
|
||||||
|
```
|
||||||
|
|
||||||
|
### نکتهها برای Production
|
||||||
|
|
||||||
|
- دستور از **تنظیمات فعال Django** میخواند (`DJANGO_SETTINGS_MODULE`)، نه از
|
||||||
|
مسیر هاردکدشده. روی سرور `khatibi.production_settings` و
|
||||||
|
`NAME=/app/sqlite/db.sqlite3` (ولوم `khatibi-sqlite-db`) است.
|
||||||
|
- در ساعات کمترافیک اجرا کن. SQLite در هر لحظه یک نویسنده دارد؛ اگر web و
|
||||||
|
worker همزمان بنویسند، `busy_timeout=30000` (در `admin_panel/signals.py`) و
|
||||||
|
اندازهٔ batch کوچک باعث میشود کند شود، ولی **سایت متوقف نمیشود**.
|
||||||
|
- هیچ migration جدیدی لازم نیست و دستور هیچ migration ای اجرا نمیکند.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## توقف و ادامه (Resume / Checkpoint)
|
||||||
|
|
||||||
|
### فایل وضعیت
|
||||||
|
|
||||||
|
پیشفرض: `.sms_normalization_checkpoint.json` در ریشهٔ پروژه، با این ساختار:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"last_id": 11427,
|
||||||
|
"batch_size": 100,
|
||||||
|
"applied": true,
|
||||||
|
"scanned": 10426,
|
||||||
|
"updated": 10084,
|
||||||
|
"overflow": 0,
|
||||||
|
"batches": 105
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- نام فایل با یک نقطه شروع میشود، پس در پوشهٔ پروژه پنهان است
|
||||||
|
(`ls -a` ببینش).
|
||||||
|
- اگر آن را در ریشه نمیخواهی (مثلاً ریشهٔ پروژه read-only است)، با
|
||||||
|
`--checkpoint` مسیر دلخواه بده — مثلاً کنار خود دیتابیس:
|
||||||
|
`--checkpoint /app/sqlite/.sms_norm.json`.
|
||||||
|
- **نوشتن فایل اتمیک است** (`tmp` + `os.replace`)؛ قطع برق وسط نوشتن، فایلِ
|
||||||
|
نیمهساخته باقی نمیگذارد.
|
||||||
|
- در `--dry-run` این فایل **هرگز** ساخته یا تغییر داده نمیشود.
|
||||||
|
|
||||||
|
### اتمیک بودن
|
||||||
|
|
||||||
|
هر batch اینها را در یک تراکنش انجام میدهد: نوشتن `sms_primary_phones`
|
||||||
|
مشتریان همان batch. و فایل وضعیت **فقط پس از commit موفق** نوشته میشود.
|
||||||
|
|
||||||
|
نتیجهٔ قطعی وسط کار:
|
||||||
|
|
||||||
|
- batch در حال اجرا ⇒ rollback میشود و checkpoint همان batch **جلو نمیرود**.
|
||||||
|
- batchهای قبلی ⇒ commit شده و باقی میمانند.
|
||||||
|
- اجرای بعدی ⇒ از `last_id` ادامه میدهد، بدون جابهجایی رکورد و بدون پرش.
|
||||||
|
|
||||||
|
اگر قطعی در فاصلهٔ «commit» تا «نوشتن فایل وضعیت» رخ دهد، همان batch دوباره
|
||||||
|
پردازش میشود. این تراکنش **بیخطر است** (idempotent): مقدار یکسان ⇒ هیچ
|
||||||
|
نوشتنی. پس نه داده خراب میشود و نه رکوردی جا میماند.
|
||||||
|
|
||||||
|
### گزینههای ادامه
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# ادامهٔ خودکار از فایل وضعیت
|
||||||
|
python manage.py normalize_customer_sms_phones --apply --resume
|
||||||
|
|
||||||
|
# شروع دستی از یک شناسه (بر --resume اولویت دارد)
|
||||||
|
python manage.py normalize_customer_sms_phones --apply --start-id 5000
|
||||||
|
|
||||||
|
# شروع از صفر (پاککردن فایل وضعیت)
|
||||||
|
python manage.py normalize_customer_sms_phones --apply --reset-checkpoint
|
||||||
|
```
|
||||||
|
|
||||||
|
### سلسلهمراتب تصمیم نقطهٔ شروع
|
||||||
|
|
||||||
|
```
|
||||||
|
--start-id > --resume > 0 (از ابتدا)
|
||||||
|
```
|
||||||
|
|
||||||
|
بدون هیچ گزینهای، اجرای جدید از ابتدای جدول شروع میشود ولی چون مقدارهای
|
||||||
|
قبلاً نوشتهشده موبهمو برابرند، **دوباره نوشته نمیشوند** (idempotent).
|
||||||
|
یعنی اجرای دوبارهٔ ناخواسته خطرناک نیست، فقط بیدلیل جدول را میخواند.
|
||||||
|
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## تعریف کلیدهای گزارش
|
||||||
|
|
||||||
|
| کلید | معنا |
|
||||||
|
|---|---|
|
||||||
|
| مشتریان بررسیشده | تعداد مشتریانی که در این اجرا از دیتابیس خوانده شدند |
|
||||||
|
| مشتریان بهروزشده / قابلبهروزرسانی | در `--apply` تعداد رکوردهایی که واقعاً نوشته شدند؛ در dry-run تعداد رکوردهایی که نوشته **میشدند** |
|
||||||
|
| بدون تغییر | مقدار محاسبهشده موبهمو با مقدار فعلی برابر بود ⇒ هیچ نوشتنی |
|
||||||
|
| شمارههای فیلترشده: ثابت | شمارههای ثابت (پیششمارهدار یا ۸رقمی مثل `57387608`) |
|
||||||
|
| شمارههای فیلترشده: نامعتبر | ناقص، `0`، متن آزاد |
|
||||||
|
| شمارههای تکراری حذفشده | موبایلهای معتبری که قبلاً در همان مشتری دیده شده بودند |
|
||||||
|
| سرریز طول | مشتریانی که رشتهٔ نهاییشان > ۱۰۰ کاراکتر میشد ⇒ دستنخورده + شناسهها چاپ میشود |
|
||||||
|
| مقدار موجودِ غیرخالی که محافظت شد | مشتریانی که شمارهٔ معتبر نداشتند ولی مقدار قبلیشان پاک **نشد** (فقط اگر > ۰ باشد چاپ میشود) |
|
||||||
|
| batchهای تکمیلشده | تعداد تراکنشهای commitشده |
|
||||||
|
| آخرین شناسهٔ پردازششده | `last_id` نهایی؛ در `--apply` همان مقدار داخل فایل وضعیت |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## عملکرد و بار روی دیتابیس
|
||||||
|
|
||||||
|
- **Keyset pagination**: `filter(id__gt=last_id).order_by('id')[:batch_size]` —
|
||||||
|
نه offset، نه شمارهٔ ردیف. پس افزودن/حذف مشتری وسط کار باعث پرش یا تکرار
|
||||||
|
نمیشود و ترتیب پردازش پایدار است.
|
||||||
|
- **جلوگیری از N+1**: `prefetch_related('phones')` ⇒ **۳ کوئری ثابت در هر
|
||||||
|
batch**، مستقل از تعداد مشتریان و تعداد شمارهها. یک تست با
|
||||||
|
`assertNumQueries(3)` این را قفل کرده است.
|
||||||
|
- **حداقل حافظه**: `only('id', 'phone1', 'phone2', 'phone3',
|
||||||
|
'sms_primary_phones')` — بقیهٔ ستونهای سنگین (یادداشت، آدرس) خوانده نمیشوند
|
||||||
|
و batchها کوچکاند.
|
||||||
|
- **هیچ نوشتن غیرضروری**: `bulk_update([...], ['sms_primary_phones'])` فقط برای
|
||||||
|
رکوردهای تغییریافته و فقط همان یک ستون. `save()` کامل صدا زده نمیشود، پس
|
||||||
|
فیلدهای `auto_now_add` مثل `create_date` لمس نمیشوند.
|
||||||
|
|
||||||
|
زمانبندی تقریبی: ۱۰٬۴۲۶ مشتری با `--batch-size 100` = ۱۰۵ تراکنش کوچک؛ در
|
||||||
|
محیط توسعه چند ثانیه.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## خطاها و اقدام درست
|
||||||
|
|
||||||
|
| پیام | معنا | اقدام |
|
||||||
|
|---|---|---|
|
||||||
|
| `unsupported operand`/متن خطای غیرمنتظره | خطا وسط batch | batch جاری rollback شد؛ با `--apply --resume` ادامه بده |
|
||||||
|
| `--resume داده شد ولی فایل وضعیت وجود ندارد: …` | فایل پاک شده یا مسیر `--checkpoint` عوض شده | یا بدون `--resume` اجرا کن، یا مسیر درست را با `--checkpoint` بده |
|
||||||
|
| `فایل وضعیت معتبر نیست (last_id=…)` | فایل دستکاری/خراب شده | `--reset-checkpoint` یا فایل را پاک کن |
|
||||||
|
| `فایل وضعیت خوانده نشد (…): …` | JSON خراب یا دسترسی فایل | همان بالا |
|
||||||
|
| `فایل وضعیت پاک نشد (…)` | دسترسی نوشتن ندارد | مسیر `--checkpoint` را به جایی قابلنوشتن تغییر بده |
|
||||||
|
| `--dry-run و --apply با هم ناسازگارند.` | هر دو فلگ داده شده | فقط یکی را بده |
|
||||||
|
| `KeyboardInterrupt` / پیام «متوقف شد (Ctrl+C)» | توقف دستی | آخرین شناسهٔ معتبر چاپ میشود؛ `--apply --resume` |
|
||||||
|
|
||||||
|
هیچکدام از این خطاها باعث truncate، پاکشدن مقدار قبلی یا نیمهکاره ماندن یک
|
||||||
|
batch نمیشود.
|
||||||
|
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## بررسی صحت داده (SQL)
|
||||||
|
|
||||||
|
روی دیتابیس، بدون اجرای دستور (فقط برای سنجش). دیتابیس را با `mode=ro` باز کن
|
||||||
|
تا هیچچیز تغییر نکند:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
.venv/Scripts/python.exe - <<'PY'
|
||||||
|
import sqlite3
|
||||||
|
con = sqlite3.connect('file:data9/kt.sqlite3?mode=ro', uri=True)
|
||||||
|
q = lambda s: con.execute(s).fetchone()[0]
|
||||||
|
print('کل مشتریان:', q('select count(*) from admin_panel_customer'))
|
||||||
|
print('دارای مقدار:', q("select count(*) from admin_panel_customer where trim(coalesce(sms_primary_phones,''))<>''"))
|
||||||
|
print('چند شمارهای:', q("select count(*) from admin_panel_customer where sms_primary_phones like '%,%'"))
|
||||||
|
print('قالب محلی (09…):', q("select count(*) from admin_panel_customer where sms_primary_phones like '09%'"))
|
||||||
|
print('قالب متعارف (+98…):', q("select count(*) from admin_panel_customer where sms_primary_phones like '+98%'"))
|
||||||
|
print('بیشترین طول:', q("select max(length(sms_primary_phones)) from admin_panel_customer"))
|
||||||
|
con.close()
|
||||||
|
PY
|
||||||
|
```
|
||||||
|
|
||||||
|
`قالب محلی (09…)` باید همیشه `0` بماند — اگر عددی غیر از صفر دیدی، یعنی جایی
|
||||||
|
خارج از قواعد این دستور نوشته شده است.
|
||||||
|
|
||||||
|
بررسی پس از اجرای واقعی (برای دو نمونهٔ مشخص، پیش و پس):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose exec web python -c "
|
||||||
|
import django; django.setup()
|
||||||
|
from admin_panel.models import Customer
|
||||||
|
for c in Customer.objects.exclude(sms_primary_phones='').order_by('-id')[:5]:
|
||||||
|
print(c.id, '|', c.phone1, c.phone2, c.phone3, '->', c.sms_primary_phones)"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## بازگردانی (اگر نتیجه را نخواستی)
|
||||||
|
|
||||||
|
دستور Undo ندارد. بازگردانی از بکاپ:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose stop web worker scheduler delivery
|
||||||
|
|
||||||
|
docker run --rm -v khatibi-sqlite-db:/data -v "$(pwd)":/backup alpine sh -c \
|
||||||
|
"cp /backup/db_backup_YYYYmmdd_HHMMSS.sqlite3 /data/db.sqlite3 \
|
||||||
|
&& rm -f /data/db.sqlite3-wal /data/db.sqlite3-shm"
|
||||||
|
|
||||||
|
docker compose start migrate web worker scheduler delivery
|
||||||
|
```
|
||||||
|
|
||||||
|
اگر فقط میخواهی ستون برای همه خالی شود (بدون بازگردانی کل بکاپ) — **این کار
|
||||||
|
تعداد گیرندگان پیامک را به حالت قبل برمیگرداند (فقط شمارهٔ اول)** و انتخابهای
|
||||||
|
دستی اپراتور را هم پاک میکند. عمداً هیچ دستوری برای انجام خودکارش نوشته نشده؛
|
||||||
|
با آگاهی و دستی در `shell` انجام بده:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# ⚠️ تنها در صورت تصمیم آگاهانه
|
||||||
|
python manage.py shell -c "
|
||||||
|
from admin_panel.models import Customer
|
||||||
|
print(Customer.objects.exclude(sms_primary_phones='').update(sms_primary_phones=''))"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## تستها
|
||||||
|
|
||||||
|
```bash
|
||||||
|
.venv/Scripts/python.exe manage.py test tests.test_sms_phone_normalize # ۲۷ تست
|
||||||
|
.venv/Scripts/python.exe manage.py test tests # کل مجموعه
|
||||||
|
```
|
||||||
|
|
||||||
|
پوشش تستها:
|
||||||
|
|
||||||
|
- **قالبها**: `+98`، `0098`، `98`، `9…`، `09…`، ارقام فارسی، ارقام عربی،
|
||||||
|
جداکنندهها.
|
||||||
|
- **اعتبارسنجی**: شمارهٔ ثابت پیششمارهدار، ثابت ۸رقمی، `0`، متن آزاد، خالی.
|
||||||
|
- **ترتیب و تکراری**: اولویت `phone1→phone2→phone3→PhoneNumber.id` و حذف
|
||||||
|
تکراری با حفظ جایگاه.
|
||||||
|
- **ایمنی داده**: فیلدهای مبدأ و ردیفهای `PhoneNumber` و سایر فیلدهای مشتری
|
||||||
|
دستنخورده؛ رکورد بدون تغییر هیچ نوشتنی ندارد؛ مقدار موجودِ غیرخالی با
|
||||||
|
نتیجهٔ خالی پاک نمیشود.
|
||||||
|
- **سرریز**: ۱۳۹ کاراکتر ⇒ دستنخورده + شناسه در گزارش؛ ۹۷ کاراکتر ⇒ ذخیره.
|
||||||
|
- **batch و checkpoint**: پوشش کامل با batch کوچک، نوشتن فایل پس از هر batch،
|
||||||
|
`resume`، `--start-id`، `--reset-checkpoint`، `resume` بدون فایل ⇒ خطا، فایل
|
||||||
|
خراب ⇒ خطا.
|
||||||
|
- **اتمیک بودن واقعی**: batch دوم با خطا میشکند ⇒ batch اول commit میماند،
|
||||||
|
batch دوم rollback میشود، checkpoint جلو نمیرود، و `--resume` بعدی کار را
|
||||||
|
تمام میکند.
|
||||||
|
- **کارایی**: `assertNumQueries(3)` برای هر batch.
|
||||||
|
- **سازگاری با پنل**: خروجی دستور با `resolve_recipients()` خوانده میشود.
|
||||||
|
|
||||||
|
تستها روی دیتابیس تستی Django (SQLite درونحافظه) اجرا میشوند؛ نه به
|
||||||
|
`data9/kt.sqlite3` دست میزنند و نه فایل وضعیت ریشهٔ پروژه را میسازند.
|
||||||
|
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## نکتهها و محدودیتها
|
||||||
|
|
||||||
|
- **قفل اجرای همزمان ندارد.** دو `--apply` موازی میتوانند درهم بیامیزند
|
||||||
|
(فایل وضعیت: آخرین نویسنده برنده). برای دستور دستی اپراتور پذیرفتنی است؛ ولی
|
||||||
|
دو نسخه را همزمان روی یک دیتابیس اجرا نکن. برای اطمینان:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# مطمئن شو نمونهٔ دیگری در حال اجرا نیست
|
||||||
|
ps aux | grep -c '[n]ormalize_customer_sms_phones'
|
||||||
|
```
|
||||||
|
- **اثر روی پیامک** — بند اول همین سند را ببین؛ مهمترین نکتهٔ تصمیمگیری.
|
||||||
|
- **شمارههای ثابت/نامعتبر از فیلدهای مبدأ حذف نمیشوند.** فقط از تجمیع بیرون
|
||||||
|
میمانند. تمیزکاری فیلدهای مبدأ کار دستور جداگانهٔ `fix_customer_phones` است.
|
||||||
|
- **`sms_primary_phones` یک `CharField(max_length=100)` است.** با ~۸ شمارهٔ
|
||||||
|
موبایل پر میشود. برای مشتریی با شمارههای بیشتر، دستور عمداً هیچکاری
|
||||||
|
نمیکند (سرریز) تا داده را نصفه نریزد. اگر مشتری زیاد سرریز داشت (در گزارش
|
||||||
|
میبینی)، افزایش `max_length` نیازمند migration جداگانه است.
|
||||||
|
- **معنای «اصلی» تغییر میکند.** اگر فرض قبلی سیستم این بود که این فیلد فقط
|
||||||
|
انتخاب دستیِ مدیر را نگه دارد، از این پس همهٔ شمارههای معتبر را نگه میدارد.
|
||||||
|
- **`--reset-checkpoint` در `--dry-run` عمداً نادیده گرفته میشود** تا شبیهسازی
|
||||||
|
هیچ اثری روی وضعیت اجراهای واقعی نگذارد.
|
||||||
|
- **بازگشتپذیری**: با `--apply` آمار داخل فایل وضعیت ذخیره میشود، ولی تاریخچهٔ
|
||||||
|
per-customer نگه داشته نمیشود (ستون زمان/audit ندارد). برای برگشت، بکاپ لازم
|
||||||
|
است.
|
||||||
|
- کنسول ویندوز: متن فارسی در خروجی ممکن است `charmap` خطا بدهد؛ مثل بقیهٔ
|
||||||
|
سرویسهای پروژه از `python -X utf8 manage.py …` استفاده کن.
|
||||||
|
- فایلهای `-wal` و `-shm` دیتابیس جزء دیتابیساند؛ در بکاپ دستی هر سه را در
|
||||||
|
لحظهٔ بدون نوشتن کپی کن (یا از `sqlite3.Connection.backup` استفاده کن).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## چکلیست سریع
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# ── توسعه ────────────────────────────────────────────────────────────────
|
||||||
|
cd /mnt/d/projects/khatibi
|
||||||
|
.venv/Scripts/python.exe manage.py test tests.test_sms_phone_normalize
|
||||||
|
.venv/Scripts/python.exe manage.py normalize_customer_sms_phones --dry-run
|
||||||
|
|
||||||
|
# ── Production ───────────────────────────────────────────────────────────
|
||||||
|
docker compose exec web python manage.py sqlite_db_backup # ۱) بکاپ
|
||||||
|
docker compose exec web python manage.py normalize_customer_sms_phones --dry-run --batch-size 50
|
||||||
|
docker compose exec web python manage.py normalize_customer_sms_phones --apply --batch-size 50
|
||||||
|
|
||||||
|
# ── پس از توقف ناخواسته ─────────────────────────────────────────────────
|
||||||
|
docker compose exec web python manage.py normalize_customer_sms_phones --apply --resume
|
||||||
|
|
||||||
|
# ── شروع از صفر (با احتیاط) ─────────────────────────────────────────────
|
||||||
|
docker compose exec web python manage.py normalize_customer_sms_phones --apply --reset-checkpoint
|
||||||
|
```
|
||||||
|
|
||||||
|
سه تصمیم قبل از `--apply`:
|
||||||
|
|
||||||
|
1. اثر پیامکی (ارسال به همهٔ شمارهها بهجای شمارهٔ اول) را میپذیری؟
|
||||||
|
2. بکاپ گرفتهای و از سالم بودنش مطمئنی؟
|
||||||
|
3. اعداد `--dry-run` را دیدهای (خصوصاً `سرریز طول` و `مقدار موجودِ محافظتشده`)؟
|
||||||
|
|
||||||
@@ -0,0 +1,476 @@
|
|||||||
|
"""تستهای قابلیت «افزودن پیشوند آقای/خانم به نام مشتریان».
|
||||||
|
|
||||||
|
همهٔ تستها روی دیتابیس تستی Django اجرا میشوند (SQLite در حافظه) و هیچگاه به
|
||||||
|
``data9/kt.sqlite3`` (دیتابیس واقعی) دست نمیزنند. فایل دادهٔ واقعی هم دستنخورده
|
||||||
|
میماند؛ هر تست روی یک فایل JSON ساختگیِ موقت کار میکند.
|
||||||
|
"""
|
||||||
|
import hashlib
|
||||||
|
import io
|
||||||
|
import itertools
|
||||||
|
import json
|
||||||
|
import shutil
|
||||||
|
import tempfile
|
||||||
|
from datetime import timedelta
|
||||||
|
from pathlib import Path
|
||||||
|
from unittest import mock
|
||||||
|
|
||||||
|
from django.core.management import call_command
|
||||||
|
from django.core.management.base import CommandError
|
||||||
|
from django.db import connections
|
||||||
|
from django.db.models.query import QuerySet
|
||||||
|
from django.db.utils import OperationalError
|
||||||
|
from django.test import SimpleTestCase, TestCase
|
||||||
|
from django.utils import timezone
|
||||||
|
|
||||||
|
from admin_panel.models import Customer, CustomerNameFixJob
|
||||||
|
from admin_panel.name_fix import (ALGORITHM_VERSION, DatasetError,
|
||||||
|
build_name_index, decide_prefix, load_name_index,
|
||||||
|
normalize_name_key)
|
||||||
|
from admin_panel.management.commands.fix_customer_names import (LOCK_TTL,
|
||||||
|
MAX_DB_RETRIES)
|
||||||
|
|
||||||
|
from .factories import make_customer
|
||||||
|
|
||||||
|
DATASET_RECORDS = [
|
||||||
|
{"Names": "محمد", "Gender": "M"},
|
||||||
|
{"Names": "مریم", "Gender": "F"},
|
||||||
|
{"Names": "علی", "Gender": "M"},
|
||||||
|
{"Names": "علي", "Gender": "M"}, # ي عربی؛ همان کلید نرمالشده
|
||||||
|
{"Names": "كاظم", "Gender": "M"}, # ك عربی
|
||||||
|
{"Names": "کاظم", "Gender": "M"}, # ک فارسی (همان کلید)
|
||||||
|
{"Names": "سارا", "Gender": "F"},
|
||||||
|
{"Names": "سارا", "Gender": "F"}, # تکراری همجنسیت
|
||||||
|
{"Names": "آرش", "Gender": "M"},
|
||||||
|
{"Names": "آرش", "Gender": "F"}, # تکراری متناقض ⇒ مبهم
|
||||||
|
{"Names": "نازنین", "Gender": "D"}, # جنسیت نامعتبر
|
||||||
|
{"Names": 123, "Gender": "M"}, # رکورد malformed
|
||||||
|
{"Names": "", "Gender": "M"}, # نام خالی
|
||||||
|
{"Names": "بیجنس"}, # کلید Gender ندارد
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def sha256_of(path):
|
||||||
|
return hashlib.sha256(Path(path).read_bytes()).hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
class DatasetFixtureMixin:
|
||||||
|
"""ساخت فایل JSON موقت و پاکسازی آن پس از تست."""
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
super().setUp()
|
||||||
|
self.tmpdir = tempfile.mkdtemp(prefix="namefix-")
|
||||||
|
self.addCleanup(shutil.rmtree, self.tmpdir, ignore_errors=True)
|
||||||
|
|
||||||
|
def write_dataset(self, records=DATASET_RECORDS, name="iranianNamesDataset.json"):
|
||||||
|
path = Path(self.tmpdir) / name
|
||||||
|
path.write_text(json.dumps({"RECORDS": records}, ensure_ascii=False),
|
||||||
|
encoding="utf-8")
|
||||||
|
return path
|
||||||
|
|
||||||
|
|
||||||
|
class NameIndexTests(DatasetFixtureMixin, SimpleTestCase):
|
||||||
|
"""اعتبارسنجی فایل، نرمالسازی و ساخت index."""
|
||||||
|
|
||||||
|
def test_load_builds_conservative_index_and_report(self):
|
||||||
|
path = self.write_dataset()
|
||||||
|
name_index = load_name_index(path)
|
||||||
|
|
||||||
|
self.assertEqual(name_index.sha256, sha256_of(path))
|
||||||
|
self.assertEqual(name_index.lookup("محمد"), "M")
|
||||||
|
self.assertEqual(name_index.lookup("مریم"), "F")
|
||||||
|
# ي/ی و ك/ک یکسانسازی میشوند.
|
||||||
|
self.assertEqual(name_index.lookup("علی"), "M")
|
||||||
|
self.assertEqual(name_index.lookup("کاظم"), "M")
|
||||||
|
# تکراری متناقض و جنسیت نامعتبر کنار گذاشته میشوند.
|
||||||
|
self.assertIsNone(name_index.lookup("آرش"))
|
||||||
|
self.assertIsNone(name_index.lookup("نازنین"))
|
||||||
|
|
||||||
|
rep = name_index.report
|
||||||
|
self.assertEqual(rep["records_total"], 14)
|
||||||
|
self.assertEqual(rep["rejected_malformed"], 2)
|
||||||
|
self.assertEqual(rep["rejected_empty_name"], 1)
|
||||||
|
self.assertEqual(rep["rejected_unknown_gender"], 1)
|
||||||
|
self.assertEqual(rep["usable_keys"], 5)
|
||||||
|
self.assertEqual(rep["unusable_keys"], 2)
|
||||||
|
self.assertEqual(rep["duplicate_keys"], 4)
|
||||||
|
self.assertEqual(rep["duplicate_same_gender"], 3)
|
||||||
|
self.assertEqual(rep["duplicate_conflict"], 1)
|
||||||
|
|
||||||
|
def test_normalization_unifies_arabic_and_persian_letters(self):
|
||||||
|
self.assertEqual(normalize_name_key(" علي "), "علی")
|
||||||
|
self.assertEqual(normalize_name_key("كاظم"), "کاظم")
|
||||||
|
self.assertEqual(normalize_name_key("محمد"), "محمد")
|
||||||
|
self.assertEqual(normalize_name_key(None), "")
|
||||||
|
|
||||||
|
def test_missing_records_key_is_rejected(self):
|
||||||
|
path = Path(self.tmpdir) / "bad.json"
|
||||||
|
path.write_text(json.dumps({"DATA": []}), encoding="utf-8")
|
||||||
|
with self.assertRaises(DatasetError):
|
||||||
|
load_name_index(path)
|
||||||
|
|
||||||
|
def test_records_must_be_a_list(self):
|
||||||
|
path = Path(self.tmpdir) / "bad.json"
|
||||||
|
path.write_text(json.dumps({"RECORDS": {"Names": "x"}}), encoding="utf-8")
|
||||||
|
with self.assertRaises(DatasetError):
|
||||||
|
load_name_index(path)
|
||||||
|
|
||||||
|
def test_invalid_json_is_rejected(self):
|
||||||
|
path = Path(self.tmpdir) / "bad.json"
|
||||||
|
path.write_text("{not json", encoding="utf-8")
|
||||||
|
with self.assertRaises(DatasetError):
|
||||||
|
load_name_index(path)
|
||||||
|
|
||||||
|
def test_normalization_collision_with_conflicting_gender_is_ambiguous(self):
|
||||||
|
name_index = build_name_index([
|
||||||
|
{"Names": "علي", "Gender": "M"},
|
||||||
|
{"Names": "علی", "Gender": "F"}, # پس از نرمالسازی همان کلید
|
||||||
|
])
|
||||||
|
self.assertIsNone(name_index.lookup("علی"))
|
||||||
|
self.assertIn("علی", name_index.ambiguous)
|
||||||
|
|
||||||
|
|
||||||
|
class DecidePrefixTests(DatasetFixtureMixin, SimpleTestCase):
|
||||||
|
"""الگوریتم تصمیم، مستقل از دیتابیس."""
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
super().setUp()
|
||||||
|
self.name_index = load_name_index(self.write_dataset())
|
||||||
|
|
||||||
|
def test_expected_behaviour_table(self):
|
||||||
|
cases = [
|
||||||
|
("محمد یاوری", "آقای محمد یاوری", "male"),
|
||||||
|
("مریم احمدی", "خانم مریم احمدی", "female"),
|
||||||
|
(" محمد یاوری ", "آقای محمد یاوری", "male"),
|
||||||
|
("\tمریم احمدی\n", "خانم مریم احمدی", "female"),
|
||||||
|
("یاوری", None, "single_word"),
|
||||||
|
("محمد", None, "single_word"),
|
||||||
|
("مریم", None, "single_word"),
|
||||||
|
("آقا محمد یاوری", None, "has_prefix"),
|
||||||
|
("آقای محمد یاوری", None, "has_prefix"),
|
||||||
|
("خانم مریم احمدی", None, "has_prefix"),
|
||||||
|
("نام ناشناخته احمدی", None, "unknown_name"),
|
||||||
|
("آرش محمدی", None, "ambiguous_gender"),
|
||||||
|
("نازنین کریمی", None, "ambiguous_gender"),
|
||||||
|
("علي رضایی", "آقای علي رضایی", "male"), # نام اصلی دستنخورده میماند
|
||||||
|
("", None, "empty"),
|
||||||
|
(" ", None, "empty"),
|
||||||
|
]
|
||||||
|
for raw, expected_new, expected_reason in cases:
|
||||||
|
decision = decide_prefix(raw, self.name_index)
|
||||||
|
self.assertEqual(decision.new_name, expected_new, msg=repr(raw))
|
||||||
|
self.assertEqual(decision.reason, expected_reason, msg=repr(raw))
|
||||||
|
|
||||||
|
def test_none_value_is_handled(self):
|
||||||
|
decision = decide_prefix(None, self.name_index)
|
||||||
|
self.assertIsNone(decision.new_name)
|
||||||
|
self.assertEqual(decision.reason, "empty")
|
||||||
|
|
||||||
|
def test_internal_spaces_are_not_rewritten(self):
|
||||||
|
decision = decide_prefix("محمد یاوری", self.name_index)
|
||||||
|
self.assertEqual(decision.new_name, "آقای محمد یاوری")
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
# (نام اولیه، نتیجهٔ مورد انتظار) — ترتیب = ترتیب ساخت ⇒ ترتیب pk
|
||||||
|
CASES = [
|
||||||
|
("محمد یاوری", "آقای محمد یاوری"),
|
||||||
|
("مریم احمدی", "خانم مریم احمدی"),
|
||||||
|
(" محمد یاوری ", "آقای محمد یاوری"),
|
||||||
|
("یاوری", "یاوری"),
|
||||||
|
("محمد", "محمد"),
|
||||||
|
("مریم", "مریم"),
|
||||||
|
("آقا محمد یاوری", "آقا محمد یاوری"),
|
||||||
|
("آقای محمد یاوری", "آقای محمد یاوری"),
|
||||||
|
("خانم مریم احمدی", "خانم مریم احمدی"),
|
||||||
|
("نام ناشناخته احمدی", "نام ناشناخته احمدی"),
|
||||||
|
("آرش محمدی", "آرش محمدی"),
|
||||||
|
("نازنین کریمی", "نازنین کریمی"),
|
||||||
|
("علي رضایی", "آقای علي رضایی"),
|
||||||
|
(" نام ناشناخته احمدی ", " نام ناشناخته احمدی "),
|
||||||
|
]
|
||||||
|
|
||||||
|
EXPECTED_CHANGED = 4 # موارد ۱، ۲، ۳، ۱۳
|
||||||
|
EXPECTED_SINGLE_WORD = 3 # یاوری، محمد، مریم
|
||||||
|
EXPECTED_HAS_PREFIX = 3
|
||||||
|
EXPECTED_UNKNOWN = 2 # «نام ناشناخته …» دو مورد (با/بی فاصله)
|
||||||
|
EXPECTED_AMBIGUOUS = 2 # آرش، نازنین
|
||||||
|
|
||||||
|
|
||||||
|
class CommandTestMixin(DatasetFixtureMixin):
|
||||||
|
"""ساخت مشتریان نمونه + فایل دادهٔ موقت برای تستهای فرمان."""
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
super().setUp()
|
||||||
|
self.json_path = self.write_dataset()
|
||||||
|
self.customers = [make_customer(name) for name, _ in CASES]
|
||||||
|
|
||||||
|
def run_command(self, **options):
|
||||||
|
options.setdefault("json_path", str(self.json_path))
|
||||||
|
options.setdefault("stdout", self._stdout())
|
||||||
|
return call_command("fix_customer_names", **options)
|
||||||
|
|
||||||
|
def _stdout(self):
|
||||||
|
return io.StringIO()
|
||||||
|
|
||||||
|
def names(self):
|
||||||
|
return list(Customer.objects.order_by("pk")
|
||||||
|
.values_list("name_family", flat=True))
|
||||||
|
|
||||||
|
def expected_names(self):
|
||||||
|
return [expected for _, expected in CASES]
|
||||||
|
|
||||||
|
def max_pk(self):
|
||||||
|
return max(c.pk for c in self.customers)
|
||||||
|
|
||||||
|
|
||||||
|
class DryRunTests(CommandTestMixin, TestCase):
|
||||||
|
"""پیشفرض فرمان نباید هیچچیز بنویسد."""
|
||||||
|
|
||||||
|
def test_dry_run_changes_nothing(self):
|
||||||
|
out = self._stdout()
|
||||||
|
self.run_command(stdout=out)
|
||||||
|
self.assertEqual(self.names(), [name for name, _ in CASES])
|
||||||
|
self.assertEqual(CustomerNameFixJob.objects.count(), 0)
|
||||||
|
self.assertIn("dry-run", out.getvalue())
|
||||||
|
|
||||||
|
def test_dry_run_flag_conflicts_with_apply(self):
|
||||||
|
with self.assertRaises(CommandError):
|
||||||
|
self.run_command(dry_run=True, apply=True, confirm_production=True)
|
||||||
|
self.assertEqual(self.names(), [name for name, _ in CASES])
|
||||||
|
|
||||||
|
def test_dry_run_reports_expected_counts(self):
|
||||||
|
out = self._stdout()
|
||||||
|
self.run_command(stdout=out)
|
||||||
|
text = out.getvalue()
|
||||||
|
self.assertIn(f"مشتریان بررسیشده: {len(CASES)}", text)
|
||||||
|
self.assertIn(f"قابل اصلاح / اصلاحشده: {EXPECTED_CHANGED}", text)
|
||||||
|
self.assertIn(f"تککلمهای: {EXPECTED_SINGLE_WORD}", text)
|
||||||
|
self.assertIn(f"ناشناخته در JSON: {EXPECTED_UNKNOWN}", text)
|
||||||
|
self.assertIn(f"جنسیت مبهم/نامعتبر: {EXPECTED_AMBIGUOUS}", text)
|
||||||
|
# نمونهٔ قبل/بعد نمایش داده میشود.
|
||||||
|
self.assertIn("«محمد یاوری» → «آقای محمد یاوری»", text)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
class ApplyTests(CommandTestMixin, TestCase):
|
||||||
|
"""اجرای واقعی: گارد تأیید، تغییر تنها مشتریان واجد شرایط، اتمیک بودن."""
|
||||||
|
|
||||||
|
APPLY = {"apply": True, "confirm_production": True}
|
||||||
|
|
||||||
|
def test_apply_without_confirm_production_is_refused(self):
|
||||||
|
with self.assertRaises(CommandError):
|
||||||
|
self.run_command(apply=True)
|
||||||
|
self.assertEqual(self.names(), [name for name, _ in CASES])
|
||||||
|
self.assertEqual(CustomerNameFixJob.objects.count(), 0)
|
||||||
|
|
||||||
|
def test_apply_changes_only_eligible_names(self):
|
||||||
|
self.run_command(batch_size=3, **self.APPLY)
|
||||||
|
self.assertEqual(self.names(), self.expected_names())
|
||||||
|
|
||||||
|
job = CustomerNameFixJob.objects.get()
|
||||||
|
self.assertEqual(job.status, CustomerNameFixJob.Status.COMPLETED)
|
||||||
|
self.assertEqual(job.algorithm_version, ALGORITHM_VERSION)
|
||||||
|
self.assertEqual(job.source_hash, sha256_of(self.json_path))
|
||||||
|
self.assertEqual(job.initial_max_pk, self.max_pk())
|
||||||
|
self.assertEqual(job.last_processed_pk, self.max_pk())
|
||||||
|
self.assertEqual(job.stats["scanned"], len(CASES))
|
||||||
|
self.assertEqual(job.stats["changed"], EXPECTED_CHANGED)
|
||||||
|
self.assertEqual(job.stats["single_word"], EXPECTED_SINGLE_WORD)
|
||||||
|
self.assertEqual(job.stats["has_prefix"], EXPECTED_HAS_PREFIX)
|
||||||
|
self.assertEqual(job.stats["unknown_name"], EXPECTED_UNKNOWN)
|
||||||
|
self.assertEqual(job.stats["ambiguous_gender"], EXPECTED_AMBIGUOUS)
|
||||||
|
self.assertEqual(job.stats["batches"], 5) # ۱۴ مشتری، batch=3
|
||||||
|
|
||||||
|
def test_other_customer_fields_and_json_file_remain_untouched(self):
|
||||||
|
target = self.customers[0]
|
||||||
|
Customer.objects.filter(pk=target.pk).update(
|
||||||
|
address="تهران، خیابان تست", phone1="09120000000",
|
||||||
|
notes="یادداشت مهم", is_active=False)
|
||||||
|
before_hash = sha256_of(self.json_path)
|
||||||
|
|
||||||
|
self.run_command(**self.APPLY)
|
||||||
|
|
||||||
|
target.refresh_from_db()
|
||||||
|
self.assertEqual(target.name_family, "آقای محمد یاوری")
|
||||||
|
self.assertEqual(target.address, "تهران، خیابان تست")
|
||||||
|
self.assertEqual(target.phone1, "09120000000")
|
||||||
|
self.assertEqual(target.notes, "یادداشت مهم")
|
||||||
|
self.assertFalse(target.is_active)
|
||||||
|
self.assertEqual(sha256_of(self.json_path), before_hash)
|
||||||
|
|
||||||
|
def test_customer_model_has_no_gender_field(self):
|
||||||
|
# فقط فیلدهای واقعی ستوندار مدل (بدون روابط معکوس).
|
||||||
|
field_names = {f.name for f in Customer._meta.concrete_fields}
|
||||||
|
self.assertEqual(field_names, {
|
||||||
|
"id", "name_family", "address", "notes", "phone1", "phone2",
|
||||||
|
"phone3", "idCode", "installDate", "create_date", "last_bazdid_dt",
|
||||||
|
"laghv_bazdid", "is_active", "sms_primary_phones",
|
||||||
|
})
|
||||||
|
self.assertNotIn("gender", field_names)
|
||||||
|
|
||||||
|
def test_rerun_of_completed_job_requires_new_job(self):
|
||||||
|
self.run_command(**self.APPLY)
|
||||||
|
with self.assertRaises(CommandError):
|
||||||
|
self.run_command(**self.APPLY)
|
||||||
|
self.assertEqual(self.names(), self.expected_names())
|
||||||
|
self.assertEqual(CustomerNameFixJob.objects.count(), 1)
|
||||||
|
|
||||||
|
def test_new_job_rerun_is_idempotent(self):
|
||||||
|
self.run_command(**self.APPLY)
|
||||||
|
self.run_command(new_job=True, **self.APPLY)
|
||||||
|
# پیشوندهای موجود تکرار نشدهاند.
|
||||||
|
self.assertEqual(self.names(), self.expected_names())
|
||||||
|
self.assertEqual(CustomerNameFixJob.objects.count(), 2)
|
||||||
|
self.assertEqual(CustomerNameFixJob.objects.latest("pk").stats["changed"], 0)
|
||||||
|
|
||||||
|
def test_missing_json_path_is_reported(self):
|
||||||
|
with self.assertRaises(CommandError):
|
||||||
|
self.run_command(json_path=str(Path(self.tmpdir) / "nope.json"),
|
||||||
|
apply=True, confirm_production=True)
|
||||||
|
self.assertEqual(self.names(), [name for name, _ in CASES])
|
||||||
|
|
||||||
|
def test_invalid_dataset_aborts_before_any_change(self):
|
||||||
|
bad = Path(self.tmpdir) / "bad.json"
|
||||||
|
bad.write_text("{oops", encoding="utf-8")
|
||||||
|
with self.assertRaises(CommandError):
|
||||||
|
self.run_command(json_path=str(bad), apply=True, confirm_production=True)
|
||||||
|
self.assertEqual(self.names(), [name for name, _ in CASES])
|
||||||
|
self.assertEqual(CustomerNameFixJob.objects.count(), 0)
|
||||||
|
|
||||||
|
def test_missing_job_table_is_reported(self):
|
||||||
|
with mock.patch.object(connections["default"].introspection,
|
||||||
|
"table_names", return_value=[]):
|
||||||
|
with self.assertRaises(CommandError):
|
||||||
|
self.run_command(**self.APPLY)
|
||||||
|
self.assertEqual(self.names(), [name for name, _ in CASES])
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
class ResilienceTests(CommandTestMixin, TestCase):
|
||||||
|
"""قفل، بازیابی پس از crash، hash، اتمیک بودن و retry قفل دیتابیس."""
|
||||||
|
|
||||||
|
APPLY = {"apply": True, "confirm_production": True}
|
||||||
|
|
||||||
|
def _job(self, **overrides):
|
||||||
|
values = {
|
||||||
|
"source_hash": sha256_of(self.json_path),
|
||||||
|
"algorithm_version": ALGORITHM_VERSION,
|
||||||
|
"status": CustomerNameFixJob.Status.PAUSED,
|
||||||
|
"batch_size": 3,
|
||||||
|
"initial_max_pk": self.max_pk(),
|
||||||
|
"last_processed_pk": 0,
|
||||||
|
"stats": {},
|
||||||
|
"heartbeat_at": timezone.now(),
|
||||||
|
}
|
||||||
|
values.update(overrides)
|
||||||
|
return CustomerNameFixJob.objects.create(**values)
|
||||||
|
|
||||||
|
def test_concurrent_run_is_blocked(self):
|
||||||
|
self._job(status=CustomerNameFixJob.Status.RUNNING,
|
||||||
|
heartbeat_at=timezone.now())
|
||||||
|
with self.assertRaises(CommandError):
|
||||||
|
self.run_command(**self.APPLY)
|
||||||
|
self.assertEqual(self.names(), [name for name, _ in CASES])
|
||||||
|
|
||||||
|
def test_stale_running_lock_is_recovered_and_resumed(self):
|
||||||
|
stale = self._job(status=CustomerNameFixJob.Status.RUNNING,
|
||||||
|
heartbeat_at=timezone.now() - LOCK_TTL - timedelta(minutes=1))
|
||||||
|
self.run_command(**self.APPLY)
|
||||||
|
stale.refresh_from_db()
|
||||||
|
self.assertEqual(stale.status, CustomerNameFixJob.Status.COMPLETED)
|
||||||
|
self.assertEqual(self.names(), self.expected_names())
|
||||||
|
|
||||||
|
def test_resume_continues_from_checkpoint(self):
|
||||||
|
first_pk = self.customers[0].pk
|
||||||
|
job = self._job(last_processed_pk=first_pk)
|
||||||
|
self.run_command(resume=job.pk, **self.APPLY)
|
||||||
|
|
||||||
|
names = self.names()
|
||||||
|
# مشتری قبل از checkpoint دستنخورده میماند
|
||||||
|
self.assertEqual(names[0], "محمد یاوری")
|
||||||
|
self.assertEqual(names[1:], self.expected_names()[1:])
|
||||||
|
job.refresh_from_db()
|
||||||
|
self.assertEqual(job.status, CustomerNameFixJob.Status.COMPLETED)
|
||||||
|
self.assertEqual(job.last_processed_pk, self.max_pk())
|
||||||
|
self.assertEqual(job.stats["scanned"], len(CASES) - 1)
|
||||||
|
|
||||||
|
def test_resume_with_different_hash_is_refused(self):
|
||||||
|
job = self._job(source_hash="0" * 64)
|
||||||
|
with self.assertRaises(CommandError):
|
||||||
|
self.run_command(resume=job.pk, **self.APPLY)
|
||||||
|
self.assertEqual(self.names(), [name for name, _ in CASES])
|
||||||
|
job.refresh_from_db()
|
||||||
|
self.assertEqual(job.status, CustomerNameFixJob.Status.PAUSED)
|
||||||
|
|
||||||
|
def test_unknown_resume_id_is_refused(self):
|
||||||
|
with self.assertRaises(CommandError):
|
||||||
|
self.run_command(resume=999999, **self.APPLY)
|
||||||
|
self.assertEqual(self.names(), [name for name, _ in CASES])
|
||||||
|
|
||||||
|
def test_batch_failure_rolls_back_names_and_checkpoint_atomically(self):
|
||||||
|
original = QuerySet.bulk_update
|
||||||
|
calls = itertools.count(1)
|
||||||
|
|
||||||
|
def flaky(self, objs, fields, batch_size=None):
|
||||||
|
if next(calls) == 2:
|
||||||
|
raise RuntimeError("boom")
|
||||||
|
return original(self, objs, fields, batch_size)
|
||||||
|
|
||||||
|
with mock.patch.object(QuerySet, "bulk_update", flaky):
|
||||||
|
with self.assertRaises(CommandError):
|
||||||
|
self.run_command(batch_size=1, **self.APPLY)
|
||||||
|
|
||||||
|
names = self.names()
|
||||||
|
# batch اول (مشتری ۱) commit شده، batch دوم (مشتری ۲) rollback شده است.
|
||||||
|
self.assertEqual(names[0], "آقای محمد یاوری")
|
||||||
|
self.assertEqual(names[1], "مریم احمدی")
|
||||||
|
|
||||||
|
job = CustomerNameFixJob.objects.get()
|
||||||
|
self.assertEqual(job.status, CustomerNameFixJob.Status.FAILED)
|
||||||
|
# checkpoint دقیقاً روی پایان batch اول است ⇒ ادامه بدون تکرار/پرت شدن داده.
|
||||||
|
self.assertEqual(job.last_processed_pk, self.customers[0].pk)
|
||||||
|
|
||||||
|
def test_locked_database_is_retried_then_succeeds(self):
|
||||||
|
original = QuerySet.bulk_update
|
||||||
|
calls = itertools.count(1)
|
||||||
|
|
||||||
|
def flaky(self, objs, fields, batch_size=None):
|
||||||
|
if next(calls) == 1:
|
||||||
|
raise OperationalError("database is locked")
|
||||||
|
return original(self, objs, fields, batch_size)
|
||||||
|
|
||||||
|
with mock.patch.object(QuerySet, "bulk_update", flaky), \
|
||||||
|
mock.patch("admin_panel.management.commands.fix_customer_names.time.sleep"):
|
||||||
|
self.run_command(**self.APPLY)
|
||||||
|
|
||||||
|
self.assertEqual(self.names(), self.expected_names())
|
||||||
|
self.assertEqual(CustomerNameFixJob.objects.get().status,
|
||||||
|
CustomerNameFixJob.Status.COMPLETED)
|
||||||
|
|
||||||
|
def test_retry_is_bounded_and_surfaces_error(self):
|
||||||
|
calls = itertools.count(1)
|
||||||
|
attempts = []
|
||||||
|
|
||||||
|
def always_locked(self, objs, fields, batch_size=None):
|
||||||
|
attempts.append(next(calls))
|
||||||
|
raise OperationalError("database is locked")
|
||||||
|
|
||||||
|
with mock.patch.object(QuerySet, "bulk_update", always_locked), \
|
||||||
|
mock.patch("admin_panel.management.commands.fix_customer_names.time.sleep"):
|
||||||
|
with self.assertRaises(CommandError):
|
||||||
|
self.run_command(**self.APPLY)
|
||||||
|
|
||||||
|
self.assertEqual(len(attempts), MAX_DB_RETRIES) # retry نامحدود نیست
|
||||||
|
self.assertEqual(self.names(), [name for name, _ in CASES])
|
||||||
|
self.assertEqual(CustomerNameFixJob.objects.get().status,
|
||||||
|
CustomerNameFixJob.Status.FAILED)
|
||||||
|
|
||||||
|
def test_batches_cover_all_customers_across_sizes(self):
|
||||||
|
for batch_size in (1, 2, 5, 200):
|
||||||
|
with self.subTest(batch_size=batch_size):
|
||||||
|
CustomerNameFixJob.objects.all().delete()
|
||||||
|
for customer, (name, _) in zip(self.customers, CASES):
|
||||||
|
Customer.objects.filter(pk=customer.pk).update(name_family=name)
|
||||||
|
self.run_command(batch_size=batch_size, new_job=True, **self.APPLY)
|
||||||
|
self.assertEqual(self.names(), self.expected_names())
|
||||||
|
|
||||||
@@ -0,0 +1,383 @@
|
|||||||
|
"""تستهای دستور ``normalize_customer_sms_phones``.
|
||||||
|
|
||||||
|
همهٔ تستها روی دیتابیس تستی Django اجرا میشوند؛ نه به ``data9/kt.sqlite3`` دست
|
||||||
|
میزنند و نه فایل وضعیت داخل ریشهٔ پروژه مینویسند (مسیر وضعیت موقت است).
|
||||||
|
"""
|
||||||
|
import io
|
||||||
|
import json
|
||||||
|
import shutil
|
||||||
|
import tempfile
|
||||||
|
from pathlib import Path
|
||||||
|
from unittest import mock
|
||||||
|
|
||||||
|
from django.core.management import call_command
|
||||||
|
from django.core.management.base import CommandError
|
||||||
|
from django.db.models.query import QuerySet
|
||||||
|
from django.test import TestCase
|
||||||
|
|
||||||
|
from admin_panel.models import Customer, PhoneNumber
|
||||||
|
from admin_panel.management.commands.normalize_customer_sms_phones import (
|
||||||
|
FIELD_MAX_LENGTH, digit_string, is_landline)
|
||||||
|
from admin_panel.sms.recipients import resolve_recipients
|
||||||
|
|
||||||
|
from .factories import make_customer
|
||||||
|
|
||||||
|
|
||||||
|
def make_phone(customer, number, title='تلفن'):
|
||||||
|
return PhoneNumber.objects.create(
|
||||||
|
customer=customer, title=title, phone_number=number)
|
||||||
|
|
||||||
|
|
||||||
|
class PureHelperTests(TestCase):
|
||||||
|
"""توابع کمکی اعتبارسنجی/تفکیک (بدون دیتابیس واقعی)."""
|
||||||
|
|
||||||
|
def test_digit_string_converts_persian_and_arabic_digits(self):
|
||||||
|
self.assertEqual(digit_string('۰۹۱۲-۳۴۵'), '0912345')
|
||||||
|
self.assertEqual(digit_string('٠٩١٢ ٣٤٥'), '0912345')
|
||||||
|
self.assertEqual(digit_string('(0912) 345'), '0912345')
|
||||||
|
self.assertEqual(digit_string(None), '')
|
||||||
|
self.assertEqual(digit_string(' '), '')
|
||||||
|
|
||||||
|
def test_landline_detection(self):
|
||||||
|
for raw in ('02112345678', '۰۲۱۱۲۳۴۵۶۷۸', '031-33445566', '05138490000',
|
||||||
|
'57387608', '57473005'): # ۸رقمی = ثابت محلی
|
||||||
|
self.assertTrue(is_landline(raw), msg=raw)
|
||||||
|
for raw in ('09123456789', '9123456789', '12345', '', None,
|
||||||
|
'+989123456789', '0012345678'):
|
||||||
|
self.assertFalse(is_landline(raw), msg=repr(raw))
|
||||||
|
|
||||||
|
|
||||||
|
class CommandTestCase(TestCase):
|
||||||
|
"""زیرساخت مشترک: دیتابیس تستی + فایل وضعیت موقت."""
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
super().setUp()
|
||||||
|
self.tmpdir = tempfile.mkdtemp(prefix='smsnorm-')
|
||||||
|
self.addCleanup(shutil.rmtree, self.tmpdir, ignore_errors=True)
|
||||||
|
self.checkpoint = Path(self.tmpdir) / '.sms_normalization_checkpoint.json'
|
||||||
|
|
||||||
|
def run_cmd(self, **options):
|
||||||
|
options.setdefault('checkpoint', str(self.checkpoint))
|
||||||
|
options.setdefault('stdout', io.StringIO())
|
||||||
|
call_command('normalize_customer_sms_phones', **options)
|
||||||
|
return options['stdout'].getvalue()
|
||||||
|
|
||||||
|
def stored(self):
|
||||||
|
return dict(Customer.objects.values_list('id', 'sms_primary_phones'))
|
||||||
|
|
||||||
|
|
||||||
|
class DryRunTests(CommandTestCase):
|
||||||
|
"""پیشفرض: هیچ دیتایی در دیتابیس یا فایل وضعیت نوشته نمیشود."""
|
||||||
|
|
||||||
|
def test_dry_run_writes_nothing(self):
|
||||||
|
customer = make_customer('خشکی')
|
||||||
|
Customer.objects.filter(pk=customer.pk).update(
|
||||||
|
phone1='09120000001', sms_primary_phones='+989120000009')
|
||||||
|
make_phone(customer, '09120000002')
|
||||||
|
|
||||||
|
out = self.run_cmd()
|
||||||
|
customer.refresh_from_db()
|
||||||
|
self.assertEqual(customer.sms_primary_phones, '+989120000009')
|
||||||
|
self.assertFalse(self.checkpoint.exists())
|
||||||
|
self.assertIn('dry-run', out)
|
||||||
|
self.assertIn('قابلبهروزرسانی', out)
|
||||||
|
|
||||||
|
def test_dry_run_reports_counts(self):
|
||||||
|
customer = make_customer('گزارش')
|
||||||
|
Customer.objects.filter(pk=customer.pk).update(phone1='09120000001')
|
||||||
|
PhoneNumber.objects.create(
|
||||||
|
customer=customer, title='t', phone_number='02112345678')
|
||||||
|
out = self.run_cmd()
|
||||||
|
self.assertIn('مشتریان بررسیشده: 1', out)
|
||||||
|
self.assertIn('شمارههای فیلترشده: ثابت=1', out)
|
||||||
|
def test_dry_run_flag_conflicts_with_apply(self):
|
||||||
|
customer = make_customer('تناقض فلگ')
|
||||||
|
with self.assertRaises(CommandError):
|
||||||
|
self.run_cmd(dry_run=True, apply=True)
|
||||||
|
customer.refresh_from_db()
|
||||||
|
self.assertEqual(customer.sms_primary_phones, '')
|
||||||
|
|
||||||
|
def test_dry_run_ignores_reset_checkpoint(self):
|
||||||
|
self.checkpoint.write_text(json.dumps({'last_id': 5}), encoding='utf-8')
|
||||||
|
self.run_cmd(reset_checkpoint=True)
|
||||||
|
self.assertTrue(self.checkpoint.exists())
|
||||||
|
|
||||||
|
|
||||||
|
class ApplyTests(CommandTestCase):
|
||||||
|
"""اجرای واقعی: تجمیع، حذف تکراری، فیلتر نامعتبر، ایمنی داده."""
|
||||||
|
|
||||||
|
def test_aggregates_all_sources_in_priority_order(self):
|
||||||
|
customer = make_customer('چند منبع')
|
||||||
|
Customer.objects.filter(pk=customer.pk).update(
|
||||||
|
phone1='09120000001', phone2='09120000002', phone3='09120000003')
|
||||||
|
PhoneNumber.objects.create(customer=customer, title='a', phone_number='09120000004')
|
||||||
|
PhoneNumber.objects.create(customer=customer, title='b', phone_number='09120000005')
|
||||||
|
|
||||||
|
self.run_cmd(apply=True)
|
||||||
|
customer.refresh_from_db()
|
||||||
|
self.assertEqual(
|
||||||
|
customer.sms_primary_phones,
|
||||||
|
'+989120000001,+989120000002,+989120000003,+989120000004,+989120000005',
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_dedupes_preserving_priority_order(self):
|
||||||
|
customer = make_customer('تکراری')
|
||||||
|
Customer.objects.filter(pk=customer.pk).update(
|
||||||
|
phone1='09120000001', phone2='989120000001') # همان شماره
|
||||||
|
PhoneNumber.objects.create(
|
||||||
|
customer=customer, title='a', phone_number='+98 912 000 0002')
|
||||||
|
PhoneNumber.objects.create(
|
||||||
|
customer=customer, title='b', phone_number='۰۹۱۲۰۰۰۰۰۰۲') # تکراری
|
||||||
|
|
||||||
|
out = self.run_cmd(apply=True)
|
||||||
|
customer.refresh_from_db()
|
||||||
|
self.assertEqual(customer.sms_primary_phones,
|
||||||
|
'+989120000001,+989120000002')
|
||||||
|
self.assertIn('شمارههای تکراری حذفشده: 2', out)
|
||||||
|
|
||||||
|
def test_converts_international_and_local_variants(self):
|
||||||
|
customer = make_customer('قالبها')
|
||||||
|
Customer.objects.filter(pk=customer.pk).update(phone1='+989120000001')
|
||||||
|
PhoneNumber.objects.create(customer=customer, title='a', phone_number='00989120000002')
|
||||||
|
PhoneNumber.objects.create(customer=customer, title='b', phone_number='989120000003')
|
||||||
|
PhoneNumber.objects.create(customer=customer, title='c', phone_number='9120000004')
|
||||||
|
PhoneNumber.objects.create(customer=customer, title='d', phone_number='0912-000-0005')
|
||||||
|
|
||||||
|
self.run_cmd(apply=True)
|
||||||
|
customer.refresh_from_db()
|
||||||
|
self.assertEqual(
|
||||||
|
customer.sms_primary_phones,
|
||||||
|
'+989120000001,+989120000002,+989120000003,+989120000004,+989120000005',
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_filters_landlines_and_invalid_values(self):
|
||||||
|
customer = make_customer('نامعتبر')
|
||||||
|
Customer.objects.filter(pk=customer.pk).update(
|
||||||
|
phone1='09120000001', phone2='02112345678', phone3='0')
|
||||||
|
PhoneNumber.objects.create(customer=customer, title='a', phone_number='57387608')
|
||||||
|
PhoneNumber.objects.create(customer=customer, title='b', phone_number='اعظمی')
|
||||||
|
PhoneNumber.objects.create(customer=customer, title='c', phone_number='')
|
||||||
|
|
||||||
|
out = self.run_cmd(apply=True)
|
||||||
|
customer.refresh_from_db()
|
||||||
|
self.assertEqual(customer.sms_primary_phones, '+989120000001')
|
||||||
|
self.assertIn('ثابت=2', out) # 02112345678 + 57387608
|
||||||
|
self.assertIn('نامعتبر=2', out) # '0' + 'اعظمی' (ردیف خالی شمرده نمیشود)
|
||||||
|
|
||||||
|
def test_source_fields_and_phone_rows_are_untouched(self):
|
||||||
|
customer = make_customer('ایزوله', address='تهران')
|
||||||
|
Customer.objects.filter(pk=customer.pk).update(
|
||||||
|
phone1='09120000001', phone2='02112345678')
|
||||||
|
row = make_phone(customer, '09120000002', title='موبایل')
|
||||||
|
|
||||||
|
self.run_cmd(apply=True)
|
||||||
|
customer.refresh_from_db()
|
||||||
|
row.refresh_from_db()
|
||||||
|
self.assertEqual(customer.phone1, '09120000001')
|
||||||
|
self.assertEqual(customer.phone2, '02112345678')
|
||||||
|
self.assertEqual(customer.name_family, 'ایزوله')
|
||||||
|
self.assertEqual(customer.address, 'تهران')
|
||||||
|
self.assertEqual(row.phone_number, '09120000002')
|
||||||
|
self.assertEqual(row.title, 'موبایل')
|
||||||
|
self.assertEqual(PhoneNumber.objects.filter(customer=customer).count(), 1)
|
||||||
|
|
||||||
|
def test_unchanged_rows_are_not_rewritten(self):
|
||||||
|
customer = make_customer('بدون تغییر')
|
||||||
|
Customer.objects.filter(pk=customer.pk).update(
|
||||||
|
phone1='09120000001', sms_primary_phones='+989120000001')
|
||||||
|
|
||||||
|
writes = []
|
||||||
|
original = QuerySet.bulk_update
|
||||||
|
|
||||||
|
def spy(self, objs, fields, batch_size=None):
|
||||||
|
writes.append(len(objs))
|
||||||
|
return original(self, objs, fields, batch_size)
|
||||||
|
|
||||||
|
with mock.patch.object(QuerySet, 'bulk_update', spy):
|
||||||
|
out = self.run_cmd(apply=True)
|
||||||
|
self.assertEqual(writes, []) # هیچ نوشتنی رخ نداد
|
||||||
|
customer.refresh_from_db()
|
||||||
|
self.assertEqual(customer.sms_primary_phones, '+989120000001')
|
||||||
|
self.assertIn('بدون تغییر: 1', out)
|
||||||
|
|
||||||
|
def test_empty_result_never_erases_existing_value(self):
|
||||||
|
customer = make_customer('محافظت')
|
||||||
|
Customer.objects.filter(pk=customer.pk).update(
|
||||||
|
phone1='02112345678', sms_primary_phones='+989120000009')
|
||||||
|
|
||||||
|
out = self.run_cmd(apply=True)
|
||||||
|
customer.refresh_from_db()
|
||||||
|
self.assertEqual(customer.sms_primary_phones, '+989120000009')
|
||||||
|
self.assertIn('محافظت شد', out)
|
||||||
|
|
||||||
|
def test_idempotent_second_run_updates_nothing(self):
|
||||||
|
customer = make_customer('تکرار اجرا')
|
||||||
|
Customer.objects.filter(pk=customer.pk).update(phone1='09120000001')
|
||||||
|
|
||||||
|
self.run_cmd(apply=True)
|
||||||
|
first = self.stored()
|
||||||
|
out = self.run_cmd(apply=True, reset_checkpoint=True)
|
||||||
|
self.assertEqual(self.stored(), first)
|
||||||
|
self.assertIn('مشتریان بهروزشده: 0', out)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
class OverflowTests(CommandTestCase):
|
||||||
|
"""سرریز طول: truncate نمیشود، رکورد دستنخورده میماند و در گزارش میآید."""
|
||||||
|
|
||||||
|
def test_overflow_leaves_customer_untouched(self):
|
||||||
|
numbers = ['09120000%03d' % i for i in range(1, 11)] # 10×13 = 139 > 100
|
||||||
|
customer = make_customer('سرریز')
|
||||||
|
Customer.objects.filter(pk=customer.pk).update(
|
||||||
|
phone1=numbers[0], phone2=numbers[1])
|
||||||
|
for number in numbers[2:]:
|
||||||
|
PhoneNumber.objects.create(customer=customer, title='t', phone_number=number)
|
||||||
|
|
||||||
|
out = self.run_cmd(apply=True)
|
||||||
|
customer.refresh_from_db()
|
||||||
|
self.assertEqual(customer.sms_primary_phones, '')
|
||||||
|
self.assertIn(f'سرریز طول (>{FIELD_MAX_LENGTH} کاراکتر، دستنخورده): 1', out)
|
||||||
|
self.assertIn(f'شناسههای سرریز: {customer.id}', out)
|
||||||
|
|
||||||
|
def test_exactly_under_max_length_is_saved(self):
|
||||||
|
# 7 شماره × 13 کاراکتر + 6 کاما = 97 ≤ 100
|
||||||
|
numbers = ['09120000%03d' % i for i in range(1, 8)]
|
||||||
|
customer = make_customer('لبه طول')
|
||||||
|
Customer.objects.filter(pk=customer.pk).update(phone1=numbers[0])
|
||||||
|
for number in numbers[1:]:
|
||||||
|
PhoneNumber.objects.create(customer=customer, title='t', phone_number=number)
|
||||||
|
|
||||||
|
out = self.run_cmd(apply=True)
|
||||||
|
customer.refresh_from_db()
|
||||||
|
self.assertEqual(len(customer.sms_primary_phones), 97)
|
||||||
|
self.assertEqual(customer.sms_primary_phones.count(','), 6)
|
||||||
|
self.assertIn('سرریز طول', out)
|
||||||
|
|
||||||
|
|
||||||
|
class BatchAndCheckpointTests(CommandTestCase):
|
||||||
|
"""keyset pagination، batch اتمیک، و بازیابی از فایل وضعیت."""
|
||||||
|
|
||||||
|
def _customers_with_phone(self, count):
|
||||||
|
pks = []
|
||||||
|
for i in range(count):
|
||||||
|
customer = make_customer(f'مشتری {i}')
|
||||||
|
Customer.objects.filter(pk=customer.pk).update(phone1='09120%06d' % (i + 1))
|
||||||
|
pks.append(customer.pk)
|
||||||
|
return pks
|
||||||
|
|
||||||
|
|
||||||
|
def test_all_customers_covered_with_small_batches(self):
|
||||||
|
pks = self._customers_with_phone(7)
|
||||||
|
out = self.run_cmd(apply=True, batch_size=2)
|
||||||
|
self.assertEqual(
|
||||||
|
Customer.objects.filter(pk__in=pks).exclude(sms_primary_phones='').count(), 7)
|
||||||
|
self.assertIn('batchهای تکمیلشده: 4', out) # ۷ مشتری / ۲ = ۴ batch
|
||||||
|
|
||||||
|
def test_checkpoint_written_after_each_batch(self):
|
||||||
|
self._customers_with_phone(5)
|
||||||
|
self.run_cmd(apply=True, batch_size=2)
|
||||||
|
data = json.loads(self.checkpoint.read_text(encoding='utf-8'))
|
||||||
|
self.assertEqual(data['last_id'], Customer.objects.order_by('-id').first().pk)
|
||||||
|
self.assertEqual(data['batches'], 3)
|
||||||
|
self.assertTrue(data['applied'])
|
||||||
|
|
||||||
|
def test_resume_continues_from_checkpoint(self):
|
||||||
|
pks = self._customers_with_phone(4)
|
||||||
|
self.checkpoint.write_text(json.dumps({'last_id': pks[1]}), encoding='utf-8')
|
||||||
|
|
||||||
|
self.run_cmd(apply=True, resume=True)
|
||||||
|
self.assertEqual(Customer.objects.get(pk=pks[0]).sms_primary_phones, '')
|
||||||
|
self.assertEqual(
|
||||||
|
Customer.objects.filter(pk__in=pks[2:]).exclude(sms_primary_phones='').count(), 2)
|
||||||
|
|
||||||
|
def test_resume_without_checkpoint_fails(self):
|
||||||
|
with self.assertRaises(CommandError):
|
||||||
|
self.run_cmd(apply=True, resume=True)
|
||||||
|
|
||||||
|
def test_resume_with_invalid_checkpoint_fails(self):
|
||||||
|
self.checkpoint.write_text('{not json', encoding='utf-8')
|
||||||
|
with self.assertRaises(CommandError):
|
||||||
|
self.run_cmd(apply=True, resume=True)
|
||||||
|
|
||||||
|
def test_start_id_takes_precedence(self):
|
||||||
|
pks = self._customers_with_phone(4)
|
||||||
|
self.checkpoint.write_text(json.dumps({'last_id': 0}), encoding='utf-8')
|
||||||
|
self.run_cmd(apply=True, resume=True, start_id=pks[2])
|
||||||
|
self.assertEqual(Customer.objects.get(pk=pks[0]).sms_primary_phones, '')
|
||||||
|
self.assertEqual(Customer.objects.get(pk=pks[3]).sms_primary_phones,
|
||||||
|
'+989120000004')
|
||||||
|
|
||||||
|
def test_reset_checkpoint_restarts_from_beginning(self):
|
||||||
|
self._customers_with_phone(3)
|
||||||
|
self.run_cmd(apply=True, batch_size=1)
|
||||||
|
self.checkpoint.write_text(json.dumps({'last_id': 10**9}), encoding='utf-8')
|
||||||
|
self.run_cmd(apply=True, reset_checkpoint=True, batch_size=1)
|
||||||
|
data = json.loads(self.checkpoint.read_text(encoding='utf-8'))
|
||||||
|
self.assertEqual(data['batches'], 3) # از ابتدا شروع شد
|
||||||
|
|
||||||
|
def test_batch_failure_keeps_previous_batches_and_checkpoint(self):
|
||||||
|
pks = self._customers_with_phone(3)
|
||||||
|
original = QuerySet.bulk_update
|
||||||
|
calls = iter([1, 2])
|
||||||
|
|
||||||
|
def flaky(self, objs, fields, batch_size=None):
|
||||||
|
if next(calls, 2) == 2:
|
||||||
|
raise RuntimeError('boom')
|
||||||
|
return original(self, objs, fields, batch_size)
|
||||||
|
|
||||||
|
with mock.patch.object(QuerySet, 'bulk_update', flaky):
|
||||||
|
with self.assertRaises(CommandError):
|
||||||
|
self.run_cmd(apply=True, batch_size=1)
|
||||||
|
|
||||||
|
# batch اول commit شده، batch دوم rollback شده، مشتری سوم دستنخورده
|
||||||
|
self.assertEqual(Customer.objects.get(pk=pks[0]).sms_primary_phones,
|
||||||
|
'+989120000001')
|
||||||
|
self.assertEqual(Customer.objects.get(pk=pks[1]).sms_primary_phones, '')
|
||||||
|
self.assertEqual(Customer.objects.get(pk=pks[2]).sms_primary_phones, '')
|
||||||
|
data = json.loads(self.checkpoint.read_text(encoding='utf-8'))
|
||||||
|
self.assertEqual(data['last_id'], pks[0])
|
||||||
|
|
||||||
|
def test_resume_after_failure_completes_remaining(self):
|
||||||
|
pks = self._customers_with_phone(3)
|
||||||
|
original = QuerySet.bulk_update
|
||||||
|
calls = iter([1, 2])
|
||||||
|
|
||||||
|
def flaky(self, objs, fields, batch_size=None):
|
||||||
|
if next(calls, 2) == 2:
|
||||||
|
raise RuntimeError('boom')
|
||||||
|
return original(self, objs, fields, batch_size)
|
||||||
|
|
||||||
|
with mock.patch.object(QuerySet, 'bulk_update', flaky):
|
||||||
|
with self.assertRaises(CommandError):
|
||||||
|
self.run_cmd(apply=True, batch_size=1)
|
||||||
|
|
||||||
|
self.run_cmd(apply=True, resume=True, batch_size=1)
|
||||||
|
for pk in pks:
|
||||||
|
self.assertNotEqual(Customer.objects.get(pk=pk).sms_primary_phones, '')
|
||||||
|
|
||||||
|
|
||||||
|
class IntegrationAndEfficiencyTests(CommandTestCase):
|
||||||
|
"""سازگاری با مسیر پیامک موجود + جلوگیری از N+1."""
|
||||||
|
|
||||||
|
def test_panel_resolution_reads_back_aggregated_numbers(self):
|
||||||
|
customer = make_customer('گیرنده')
|
||||||
|
Customer.objects.filter(pk=customer.pk).update(
|
||||||
|
phone1='09120000001', phone2='09120000002')
|
||||||
|
self.run_cmd(apply=True)
|
||||||
|
customer.refresh_from_db()
|
||||||
|
# قالب ذخیرهشده باید همان چیزی باشد که مسیر پیامک انتظار دارد.
|
||||||
|
self.assertEqual(resolve_recipients(customer),
|
||||||
|
['+989120000001', '+989120000002'])
|
||||||
|
|
||||||
|
def test_query_count_is_constant_per_batch(self):
|
||||||
|
for i in range(3):
|
||||||
|
customer = make_customer(f'کوئری {i}')
|
||||||
|
Customer.objects.filter(pk=customer.pk).update(phone1='09120%06d' % (i + 1))
|
||||||
|
for j in range(5):
|
||||||
|
PhoneNumber.objects.create(
|
||||||
|
customer=customer, title='t',
|
||||||
|
phone_number='09130%06d' % (i * 10 + j))
|
||||||
|
|
||||||
|
with self.assertNumQueries(3): # ۱ کوئری سقف دامنه + ۱ مشتریان + ۱ prefetch
|
||||||
|
self.run_cmd()
|
||||||
|
|
||||||
Reference in new issue
Block a user