fix mobile and names

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

No files matched your search

+6
View File
@@ -56,6 +56,12 @@ RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \
# کپی کردن و واگذاری مالکیت محیط مجازی به کاربر django
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 . .
+13 -6
View File
@@ -1,10 +1,10 @@
Project Root: khatibi
Generated At: 2026-10-11T12:20:45+00:00
Python Files: 171
Generated At: 2026-10-15T06:59:41+00:00
Python Files: 175
Django Files: 12
JavaScript/TypeScript Files: 14
HTML Files: 58
Total Files: 682
Total Files: 689
Total Directories: 76
khatibi
@@ -38,6 +38,7 @@ khatibi
│ ├── management
│ │ ├── commands
│ │ │ ├── __init__.py
│ │ │ ├── fix_customer_names.py
│ │ │ ├── fix_customer_phones.py
│ │ │ ├── gt_esh.py
│ │ │ ├── recalc_last_bazdid.py
@@ -119,6 +120,7 @@ khatibi
│ │ ├── 0069_smstemplate_auto_send.py
│ │ ├── 0070_sms_types_seven.py
│ │ ├── 0071_sms_settings_permission.py
│ │ ├── 0072_customernamefixjob.py
│ │ └── __init__.py
│ ├── models
│ │ ├── __init__.py
@@ -302,6 +304,7 @@ khatibi
│ ├── admin.py
│ ├── apps.py
│ ├── context_processor.py
│ ├── name_fix.py
│ ├── services.py
│ ├── signals.py
│ ├── tests.py
@@ -312,9 +315,7 @@ khatibi
│ ├── db2 (2).sqlite32
│ ├── db2.sqlite32
│ ├── db222.sqlite3
│ ├── kt.sqlite3
│ ├── kt.sqlite3-shm
│ └── kt.sqlite3-wal
│ └── kt.sqlite3
├── graphify-out
│ ├── 2026-10-09
│ │ ├── .graphify_analysis.json
@@ -428,6 +429,7 @@ khatibi
│ │ │ ├── 5d994876cad71133f4bf804932fb2604ffdc8bb8cc434ca1434667ef789144e7.json
│ │ │ ├── 5e19aa3aba5509cd48a9a93b39db071cfc2c2728ccae46b34caa0e214cc6be01.json
│ │ │ ├── 5f050da3769e151e52138ee1ea5dcbcb6160cb57952c75665e607628d0e08d67.json
│ │ │ ├── 5f858ab6d9f90e0f41617273b6f132af3f76f520deafb8d612adce373875e768.json
│ │ │ ├── 60002a01d6fc22147e2994d1a1a447d5e577c02aed2443789af20571ec2d2a19.json
│ │ │ ├── 60e00560038df059b1696e54dcf055cd658677d6e1201570a8500531addc0ac6.json
│ │ │ ├── 62d88411a61ee9d3b60dda6c4febf33539b8b9e8226b5d2b9205f8e2b0c4478a.json
@@ -471,6 +473,7 @@ khatibi
│ │ │ ├── 81d6d00c4c975b3289ac212d355856c3d6e60bccde0780fe2e3fc8cac31f3c19.json
│ │ │ ├── 82833f6235cb6559bad38e93372d42284eb4e9097e90519337266113da2495bf.json
│ │ │ ├── 828dacee98d6f71fcf53c8a051b3bf1bfc9d94c272a79ed8539801e3fb90b3df.json
│ │ │ ├── 82916053b0e3cfa286347ea0a7101f2d06678bd28b8b8d8fe955d11bcb7a43aa.json
│ │ │ ├── 837801d7d0e218e49de562a7e03711e75941d42ce5cc2f77552bc6293a45a4e8.json
│ │ │ ├── 84281ba94e70a71d52b5179cf2e933f8412da3a9eaf5e7177b815f8fadd0afc5.json
│ │ │ ├── 846ba1087a1ee6b5cda2ebdf22d0bc61ad9175b21aba29783429277288dbfaaf.json
@@ -513,6 +516,7 @@ khatibi
│ │ │ ├── b82d5f6840b4930eb62fdb1c7b3a58124fff805e529ede7684516a65322fcae0.json
│ │ │ ├── b9f8ea31056fe0c0fc40a84640c58f29895312c65bf694567487204d8f1170e9.json
│ │ │ ├── bb140cfe12cfb3e32dc9815393035fc33c7649c2faeeccb82cdee56a930ea635.json
│ │ │ ├── bb74cda4073a6da7a4b60fecfb3bb40796a51ceafcbdd128d60e5ce23fa4f319.json
│ │ │ ├── bc24ddb05a8aa685edc1376cbd110423adf05bcc488fb97a30fe286e7e74c2da.json
│ │ │ ├── bc6f93c7938b3565d50cdfc4abe4fd62f0739fd6299f35d7c50075cd860cab58.json
│ │ │ ├── bcea7c584e5031a9c5734144f8169d016ef1ed316e6f4827609509ba2a79d3eb.json
@@ -730,6 +734,7 @@ khatibi
├── tests
│ ├── __init__.py
│ ├── factories.py
│ ├── test_customer_name_fix.py
│ ├── test_customers.py
│ ├── test_factors.py
│ ├── test_inventory.py
@@ -750,9 +755,11 @@ khatibi
├── Dockerfile
├── entrypoint.sh
├── financial_audit_20260816_151225.csv
├── fix_customer_names.md
├── fix_customer_phones.md
├── GEMINI.md
├── ghost_hunter.py
├── iranianNamesDataset.json
├── khatibi.zip
├── local.env
├── 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های اصلاح نام مشتری',
},
),
]
+42
View File
@@ -1,6 +1,48 @@
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):
key = models.CharField(max_length=50, unique=True)
value = models.CharField(max_length=400)
+205
View File
@@ -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")
+642
View File
@@ -0,0 +1,642 @@
# افزودن پیشوند «آقای/خانم» به نام مشتریان
## چرا؟
فیلد `Customer.name_family` (همان «نام» مشتری) سال‌ها بدون پیشوند وارد شده:
`محمد یاوری`، `مریم احمدی`. برای پیامک و گزارش‌ها بهتر است نام با پیشوند
محترمانه بیاید: `آقای محمد یاوری`، `خانم مریم احمدی`.
اما این کار **فقط** وقتی مجاز است که جنسیتِ اولین کلمهٔ نام با اطمینان قابل
تعیین باشد. فایل `iranianNamesDataset.json` (حدود ۲۰ هزار نام، `M`/`F`) همین
مرجع است.
> **قاعدهٔ بنیادین:** اگر کوچک‌ترین ابهامی در تشخیص جنسیت باشد، **هیچ تغییری
> اعمال نمی‌شود.** حدس جنسیت، تطبیق تقریبی (fuzzy) و انتخاب تصادفی ممنوع است.
مسیرهای اصلی:
| جزء | مسیر |
|---|---|
| منطق تصمیم (بدون Django) | `admin_panel/name_fix.py` |
| دستور اجرا | `admin_panel/management/commands/fix_customer_names.py` |
| مدل وضعیت job | `CustomerNameFixJob` در `admin_panel/models/system.py` |
| مهاجرت | `admin_panel/migrations/0072_customernamefixjob.py` |
| تست‌ها | `tests/test_customer_name_fix.py` |
| فایل داده | `iranianNamesDataset.json` در ریشهٔ پروژه (`/app/iranianNamesDataset.json` در کانتینر) |
---
## سریع‌ترین مسیر روی Production (خلاصهٔ اجرایی)
روی VPS، `python` میزبان وجود ندارد؛ همه‌چیز داخل کانتینر `web` اجرا می‌شود:
```bash
cd /path/to/khatibi
# ۱) تأیید وجود فایل داده داخل ایمیج (پیش‌نیاز قطعی این دستور)
docker compose exec web ls -l /app/iranianNamesDataset.json
docker compose exec web sha256sum /app/iranianNamesDataset.json
# ۲) اعمال migration (فقط این دستور به جدول CustomerNameFixJob نیاز دارد)
docker compose run --rm migrate
# ۳) بکاپ دیتابیس
docker compose exec web python -c "
import sqlite3, pathlib, datetime
src = pathlib.Path('/app/sqlite/db.sqlite3')
dst = src.parent / f'db_backup_{datetime.datetime.now():%Y%m%d_%H%M%S}.sqlite3'
s = sqlite3.connect(f'file:{src}?mode=ro', uri=True, timeout=30); d = sqlite3.connect(dst)
s.backup(d, pages=250, sleep=0.05); d.close(); s.close(); print('backup:', dst)"
# ۴) شبیه‌سازی روی دیتابیس واقعی — هیچ نوشتنی ندارد
docker compose exec web python manage.py fix_customer_names --dry-run --batch-size 200 --samples 5
# ۵) اجرای واقعی (هر دو فلگ لازم است)
docker compose exec web python manage.py fix_customer_names --apply --confirm-production --batch-size 200
```
**چرا این ترتیب؟**
| مرحله | چرا |
|---|---|
| ۱ | دستور مسیر پیش‌فرض را از `settings.BASE_DIR` می‌سازد. اگر فایل داخل ایمیج نباشد، دستور با «فایل داده پیدا نشد» رد می‌شود. |
| ۲ | `fix_customer_names` پیش از هر نوشتن، وجود جدول `admin_panel_customernamefixjob` را چک می‌کند و بدون آن خطا می‌دهد. (روی این پروژه `entrypoint.sh` و سرویس `migrate` هم خودشان اجرا می‌کنند، ولی صریح زدن ضرری ندارد.) |
| ۳ | دستور Undo ندارد؛ برگشت فقط با بکاپ ممکن است. |
| ۴ | پیش‌فرض بدون نوشتن است؛ اعداد (خصوصاً تعداد قابل‌اصلاح و نام‌های مبهم) را می‌بینی. |
| ۵ | نوشتن نیازمند تأیید صریح اپراتور است، نه حدس از `DEBUG` یا نام محیط. |
### ✅ فایل JSON داخل ایمیج کپی می‌شود (تضمین‌شده)
`Dockerfile` حالا **صریحاً** این فایل را کپی می‌کند (خط ۶۳):
```dockerfile
COPY --chown=django:django iranianNamesDataset.json /app/iranianNamesDataset.json
```
یعنی مسیر پیش‌فرض دستور (`settings.BASE_DIR` = `/app`) همیشه داخل ایمیج
موجود است و به `COPY . .` یا محتوای `.dockerignore` وابسته نیست.
**عواقب این انتخاب:**
- اگر فایل در build context نباشد، **build شکست می‌خورد** (fail-fast) — این
رفتار مطلوب است: خطا همان‌جا در CI دیده می‌شود، نه بعداً روی سرور با
«فایل داده پیدا نشد».
- روی سرورهای میزبان (VPS/CI) باید فایل در build context حاضر باشد. اگر
Coolify/CI ایمیج را از **گیت** می‌سازد، فایل باید commit شده باشد (الان
`git status` آن را `??` نشان می‌دهد، یعنی هنوز track نشده).
برای تست سریع بدون commit و بدون rebuild:
```bash
# الف) کپی دستی داخل کانتینر در حال اجرا (تا بازسازی بعدی کانتینر پایدار است)
docker compose cp iranianNamesDataset.json web:/app/iranianNamesDataset.json
# ب) bind-mount از میزبان (فایل هرجا باشد، با هر rebuild هم می‌ماند)
# docker-compose.override.yml
# services:
# web:
# volumes:
# - ./iranianNamesDataset.json:/app/iranianNamesDataset.json:ro
docker compose exec web python manage.py fix_customer_names --dry-run \
--json-path /app/iranianNamesDataset.json
```
برای تأیید اینکه ایمیج ساخته‌شده واقعاً فایل را دارد:
```bash
docker compose exec web ls -l /app/iranianNamesDataset.json
docker compose exec web sha256sum /app/iranianNamesDataset.json # باید با hash گزارش دستور یکی باشد
```
> چون jobها با **SHA-256 فایل** شناسایی می‌شوند، فایل باید بایت‌به‌بایت همان
> چیزی باشد که dry-run دید. اگر فایل را عوض کردی، ادامهٔ job قدیمی رد می‌شود و
> باید `--new-job` بدهی.
### اگر وسط کار قطع شد
```bash
# الف) ادامهٔ خودکار: فقط همان دستور را دوباره بزن.
# خودش آخرین job ناتمام با همان hash و همان نسخهٔ الگوریتم را پیدا و ادامه می‌دهد.
docker compose exec web python manage.py fix_customer_names --apply --confirm-production --batch-size 200
# ب) ادامهٔ صریح یک job مشخص
docker compose exec web python manage.py fix_customer_names --apply --confirm-production --resume 3
# ج) اگر شمارهٔ job را نمی‌دانی:
docker compose exec web python manage.py shell -c "
from admin_panel.models import CustomerNameFixJob as J
print(list(J.objects.values('id','status','last_processed_pk','initial_max_pk','stats')))"
```
> اگر job قبلی `completed` باشد، اجرای دوباره **خطا می‌دهد** و خودکار job جدید
> نمی‌سازد (تا داده دوباره دست نخورد). برای اجرای عمدی از صفر: `--new-job`.
### بررسی نتیجه
```bash
docker compose exec web python manage.py shell -c "
from admin_panel.models import Customer
print('آقای:', Customer.objects.filter(name_family__startswith='آقای ').count())
print('خانم:', Customer.objects.filter(name_family__startswith='خانم ').count())"
```
---
**فقط** فیلد `name_family` مشتریان واجد شرایط. هیچ فیلدی به مدل مشتری اضافه
نشده، **هیچ جنسیتی ذخیره نمی‌شود**، و تلفن/آدرس/یادداشت/وضعیت حساب
دست‌نخورده می‌مانند. جدول `PhoneNumber` هم لمس نمی‌شود.
## الگوریتم تصمیم (به همین ترتیب)
| # | بررسی | نتیجه |
|---|---|---|
| ۱ | نام خالی / فقط فاصله / `NULL` | بدون تغییر |
| ۲ | اولین کلمه `آقا`، `آقای` یا `خانم` | بدون تغییر (پیشوند تکرار نمی‌شود) |
| ۳ | نام **تک‌کلمه‌ای** | بدون تغییر (ممکن است فقط فامیل باشد) |
| ۴ | اولین کلمه در فایل پیدا نشود | بدون تغییر |
| ۵ | جنسیت اولین کلمه مبهم/نامعتبر باشد | بدون تغییر |
| ۶ | وگرنه | افزودن قطعی پیشوند |
جست‌وجو **فقط** روی اولین کلمه انجام می‌شود؛ از فامیل و کلمات بعدی برای حدس
استفاده نمی‌شود.
### نمونه‌های رفتاری
| مقدار فعلی | نتیجه |
|---|---|
| `محمد یاوری` | `آقای محمد یاوری` |
| `مریم احمدی` | `خانم مریم احمدی` |
| `یاوری` | بدون تغییر — تک‌کلمه‌ای |
| `محمد` | بدون تغییر — تک‌کلمه‌ای |
| `آقا محمد یاوری` | بدون تغییر |
| `آقای محمد یاوری` | بدون تغییر |
| `خانم مریم احمدی` | بدون تغییر |
| `نام ناشناخته احمدی` | بدون تغییر — ناشناخته |
| `آرش محمدی` | بدون تغییر — جنسیت متناقض در فایل |
| ` محمد یاوری ` | `آقای محمد یاوری` (فاصله‌های سر/ته فقط در این حالت حذف می‌شوند) |
| ` نام ناشناخته احمدی ` | بدون تغییر، **با حفظ فاصله‌های اصلی** |
نکته: حذف فاصله‌های سر/ته به‌تنهایی هیچ‌وقت باعث تغییر نمی‌شود؛ فقط وقتی
تصمیم قطعی به افزودن پیشوند گرفته شد، خروجی از نام `strip()`شده ساخته می‌شود.
فاصله‌های **داخلی** دست‌نخورده می‌مانند (`محمد یاوری` → `آقای محمد یاوری`).
### نرمال‌سازی (فقط برای کلید جست‌وجو)
`NFC` + حذف فاصلهٔ سر/ته + یکسان‌سازی `ي→ی` و `ك→ک`. مقدارِ ذخیره‌شدهٔ مشتری
حرف‌به‌حرف همان مقدار اصلی است (`علي رضایی` → `آقای علي رضایی`).
### برخورد با نام‌های تکراری و متناقض
یک نام فقط وقتی قابل استفاده است که **همهٔ** رکوردهای همان کلید نرمال‌شده
جنسیت معتبر (`M`/`F`) و **یکسان** داشته باشند:
| وضعیت در فایل | تصمیم |
|---|---|
| چند رکورد، همه `M` یا همه `F` | قابل استفاده |
| همان نام هم `M` هم `F` | **مبهم** ⇒ بدون تغییر |
| جنسیت ناشناخته (`D`، `MF`، `FF`، خالی) | **غیرقابل استفاده** ⇒ بدون تغییر |
| رکورد ناقص/`Names` غیر رشته/نام خالی | رد می‌شود؛ کل آن نام کنار گذاشته می‌شود |
| یکی‌شدن دو کلید با جنسیت متناقض پس از نرمال‌سازی | **مبهم** ⇒ بدون تغییر |
«اولین رکورد» یا «پرتکرارترین جنسیت» بدون دلیل معتبر انتخاب نمی‌شود؛ تناقض
یعنی نام کنار گذاشته می‌شود.
وضعیت فایل دادهٔ فعلی پروژه:
```
SHA-256: f5953a216a0b71e59e20249f5bfa6a380a2c3179c6b15e4f0e60616b94948114
رکورد کل: 19881 | کلید قابل‌استفاده: 19870 | کلید غیرقابل‌استفاده: 5
جنسیت نامعتبر: 5 (فروزان/فروزن/فروزنده = D، گوهربانو = FF، مهروز = MF)
نام تکراری: 6، همه هم‌جنسیت (بدون تناقض)
```
## گزینه‌های دستور
| گزینه | پیش‌فرض | کار |
|---|---|---|
| `--json-path` | `iranianNamesDataset.json` در ریشهٔ پروژه | مسیر فایل داده |
| `--batch-size` | `200` | تعداد مشتری در هر batch |
| `--dry-run` | خاموش (اما **رفتار پیش‌فرض همین است**) | گزارش بدون هیچ نوشتنی؛ همراه `--apply` ناسازگار است |
| `--apply` | خاموش | نوشتن واقعی؛ بدون آن اجرا فقط گزارش است |
| `--confirm-production` | خاموش | تأیید صریح اپراتور؛ همراه `--apply` **الزامی** |
| `--resume <JOB_ID>` | — | ادامهٔ یک job مشخص |
| `--new-job` | خاموش | شروع عمدی job جدید، حتی اگر job تکمیل‌شده‌ای برای همین فایل باشد |
| `--samples` | `5` | تعداد نمونهٔ «قبل/بعد» در گزارش |
**پیش‌فرض دستور بدون نوشتن است.** هیچ‌کدام از این‌ها تغییر نمی‌دهند: نام مشتری،
رکورد job، ساختار دیتابیس، فایل JSON. حتی migration هم خودکار اجرا نمی‌شود.
## اجرای محلی (توسعه)
### ۱) اول همیشه شبیه‌سازی
از WSL:
```bash
cd /mnt/d/projects/khatibi
.venv/Scripts/python.exe manage.py fix_customer_names --dry-run --samples 5
```
از Windows (PowerShell / CMD):
```bat
cd D:\projects\khatibi
.venv\Scripts\python.exe manage.py fix_customer_names --dry-run --samples 5
```
خروجی نمونه (دادهٔ واقعی همین پروژه، ۱۰٬۴۲۶ مشتری):
```
مقصد دیتابیس: alias=default engine=django.db.backends.sqlite3
NAME: D:\Projects\khatibi\data9\kt.sqlite3
(فقط نام/مسیر دیتابیس چاپ می‌شود؛ credential چاپ نمی‌شود.)
اعتبارسنجی فایل داده
مسیر: D:\Projects\khatibi\iranianNamesDataset.json
SHA-256: f5953a21…
رکورد کل: 19881 | کلید قابل‌استفاده: 19870 | کلید غیرقابل‌استفاده: 5
ردشده → malformed: 0 | نام خالی: 0 | جنسیت نامعتبر: 5
نام تکراری: 6 (هم‌جنسیت: 6 | متناقض: 0)
گزارش dry-run — هیچ تغییری نوشته نشد
مشتریان بررسی‌شده: 10426
قابل اصلاح / اصلاح‌شده: 7303
بدون تغییر: 3123
تک‌کلمه‌ای: 1576 | دارای پیشوند: 89 | خالی/نامعتبر: 0
ناشناخته در JSON: 1457 | جنسیت مبهم/نامعتبر: 1
batchهای تکمیل‌شده: 53
نمونهٔ تغییرات (قبل → بعد):
«مجتبی سالاری» → «آقای مجتبی سالاری»
«مرتضی محمد باقری » → «آقای مرتضی محمد باقری»
«محمد فصاحت » → «آقای محمد فصاحت»
برای اجرای واقعی: --apply --confirm-production
```
### ۲) اجرای واقعی (فقط روی مقصد دلخواه)
```bash
.venv/Scripts/python.exe manage.py fix_customer_names --apply --confirm-production
```
بدون `--confirm-production`:
```
CommandError: برای نوشتن واقعی، هم --apply و هم --confirm-production لازم است.
```
## اجرا روی سرور Production
**پیش‌نیاز: migration باید اعمال شده باشد.** روی این پروژه migration سرویس وب
خودکار اجرا می‌شود (`entrypoint.sh` و سرویس `migrate` در `docker-compose.yml`)،
ولی برای اطمینان:
```bash
docker compose run --rm migrate # یا: python manage.py migrate
```
اگر جدول job نباشد، دستور **پیش از هر تغییری** خطا می‌دهد و هیچ نامی را
تغییر نمی‌دهد:
```
CommandError: جدول «admin_panel_customernamefixjob» وجود ندارد.
ابتدا migration را اعمال کن: python manage.py migrate
```
### ترتیب دقیق اجرا
خلاصهٔ اجرایی و کامل این ترتیب در بخش
**«سریع‌ترین مسیر روی Production»** بالای همین سند آمده؛ این همان نسخهٔ
جزئی‌تر با تأکید بر فایل داده:
```bash
# ۰) فایل داده داخل کانتینر — تأیید وجود و هش
docker compose exec web ls -l /app/iranianNamesDataset.json
docker compose exec web sha256sum /app/iranianNamesDataset.json
# ۱) اعمال migration (لازم؛ جدول CustomerNameFixJob را می‌سازد)
docker compose run --rm migrate
# ۲) بکاپ دیتابیس (بخش «بکاپ پیش از اجرای واقعی» را ببین)
docker compose exec web python manage.py sqlite_db_backup # نیازمند env های S3
# اگر S3 تنظیم نیست، اسنیپت WAL-safe بخش «سریع‌ترین مسیر» را بزن.
# ۳) شبیه‌سازی روی دیتابیس واقعی — هیچ نوشتنی ندارد
docker compose exec web python manage.py fix_customer_names --dry-run --batch-size 200 --samples 5
# ۴) اگر اعداد منطقی بود: اجرای واقعی
docker compose exec web python manage.py fix_customer_names --apply --confirm-production --batch-size 200
# ۵) بررسی خلاصه و وضعیت job
docker compose exec web python manage.py shell -c "from admin_panel.models import CustomerNameFixJob as J; print(list(J.objects.values('id','status','source_hash','last_processed_pk','initial_max_pk','stats')))"
```
> **چرا `docker compose exec web` و نه `python manage.py`؟** روی VPS پایتون
> پروژه فقط داخل کانتینر است و دیتابیس هم در ولوم `/app/sqlite` همان کانتینر
> قرار دارد. اجرای `python manage.py` روی میزبان یا به دیتابیس اشتباه وصل
> می‌شود یا اصلاً وجود ندارد.
### اطمینان از انتخاب دیتابیس درست
دستور از تنظیمات فعال Django (`DJANGO_SETTINGS_MODULE`) استفاده می‌کند و هیچ
مسیر SQLite ای هاردکد نشده است. پیش از هر کاری، alias + engine + مسیر دیتابیس
را چاپ می‌کند (بدون credential):
```
مقصد دیتابیس: alias=default engine=django.db.backends.sqlite3
NAME: /app/sqlite/db.sqlite3
توجه: ماژول تنظیمات production فعال است.
```
- روی سرور، `DJANGO_SETTINGS_MODULE=khatibi.production_settings` و
`NAME=/app/sqlite/db.sqlite3` (ولوم `khatibi-sqlite-db` در کانتینر) است.
- در توسعه، `khatibi.settings` و `data9/kt.sqlite3`.
- اگر مسیر چاپ‌شده همان چیزی نیست که انتظار داری، **همان‌جا `Ctrl+C` بزن**؛
هیچ داده‌ای تا آن لحظه نوشته نشده است.
- تشخیص production بر پایهٔ نام ماژول تنظیمات است، نه `DEBUG`. (در این پروژه
`production_settings.py` مقدار `DEBUG=True` دارد؛ پس اتکا به `DEBUG` غلط بود و
عمداً از آن استفاده نشده.)
## بکاپ پیش از اجرای واقعی
دستور آمادهٔ همین پروژه روی سرور (لینوکس/کانتینر): `sqlite_db_backup` — از متد
بومی SQLite (`Connection.backup`) با اتصال `mode=ro` استفاده می‌کند و بنابراین با
حالت WAL سازگار است. دو شرط دارد: فقط روی لینوکس اجرا می‌شود (`fcntl`) و
متغیرهای S3 را لازم دارد (`S3_ACCESS_KEY`, `S3_SECRET_KEY`, `S3_ENDPOINT`,
`S3_BUCKET`).
```bash
# داخل کانتینر (لینوکس) — نتیجه در S3 ذخیره و اعتبارسنجی می‌شود
docker compose exec web python manage.py sqlite_db_backup
```
اگر S3 در دسترس نیست یا روی ویندوز/WSL کار می‌کنی، همین بکاپ WAL-safe را
مستقیم بگیر (همه‌جا کار می‌کند و به env نیاز ندارد):
```bash
# WSL
.venv/Scripts/python.exe - <<'PY'
import sqlite3, pathlib, datetime
src = pathlib.Path('data9/kt.sqlite3')
dst = src.parent / f"db_backup_{datetime.datetime.now():%Y%m%d_%H%M%S}.sqlite3"
s = sqlite3.connect(f"file:{src}?mode=ro", uri=True, timeout=30)
d = sqlite3.connect(dst)
s.backup(d, pages=250, sleep=0.05) # کپی سطح C، سازگار با WAL
d.close(); s.close()
print("backup:", dst, dst.stat().st_size, "bytes")
PY
```
> **کپی سادهٔ فایل SQLite در حین اجرای برنامه بکاپ معتبر نیست.** دیتابیس در حالت
> `WAL` اجرا می‌شود (`entrypoint.sh`)؛ اگر فقط `db.sqlite3` را `cp` کنی،
> تراکنش‌های موجود در `db.sqlite3-wal` از دست می‌روند و ممکن است فایل خراب
> بازیابی شود. اگر دستی بکاپ می‌گیری، باید **هر سه فایل** (`db.sqlite3`،
> `-wal`، `-shm`) در لحظهٔ بدون نوشتن کپی شوند — و بهترین راه همان
> `sqlite3.Connection.backup` بالا است.
نکات مکمل:
- فضای دیسک: قبل از بکاپ حداقل ~۱.۵ برابر حجم دیتابیس خالی باشد
(دیتابیس فعلی ≈۷۹ مگابایت).
- وضعیت WAL: `PRAGMA journal_mode;` باید `wal` برگرداند.
- دسترسی فایل: کاربر اجراکنندهٔ کانتینر باید روی `/app/sqlite` و روی فایل JSON
دسترسی خواندن/نوشتن داشته باشد.
- فایل `iranianNamesDataset.json` **هرگز** تغییر داده، بازنویسی یا حذف نمی‌شود؛
دستور فقط آن را می‌خواند.
## ادامهٔ job پس از crash / قطع برق
هر batch شامل سه چیز است که **در یک تراکنش اتمیک** ثبت می‌شوند:
1. تغییرات `name_family` مشتریان واجد شرایط همان batch،
2. `last_processed_pk` (checkpoint)،
3. `heartbeat_at` و آمار تجمیعی.
پس یا کل batch ثبت شده یا هیچ‌کدام. دامنهٔ job در لحظهٔ آغاز با بزرگ‌ترین
primary key مشتریان قفل می‌شود (`initial_max_pk`)؛ مشتریان جدیدتر وارد دامنه
نمی‌شوند.
### گزینه‌های ادامه
| وضعیت | رفتار پیش‌فرض | راه‌حل |
|---|---|---|
| job ناتمام با همان فایل و همان نسخهٔ الگوریتم | **خودکار ادامه می‌یابد** | فقط همان دستور `--apply --confirm-production` را دوباره بزن |
| می‌خواهی صریحاً همان job را ادامه دهی | — | `--apply --confirm-production --resume <JOB_ID>` |
| job قبلی `completed` است | خطا؛ خودکار job جدید نمی‌سازد | `--apply --confirm-production --new-job` |
| فایل عوض شده (hash متفاوت) | **توقف، بدون هیچ تغییری** | `--new-job` با فایل جدید |
| نسخهٔ الگوریتم عوض شده | **توقف** | `--new-job` |
پیام خطاهای مربوطه:
```
CommandError: hash فایل ورودی با job#3 نمی‌خواند؛ ادامه رد شد و هیچ تغییری اعمال نمی‌شود.
CommandError: job#3 قبلاً تکمیل شده است؛ برای اجرای دوباره --new-job بده.
CommandError: job#3 همین حالا در حال اجراست (heartbeat تازه). اجرای هم‌زمان مجاز نیست.
```
### دستورهای دقیق ادامه / شروع مجدد
```bash
# ادامهٔ خودکار آخرین job ناتمامِ همین فایل (ساده‌ترین راه)
python manage.py fix_customer_names --apply --confirm-production
# ادامهٔ صریح یک job مشخص
python manage.py fix_customer_names --apply --confirm-production --resume 3
# شروع عمدی job جدید (پس از تکمیل job قبلی یا با فایل جدید)
python manage.py fix_customer_names --apply --confirm-production --new-job
# شروع job جدید با فایل دیگر و حجم batch کوچک‌تر (ساعات پرترافیک)
python manage.py fix_customer_names --apply --confirm-production --new-job \
--json-path /app/data/iranianNamesDataset_v2.json --batch-size 50
```
اجرای دوبارهٔ بی‌خطر: با `--new-job` روی دادهٔ اصلاح‌شده، `changed = 0` می‌شود،
چون نام‌های دارای پیشوند دیگر رد می‌شوند.
## قفل و اجرای هم‌زمان
- جلوگیری از اجرای هم‌زمان با **heartbeat** انجام می‌شود، نه `select_for_update()`.
(روی SQLite قفل ردیفی واقعی وجود ندارد؛ اتکا به `select_for_update` گمراه‌کننده بود.)
- هر batch مقدار `heartbeat_at` را به‌روز می‌کند. jobی که `status=running` دارد و
heartbeat آن تازه است (< `LOCK_TTL = 15 دقیقه`)، اجرای دوم را مسدود می‌کند.
- اگر سرور وسط کار خاموش شود، heartbeat کهنه می‌ماند و اجرای بعدی قفل را
**به‌صورت کنترل‌شده آزاد می‌کند** و به‌عنوان `failed` علامت می‌زند:
```
job#1 ناتمام رها شده بود و اکنون به‌عنوان failed آزاد شد.
```
پس از آن، همان دستور از `last_processed_pk` ادامه می‌دهد. قفل، دائمی نمی‌شود.
## SQLite، قفل دیتابیس و بار روی سایت
- batchهای کوچک (`--batch-size 200`) با تراکنش‌های کوتاه؛ هیچ تراکنش واحدی روی
۱۰٬۴۲۶ مشتری ساخته نمی‌شود.
- خطای `database is locked`: بازآزمایی حداکثر `4` بار با backoff نمایی
(۰.۵s، ۱s، ۲s) به‌همراه jitter. **retry نامحدود وجود ندارد.**
- خطاهای غیرقابل‌بازیابی فوراً بالا می‌آیند؛ batch جاری rollback می‌شود،
checkpoint جلو نمی‌رود و job با `failed` ثبت می‌شود.
- `PRAGMA busy_timeout=30000` موجود (در `admin_panel/signals.py`) دست‌نخورده است.
`journal_mode=WAL` و `synchronous=FULL` هم تغییر داده نشده‌اند.
- **محدودیت باقی‌مانده:** SQLite در هر لحظه فقط یک نویسنده دارد. بهتر است دستور
را در ساعات کم‌ترافیک اجرا کنی؛ اگر web/worker هم‌زمان بنویسند، ممکن است
چند batch با backoff کندتر شوند (نه خطا). سایت متوقف نمی‌شود.
## مدل وضعیت job
`CustomerNameFixJob` — **فقط** وضعیت و checkpoint. هیچ اطلاعاتی از جنسیت یا هویت
مشتری در آن ذخیره نمی‌شود.
| فیلد | معنا |
|---|---|
| `source_hash` | SHA-256 فایل ورودی؛ مبنای تشخیص «همان فایل» |
| `algorithm_version` | نسخهٔ الگوریتم تصمیم (`ALGORITHM_VERSION`) |
| `status` | `running` / `paused` / `completed` / `failed` |
| `batch_size` | اندازهٔ batch هنگام آغاز |
| `initial_max_pk` | سقف دامنه (بیشترین pk مشتریان در آغاز) |
| `last_processed_pk` | checkpoint؛ آخرین pk پردازش‌شده |
| `stats` | آمار تجمیعی (JSON) |
| `note` | توضیح وضعیت/خطا |
| `started_at` / `updated_at` / `finished_at` / `heartbeat_at` | زمان‌ها |
شرط خواندن هر batch: `last_processed_pk < pk <= initial_max_pk`، مرتب بر اساس
`pk`. هیچ‌وقت از offset یا شمارهٔ ردیف استفاده نمی‌شود (offset با افزودن/حذف
رکورد جابه‌جا می‌شود و باعث پرش/تکرار می‌شود).
## بکاپ/بازیابی خودکار نیست
دستور بکاپ نمی‌گیرد و تغییرات را «undo» نمی‌کند. برای برگشت، بکاپِ قبل از اجرا
را بازیابی کن:
```bash
# ۱) توقف سرویس‌هایی که می‌نویسند
docker compose stop web worker scheduler delivery
# ۲) جایگزینی فایل دیتابیس در ولوم، با پاک کردن WAL/SHM قدیمی
docker run --rm \
-v khatibi-sqlite-db:/data \
-v "$(pwd)":/backup \
alpine sh -c "cp /backup/db_backup_YYYYmmdd_HHMMSS.sqlite3 /data/db.sqlite3 \
&& rm -f /data/db.sqlite3-wal /data/db.sqlite3-shm"
# ۳) بالا آوردن دوباره (سرویس migrate اول اجرا می‌شود)
docker compose start migrate web worker scheduler delivery
```
اگر از `sqlite_db_backup` استفاده کرده‌ای، فایل بکاپ در S3 است؛ اول آن را با
`aws s3 cp`/کلاینت S3 دانلود کن و بعد مراحل بالا را اجرا کن.
## خطاهای رایج و اقدام درست
| خطا | معنا | اقدام |
|---|---|---|
| `فایل داده پیدا نشد: …` | مسیر `--json-path` غلط | مسیر را با `ls` تأیید کن |
| `JSON نامعتبر است: …` | فایل خراب است | هیچ تغییری اعمال نشد؛ فایل درست را بگذار |
| `کلید «RECORDS» در فایل JSON وجود ندارد` | ساختار فایل عوض شده | فایل را اصلاح کن — **هیچ نامی تغییر نمی‌کند** |
| `جدول … وجود ندارد` | migration اعمال نشده | `python manage.py migrate` |
| `hash فایل ورودی … نمی‌خواند` | فایل عوض شده | یا فایل قبلی را برگردان، یا `--new-job` |
| `همین حالا در حال اجراست` | یک اجرای دیگر فعال است | صبر کن یا `--resume` همان job را بده |
| `database is locked` (پس از ۴ تلاش) | فشار نوشتن هم‌زمان | دوباره اجرا کن؛ از checkpoint ادامه می‌دهد |
| `برای نوشتن واقعی، هم --apply و هم --confirm-production لازم است` | تأیید ناقص | هر دو گزینه را بده |
## بررسی صحت داده (SQL، پیش از/پس از اجرا)
```sql
-- وضعیت jobها
SELECT id, status, source_hash, last_processed_pk, initial_max_pk,
started_at, finished_at
FROM admin_panel_customernamefixjob ORDER BY id;
-- مشتریانی که پیشوند ندارند ولی نامشان چندکلمه‌ای است (کاندید بالقوه)
SELECT COUNT(*) FROM admin_panel_customer
WHERE TRIM(name_family) LIKE '% %'
AND name_family NOT LIKE 'آقا %'
AND name_family NOT LIKE 'آقای %'
AND name_family NOT LIKE 'خانم %';
-- تعدادی که پیشوند گرفته‌اند (نتیجهٔ اجرای واقعی)
SELECT COUNT(*) FROM admin_panel_customer WHERE name_family LIKE 'آقای %';
SELECT COUNT(*) FROM admin_panel_customer WHERE name_family LIKE 'خانم %';
```
## تست‌ها
```bash
.venv/Scripts/python.exe manage.py test tests.test_customer_name_fix # ۳۰ تست
.venv/Scripts/python.exe manage.py test tests # کل مجموعه
.venv/Scripts/python.exe manage.py makemigrations --check --dry-run # باید «No changes detected» بدهد
```
تست‌ها فقط روی دیتابیس تستی Django (SQLite درون‌حافظه) و با فایل JSON ساختگیِ
موقت اجرا می‌شوند؛ نه به `data9/kt.sqlite3` دست می‌زنند و نه به فایل دادهٔ واقعی.
## نکته‌ها و محدودیت‌ها
- **نام تک‌کلمه‌ای هیچ‌وقت تغییر نمی‌کند** — حتی اگر در فایل با جنسیت قطعی
باشد؛ چون ممکن است فقط فامیل باشد.
- **حرف اضافه/کم کردن ممنوع است:** نه fuzzy matching، نه حذف حروف، نه حدس از
فامیل، نه انتخاب تصادفی جنسیت.
- نام‌های دارای پیشوند **دوباره** پیشوند نمی‌گیرند (idempotent).
- دستور `save()` کامل صدا نمی‌زند؛ روی `Customer` سیگنال و منطق اجباری در
`save()` وجود ندارد، پس `bulk_update(['name_family'])` امن است و فقط همان
یک ستون را می‌نویسد (فیلدهای `auto_now_add` مثل `create_date` لمس نمی‌شوند).
- منطق روی `name_family` اجرا می‌شود، نه `name` — این نام واقعی فیلد در این
پروژه است.
- ۵ نام موجود در فایل جنسیت نامعتبر دارند (`D`/`FF`/`MF`) و عمداً کنار گذاشته
می‌شوند؛ مشتریانی با آن نام‌ها بدون پیشوند می‌مانند.
- نام‌هایی که در فایل نیستند (۱۴۵۷ مورد در دادهٔ فعلی) پیشوند نمی‌گیرند. اگر
بعداً فایل کامل‌تری آمد، با `--json-path` فایل جدید و `--new-job` می‌توان
دور تازه‌ای اجرا کرد.
- برای تغییر قواعد تصمیم یا نرمال‌سازی، `ALGORITHM_VERSION` در
`admin_panel/name_fix.py` را بالا ببر تا jobهای قدیمی ناخواسته ادامه داده
نشوند.
- کنسول ویندوز: متن فارسی در خروجی ممکن است `charmap` خطا بدهد؛ مثل بقیهٔ
سرویس‌های پروژه از `python -X utf8 manage.py …` استفاده کن.
## چک‌لیست سریع (کپی/پیست)
```bash
# ── توسعه (WSL) ──────────────────────────────────────────────
.venv/Scripts/python.exe manage.py test tests.test_customer_name_fix
.venv/Scripts/python.exe manage.py fix_customer_names --dry-run --samples 5
# ── Production ───────────────────────────────────────────────
docker compose run --rm migrate # ۱) مهاجرت
docker compose exec web python manage.py sqlite_db_backup # ۲) بکاپ
docker compose exec web python manage.py fix_customer_names --dry-run --samples 10
docker compose exec web python manage.py fix_customer_names --apply --confirm-production
docker compose exec web python manage.py fix_customer_names --apply --confirm-production --resume <JOB_ID>
```
گزارش نهایی یک اجرای واقعی:
```
گزارش اجرای واقعی job#1 (completed)
مشتریان بررسی‌شده: 10426
قابل اصلاح / اصلاح‌شده: 7303
بدون تغییر: 3123
تک‌کلمه‌ای: 1576 | دارای پیشوند: 89 | خالی/نامعتبر: 0
ناشناخته در JSON: 1457 | جنسیت مبهم/نامعتبر: 1
batchهای تکمیل‌شده: 53
شناسهٔ job: 1 | شروع: … | پایان: …
نمونهٔ تغییرات (قبل → بعد):
«مجتبی سالاری» → «آقای مجتبی سالاری»
```
### تعریف کلیدهای آماری
| کلید | معنا |
|---|---|
| `scanned` | کل مشتریان بررسی‌شده در دامنهٔ job |
| `changed` | نام‌هایی که پیشوند گرفتند (یا در dry-run، می‌گیرند) |
| `unchanged` | `scanned - changed` |
| `single_word` | تک‌کلمه‌ای ⇒ دست‌نخورده |
| `has_prefix` | از قبل `آقا`/`آقای`/`خانم` ⇒ دست‌نخورده |
| `empty_invalid` | خالی/فقط فاصله ⇒ دست‌نخورده |
| `unknown_name` | اولین کلمه در فایل نیست ⇒ دست‌نخورده |
| `ambiguous_gender` | جنسیت متناقض/نامعتبر ⇒ دست‌نخورده |
| `batches` | تعداد batchهای commit‌شده |
File diff suppressed because it is too large. Load diff
+611
View File
@@ -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` را دیده‌ای (خصوصاً `سرریز طول` و `مقدار موجودِ محافظت‌شده`)؟
+476
View File
@@ -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())
+383
View File
@@ -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()