improve entezar bazdid

This commit is contained in:
javad committed 2026-10-20 19:02:34 +03:30
1 parent 1fd27d0445
commit 2aee85593f
18 files changed
+10271 -7558

No files matched your search

+38 -6
View File
@@ -1,11 +1,11 @@
Project Root: khatibi
Generated At: 2026-10-16T12:37:31+00:00
Python Files: 179
Generated At: 2026-10-20T14:01:53+00:00
Python Files: 186
Django Files: 12
JavaScript/TypeScript Files: 14
HTML Files: 58
Total Files: 713
Total Directories: 77
Total Files: 744
Total Directories: 78
khatibi
├── .cursor
@@ -38,6 +38,7 @@ khatibi
│ ├── management
│ │ ├── commands
│ │ │ ├── __init__.py
│ │ │ ├── cleanup_visit_dedup.py
│ │ │ ├── fix_customer_names.py
│ │ │ ├── fix_customer_phones.py
│ │ │ ├── gt_esh.py
@@ -124,6 +125,9 @@ khatibi
│ │ ├── 0072_customernamefixjob.py
│ │ ├── 0073_alter_action_actiontype_choices.py
│ │ ├── 0074_alter_action_status_choices.py
│ │ ├── 0075_cheque_funding_before_due.py
│ │ ├── 0076_alter_smstemplate_text_help.py
│ │ ├── 0077_alter_smstemplate_text_balance_help.py
│ │ └── __init__.py
│ ├── models
│ │ ├── __init__.py
@@ -138,6 +142,7 @@ khatibi
│ │ └── users.py
│ ├── sms
│ │ ├── __init__.py
│ │ ├── balance.py
│ │ ├── enqueue.py
│ │ ├── notices.py
│ │ ├── parts.py
@@ -313,7 +318,8 @@ khatibi
│ ├── tests.py
│ ├── urls.py
│ ├── views - Copy.py
│ └── viewsBK.py
│ ├── viewsBK.py
│ └── visit_policy.py
├── data9
│ ├── db2 (2).sqlite32
│ ├── db2.sqlite32
@@ -348,6 +354,13 @@ khatibi
│ │ ├── graph.json
│ │ ├── GRAPH_REPORT.md
│ │ └── manifest.json
│ ├── 2026-10-20
│ │ ├── .graphify_analysis.json
│ │ ├── .graphify_labels.json
│ │ ├── .graphify_semantic_marker
│ │ ├── graph.json
│ │ ├── GRAPH_REPORT.md
│ │ └── manifest.json
│ ├── cache
│ │ ├── ast
│ │ │ └── v0.9.82-s7
@@ -367,11 +380,13 @@ khatibi
│ │ │ ├── 100f37182d082767395fcd09f1ec937fd74e57ed039885843bf81d349c3fc1f5.json
│ │ │ ├── 10615f5a999e04257afc03cdc52e1b9e4e11ae7818d7c64ae13b976e3974feb4.json
│ │ │ ├── 11042a6b38137d4e725c18d536cde9c98652782cb58390aa6216dacad2d18cf5.json
│ │ │ ├── 1219157d5f2c0a61d4069d2e7067d2eaf1c6403fcd5965743d9a196fe4564184.json
│ │ │ ├── 1285cdf9b89689e3ac99cea19470a3c14fdbf3a9d1bb2cd8da2cfdbd42ca940f.json
│ │ │ ├── 128b2b616c097fbfbcbc3fddafc224df0919ba261bb5ded7bcafa8b445fe118b.json
│ │ │ ├── 12d922593c75865b8068057f9244ad61a5ee573b7f3f38a16c31fa83abb9d668.json
│ │ │ ├── 13a39530fbb076496d510363b40bf16093d8d117751e7240bcc09a447bdace35.json
│ │ │ ├── 13ca1f37c3eea3c07228d2d98d47f7db6a9c1d8468b259651f3fea5419651569.json
│ │ │ ├── 143902d3d7280d781e9f6a8e366068809dec67bd385462175012f9b5c072e470.json
│ │ │ ├── 14f64f9b156b6b520e7af189b303a2844728f3ab8bbe1e65fed49160b1431cb2.json
│ │ │ ├── 15be5091a5bfbf8f48c3f3ae344a980be5452eab00f054c001958aae96557514.json
│ │ │ ├── 174e406c2649f2384921581cbf912945a95d54aab73707b9aa45752aaa7c54d7.json
@@ -406,17 +421,20 @@ khatibi
│ │ │ ├── 38980449465279974120694e1c667f229049f75d656fe518196d9cadc2429155.json
│ │ │ ├── 3ba0d4c75ab8bfa5bc2a60dccb74197cb94bd161e526e54770a9b0a2aa757806.json
│ │ │ ├── 3c9f8987dc7426b442fe2a90c7de5d909fc85988e172e7722c11f24a2c331481.json
│ │ │ ├── 3d94db4e528b0e06bb975a12c71733767fe57307bb6e3de7c6ce65721e097541.json
│ │ │ ├── 3ff644065147b53af40ea703202c0fd832114936fe67e6c9a30d15e3da915455.json
│ │ │ ├── 40e7fbe5e765171201ab8016acce76a28304e1339d9aeb5a9cc18da87c5604d2.json
│ │ │ ├── 4162977f2c2251e7a8b331d7229bd43dff56df31356191b19886b5bf5d56377d.json
│ │ │ ├── 41952e7c846da15fe658603ff001dafdb0e12d3372a7f0306adfa3a71de8bee7.json
│ │ │ ├── 4204828f3cbb3465c469e28345e0fc4d7ff9a84e30e3dd9f00ab4dbe27014e0d.json
│ │ │ ├── 435b8e433cd2f09f7b81717214592f9bfadd8edd8177adcabd4338ce803855d2.json
│ │ │ ├── 44fd8135217bc689f0886e5bd75458999e67ea57252445884c2822fff306312e.json
│ │ │ ├── 458c1b9a3ce257e789f75305939ab2e79029c557657102eebf2082e50a47a0e5.json
│ │ │ ├── 45f7e59ef3f291171180b6763e8da408d8b7579bb7cb96dd4ca859e48aa5ce14.json
│ │ │ ├── 46616763804b2b938d73c75f7651158e94803e0b86b3a759ecdd59375759eea3.json
│ │ │ ├── 46bde576f6f1e69457e4ad33238fdbd602d2a7f027644a84cb81068215fbd5e1.json
│ │ │ ├── 477b0c06bcadb94314490ac76899f499d45a942aadef1e65623d303342157463.json
│ │ │ ├── 492a9871203480b590199773e90727872f60f06013713a4a2936d87986cdbbe5.json
│ │ │ ├── 4da7df08a0ef803551d404438fe24d6c8cc11d9a2c3cc8d85e37d5228ba3a166.json
│ │ │ ├── 4e24d52f79dfa76aa2406de05583fa49ce966a28fb653fa284fb8e8204797385.json
│ │ │ ├── 4e604dd03a70463ae25f6615ea3ce4fe8e98fab15d6b11ddafd51d601062e84d.json
@@ -426,6 +444,7 @@ khatibi
│ │ │ ├── 527de91bd8bbc5a7ab79951457a9f192909faa9bbf02aaec72bb3d26e2298b57.json
│ │ │ ├── 5316515ee7fc3e1038e676b361d9028edc735993c3d34791a77a14503deb064d.json
│ │ │ ├── 53bcc38a002a291b8a370a1c24dde42e704cbda5d409d77b8405599e45844cae.json
│ │ │ ├── 559fbbe72061ed129b192884f1f2b9c58d23f2af199cd408aa95d7c5af2753ef.json
│ │ │ ├── 5730d72cc177474584197921cce656696e3177780028c4629950f5514b530c6d.json
│ │ │ ├── 57a18b5350b4a567305ca01b2951efee6eeb3b289b5ffbbf390c3962ff29d61c.json
│ │ │ ├── 58ad02e0930221b4f44603dbcf01d3b5d07d9da2772edafe48a94a9894ea148a.json
@@ -479,6 +498,7 @@ khatibi
│ │ │ ├── 77a1520156879de714ee872c29b1387967bcc5ce470b47d37087ea2a110f3875.json
│ │ │ ├── 780977bca042ffdde1cca2811a3a46c36bd3fcff2723c94ac527428113131c64.json
│ │ │ ├── 784dd791c21e8837a3e925331cf234b2138ded3d2012968739604d5535a18e09.json
│ │ │ ├── 79ffc45a7139464dd6c7b1fc6f1f0f9c44347fbd7dcee92c6d4b63525424c442.json
│ │ │ ├── 7be7f428e6acfe4c99370707cc7de9253f801feff1564088f2584d5dc8c42a89.json
│ │ │ ├── 7bf404104bb32fc04568c2d287891fa69955ac3f46718c99dcca64a4bfff6fe3.json
│ │ │ ├── 7c4fc90402a7836b364425c33cc2ffc98e7634391ebe78143df03c88945d8988.json
@@ -514,6 +534,7 @@ khatibi
│ │ │ ├── 9c2c2f6f8d4d4c3a9a60da3ed38a26dc789e8cfaff5d46d140d2f208e5fef3c3.json
│ │ │ ├── 9cfb6e4f7b08a863675af032e7823c0523170f5201e686c5ef72b39c04b07a12.json
│ │ │ ├── 9eabdb0cacba38c8fb6b3e777932e06bd82172cdc0bcccf2fc0bfc23b5bd4974.json
│ │ │ ├── 9ef7cb42412a83490d3e54c81d4e7fa58db25253eb50a41d97d5d4064df0dfe2.json
│ │ │ ├── a186e74ebd87e23212ffe7fd72c40e9383c0624a3efdb2fe1e3271d9ea96d9b9.json
│ │ │ ├── a31ab0e36b30049a602cc060d77c0715187f7c3838db91b8c20bfaa802f49899.json
│ │ │ ├── a43d68ff03b49e9153a73777c0bde346a6b1969f640d67eae500d60d410a02c0.json
@@ -523,6 +544,7 @@ khatibi
│ │ │ ├── aabf6dbb2202699bb1b06fd582c88465b9cf2b8cc060bda2e2bc0c67fc493bed.json
│ │ │ ├── ab5d0682afc0714c24b9ac5fda36b243c638d272ea973f46a5c18e783132c861.json
│ │ │ ├── add004e78a62164d7565c102eb825debea20811f7fe76afa0567a1b6b289f264.json
│ │ │ ├── adda28366431a09f735c806811acad036f33199438c7679d275e3c80714b9a80.json
│ │ │ ├── aeaae7b60339b1283cab517b4ab42806c64933c272d007a68431988ba8e0edb5.json
│ │ │ ├── af128ca7fc8d02ae40fff36925a5b96034862ce0d581e22a8a0c88f41040e526.json
│ │ │ ├── b24556e20d540fd63de3dc04501ffd021836b930c4ebc3ff309f7950ef17a335.json
@@ -532,6 +554,7 @@ khatibi
│ │ │ ├── b543952627caaf0a5c52bec09b33fae93443fca7234365680bbe75acc260dd1c.json
│ │ │ ├── b59999c2fd322c241c7bd37e6ed0a95c9124cbb79f8424f1fa7cb2e92fe63817.json
│ │ │ ├── b82d5f6840b4930eb62fdb1c7b3a58124fff805e529ede7684516a65322fcae0.json
│ │ │ ├── b87a932e418a18531d61dc15776db652401e7cf1682dead02abdf50e2cd00530.json
│ │ │ ├── b9f8ea31056fe0c0fc40a84640c58f29895312c65bf694567487204d8f1170e9.json
│ │ │ ├── bb140cfe12cfb3e32dc9815393035fc33c7649c2faeeccb82cdee56a930ea635.json
│ │ │ ├── bb74cda4073a6da7a4b60fecfb3bb40796a51ceafcbdd128d60e5ce23fa4f319.json
@@ -574,12 +597,14 @@ khatibi
│ │ │ ├── d88e1e365cafd5d26ce47c2a0fe1f067dc3bfe5ceed1f75a21393fae950be939.json
│ │ │ ├── d899e8cc575f125b908d78f0c8d34408a4af1972ee0544d0517f8d1933239214.json
│ │ │ ├── d94bebfe941bb0acc30b633423eaee11690652709e5f8f28d157473d31a07ee3.json
│ │ │ ├── d9590fb13a20db20da78d33674f56397a18e3356d08dab6f7c0739130caee096.json
│ │ │ ├── db68b8db5217e2295eb1195760d7352960627da044a4c4f8f6b89c67ad2f8f57.json
│ │ │ ├── db794bbcafe219af962673ef26efb1ee1eacf3923e13113352f3eedfd3192d23.json
│ │ │ ├── dbbdad9d992922aff62ad3262a531716cfc32e2f15f600773b9a0cf10545058d.json
│ │ │ ├── dc0344515d1b3ffe8643560516b88418be0b306fba56101b8a630ed73d48db46.json
│ │ │ ├── dcad55d51bb4494a642af555d5bfe78c5b8ee3d11de1b3d8f4286d608980854b.json
│ │ │ ├── e08c4784f0f82403ff24a1676e3d75e11a208aadf8fe94a723461c9658afd1e0.json
│ │ │ ├── e0eb3c543474df255f97ae0679df48999b86b75dc42943b9d67f4cfcf1acae34.json
│ │ │ ├── e16f78807ff202fa2c81867b321acfacede2d24cc785856eef0633d8d352796b.json
│ │ │ ├── e1a7be59904a3dc503c5b12bddc7604c6783b02462c8ec76834c65c5610c3bda.json
│ │ │ ├── e2967fb2aed9420b2cdf823d95f971b7038323c97a6ed139ce63b7a1912381d1.json
@@ -765,13 +790,20 @@ khatibi
│ ├── test_sms.py
│ ├── test_sms_phone_normalize.py
│ ├── test_urls_contract.py
│ └── test_view_smoke.py
│ ├── test_view_smoke.py
│ └── test_visit_policy.py
├── .clinerules
├── .dockerignore
├── .gitignore
├── .graphifyignore
├── 0098sms.md
├── AGENTS.md
├── backup-20261020-155504.sqlite3
├── backup-20261020-155539.sqlite3
├── backup-20261020-155613.sqlite3
├── backup-20261020-155723.sqlite3
├── backup-20261020-155757.sqlite3
├── backup-20261020-155827.sqlite3
├── check_finance.py
├── CLAUDE.md
├── db.json
@@ -0,0 +1,81 @@
"""پاک‌سازی کلیدهای «آلوده» دفتر یکتاسازی یادآوری بازدید.
چرا لازم است؟ کلید یکتاسازیِ یادآوری بازدید، در نسخهٔ قدیمی ``visit:<id>:<شمارهٔ چرخه>``
بود که شمارهٔ چرخه با تقسیم روز بر یک عدد ثابت محاسبه می‌شد. وقتی آن عدد صفر می‌شد،
عددهای بی‌معنا (مثل ``visit:1601:24663``) ساخته و برای همیشه در ``SMSDedup`` ثبت
می‌شدند؛ نتیجه‌اش قفل دائمی یادآوری برای همان مشتری بود.
قاعدهٔ جدید کلید را با «موعد تقویمی» می‌سازد: ``visit:<id>:<YYYY-MM>``. کلیدهای قدیمی
هرگز با کلید جدید برخورد نمی‌کنند، بنابراین بی‌ضرر ولی بازدارنده‌اند.
این دستور **فقط** کلیدهای بی‌معنا/قدیمیِ دفتر را حذف می‌کند و هیچ مشتری، اقدام یا
پیامکی را تغییر نمی‌دهد.
python manage.py cleanup_visit_dedup --dry-run # فقط گزارش
python manage.py cleanup_visit_dedup # حذف کلیدهای بی‌معنا (cycle > حد)
python manage.py cleanup_visit_dedup --all-legacy # حذف همهٔ کلیدهای قالب قدیمی
"""
import re
from django.core.management.base import BaseCommand
from django.db import transaction
from ...models import SMSDedup
# سقف منطقی شمارهٔ چرخه؛ هر عدد بزرگ‌تر از این، نتیجهٔ باگ تقسیم قدیمی است.
MAX_SANE_CYCLE = 50
LEGACY_PATTERN = re.compile(r"^visit:(\d+):(\d+)$")
NEW_PATTERN = re.compile(r"^visit:(\d+):(\d{4}-\d{2})$")
class Command(BaseCommand):
help = "حذف کلیدهای آلوده/قدیمی دفتر یکتاسازی یادآوری بازدید (بدون تغییر دادهٔ مشتری)."
def add_arguments(self, parser):
parser.add_argument("--dry-run", action="store_true",
help="فقط گزارش؛ چیزی حذف نمی‌شود.")
parser.add_argument("--all-legacy", action="store_true",
help="همهٔ کلیدهای قالب قدیمی (visit:<id>:<عدد>) حذف شوند، "
"نه فقط عددهای بی‌معنا.")
def handle(self, *args, **options):
dry_run = options["dry_run"]
all_legacy = options["all_legacy"]
absurd, sane_legacy, current = [], [], []
for key in SMSDedup.objects.filter(key__startswith="visit:").values_list(
"key", flat=True
).iterator():
if NEW_PATTERN.match(key):
current.append(key)
continue
match = LEGACY_PATTERN.match(key)
if match:
cycle = int(match.group(2))
(absurd if cycle > MAX_SANE_CYCLE else sane_legacy).append(key)
else:
absurd.append(key)
doomed = absurd + (sane_legacy if all_legacy else [])
self.stdout.write(self.style.MIGRATE_LABEL("گزارش کلیدهای یادآوری بازدید:"))
self.stdout.write(f" کلیدهای قالب جدید (موعد تقویمی): {len(current):,}")
self.stdout.write(f" کلیدهای بی‌معنا (چرخه > {MAX_SANE_CYCLE} یا ناشناخته): {len(absurd):,}")
self.stdout.write(f" کلیدهای قدیمیِ سالم (چرخه ≤ {MAX_SANE_CYCLE}): {len(sane_legacy):,}")
if absurd:
self.stdout.write(" نمونهٔ کلیدهای بی‌معنا: " + ", ".join(absurd[:5]))
self.stdout.write(f" قابل حذف در این اجرا: {len(doomed):,}")
if dry_run:
self.stdout.write(self.style.WARNING(
"\n[شبیه‌سازی] هیچ کلیدی حذف نشد."
))
return
if not doomed:
self.stdout.write(self.style.SUCCESS("\nانجام شد: چیزی برای حذف نبود."))
return
with transaction.atomic():
deleted, _ = SMSDedup.objects.filter(key__in=doomed).delete()
self.stdout.write(self.style.SUCCESS(
f"\nانجام شد: {deleted:,} کلید حذف شد (فقط از دفتر یکتاسازی)."
))
@@ -1,156 +1,270 @@
"""بازمحاسبهٔ ``Customer.last_bazdid_dt`` از روی اقدام‌های «بازدیدِ تمام‌شده».
"""بازمحاسبهٔ ``Customer.last_bazdid_dt`` از روی منبع حقیقت «آخرین بازدید».
فیلد ``last_bazdid_dt`` «آخرین بازدید» مشتری است و صفحهٔ ``/panel/entezar/`` بر پایهٔ
آن مشتریانِ معوق (بیش از N ماه بازدید نشده) را لیست می‌کند. این مقدار باید برابر
«بزرگ‌ترین ``finishDateTime`` میان اقدام‌های ``actionType='bazdid'`` و ``status='finish'``
هر مشتری» باشد — همان چیزی که ویوهای ``customerNewAction``/``customerEditAction``
می‌نویسند.
منبع حقیقت، ``admin_panel.visit_policy.last_visit_map`` است: بزرگ‌ترین تاریخ مؤثر
در میان اقدام‌های ``bazdid + finish`` هر مشتری (تاریخ مؤثر = ``actionDateTime`` اگر
از حالا جلوتر نباشد، وگرنه ``finishDateTime``).
اگر رکوردی از راهی غیر از این ویوها وارد شود (اسکریپت، ایمپورت، ویرایش دستی دیتابیس)،
این فیلد عقب می‌ماند و مشتریِ واقعاً بازدیدشده اشتباهاً «معوق» شمرده می‌شود. این
دستور آن واگرایی (drift) را از منبع حقیقت (جدول Action) ترمیم می‌کند.
این دستور **فقط** ستون ``last_bazdid_dt`` مشتری را عوض می‌کند و هیچ رکورد اقدامی
(از جمله رکوردهای «هماهنگی») را نمی‌خواند/نمی‌نویسد.
python manage.py recalc_last_bazdid --dry-run # فقط گزارش، بدون هیچ تغییری
python manage.py recalc_last_bazdid # اعمال اصلاحات
python manage.py recalc_last_bazdid --dry-run # فقط گزارش
python manage.py recalc_last_bazdid # فقط اصلاح رو به جلو
python manage.py recalc_last_bazdid --exact --confirm=YES # ترمیم کامل + بکاپ خودکار
مشخصات:
* idempotent — اجرای دوباره پس از یک‌بار اجرا هیچ تغییری ایجاد نمی‌کند.
* پیش‌فرض محافظه‌کارانه است: فقط مقدار را **به‌روز** می‌کند و هیچ‌وقت روی مقادیری که
اقدامِ پشتیبان ندارند دست نمی‌گذارد (مگر با ``--clear-orphans``).
* روی دیتابیس production هم قابل اجرا است.
* ``--dry-run``: هیچ نوشتنی انجام نمی‌شود.
* ``--exact``: هر اختلافی (رو به جلو، رو به عقب، پرکردن مقدار خالی) اصلاح می‌شود.
بدون ``--confirm=YES`` اجرا نمی‌شود و پیش از هر نوشتن، خودکار ``VACUUM INTO``
می‌گیرد؛ اگر بکاپ شکست بخورد، هیچ چیز نوشته نمی‌شود.
* ``--clear-orphans``: مقادیری که هیچ بازدید معتبری پشتشان نیست را ``NULL`` می‌کند.
* ``--batch-size``: اندازهٔ دستهٔ خواندن مشتریان.
idempotent است: اجرای دوباره در همان حالت هیچ تغییری ایجاد نمی‌کند.
"""
from django.core.management.base import BaseCommand
from django.db import transaction
from django.db.models import Max
import os
from datetime import datetime
from django.core.management.base import BaseCommand, CommandError
from django.db import connection, transaction
from ...models import Action, Customer
from ...visit_policy import as_aware, last_visit_map
CONFIRM_TOKEN = "YES"
SAMPLE_LIMIT = 20
def _bucket(current, new, clear_orphans):
"""دستهٔ تغییر پیشنهادی برای یک مشتری."""
if new is None:
if current is not None and clear_orphans:
return "orphan_cleared"
return "no_change"
if current is None:
return "fill_null"
if new > current:
return "forward"
if new < current:
return "backward"
return "no_change"
class Command(BaseCommand):
help = (
"بازمحاسبهٔ Customer.last_bazdid_dt از MAX(finishDateTime) اقدام‌های "
"بازدیدِ تمام‌شده (ترمیم واگرایی داده؛ idempotent)."
"بازمحاسبهٔ Customer.last_bazdid_dt از منبع حقیقت بازدید "
"(فقط bazdid + finish؛ هرگز هماهنگی)."
)
def add_arguments(self, parser):
parser.add_argument(
"--dry-run", action="store_true",
help="فقط گزارش کن که چه چیزی تغییر می‌کند؛ هیچ رکوردی ذخیره نمی‌شود.",
help="فقط گزارش؛ هیچ رکوردی ذخیره نمی‌شود.",
)
parser.add_argument(
"--exact", action="store_true",
help="هر اختلافی را اصلاح کن (شامل عقب‌بردن مقدار). نیازمند --confirm=YES.",
)
parser.add_argument(
"--confirm", default="",
help="برای اجرای --exact باید مقدار YES باشد.",
)
parser.add_argument(
"--clear-orphans", action="store_true",
help="مقدارهایی که هیچ اقدامِ بازدیدِ تمام‌شده‌ای پشتشان نیست را هم NULL کن "
"(پیش‌فرض: دست‌نخورده بمانند). فقط اگر مطمئنی این ستون آلوده است.",
help="مقدارهایی که هیچ اقدام بازدید معتبری پشتشان نیست را NULL کن.",
)
parser.add_argument(
"--batch-size", type=int, default=500,
help="اندازهٔ دستهٔ خواندن مشتریان در هر گام (پیش‌فرض ۵۰۰).",
help="اندازهٔ دستهٔ خواندن مشتریان (پیش‌فرض ۵۰۰).",
)
# ------------------------------------------------------------------
def handle(self, *args, **options):
dry_run = options["dry_run"]
exact = options["exact"]
clear_orphans = options["clear_orphans"]
batch_size = max(1, options["batch_size"])
if exact and not dry_run:
if options["confirm"].strip().upper() != CONFIRM_TOKEN:
raise CommandError(
"اجرای --exact بدون تاییدیه مجاز نیست. ابتدا --dry-run --exact "
"را ببینید، از دیتابیس بکاپ بگیرید و سپس با --confirm=YES اجرا کنید."
)
prefix = "[شبیه‌سازی] " if dry_run else ""
self.stdout.write(self.style.MIGRATE_HEADING(
f"{prefix}بازمحاسبهٔ آخرین بازدید مشتریان آغاز شد."
))
# منبع حقیقت: بزرگ‌ترین زمانِ پایانِ بازدید در اقدام‌های تمام‌شدهٔ هر مشتری.
# (Max مقادیر NULL را نادیده می‌گیرد؛ مشتریِ بدون بازدید به‌کل در نقشه نمی‌آید.)
latest_rows = (
Action.objects
.filter(actionType="bazdid", status="finish")
.exclude(customer_id__isnull=True)
.values("customer_id")
.annotate(mx=Max("finishDateTime"))
)
latest = {
row["customer_id"]: row["mx"]
for row in latest_rows.iterator(chunk_size=batch_size)
if row["mx"] is not None
}
stats, samples, pending, suspects = self._plan(exact, clear_orphans, batch_size)
stats = {
"scanned": 0, # کل مشتریان بررسی‌شده
"with_visit": 0, # مشتریان دارای بازدیدِ تمام‌شده
"updated": 0, # مقدار اصلاح/به‌روز شد
"moved_forward": 0, # جدیدتر شد ⇒ از لیست معوق‌ها خارج می‌شود
"moved_backward": 0, # عقب‌تر شد
"set_null": 0, # با --clear-orphans خالی شد
"already_ok": 0, # از قبل درست بود
"kept_orphan": 0, # مقدار داشت ولی اقدامِ پشتیبان نداشت (دست‌نخورده ماند)
"null_and_no_action": 0, # NULL و بدون اقدام (طبیعی؛ کاری لازم نیست)
}
pending = [] # (customer_id, target_value) برای اعمال گروهی
qs = Customer.objects.only("id", "last_bazdid_dt").order_by("id")
for customer in qs.iterator(chunk_size=batch_size):
stats["scanned"] += 1
mx = latest.get(customer.id)
current = customer.last_bazdid_dt
if mx is not None:
stats["with_visit"] += 1
if current == mx:
stats["already_ok"] += 1
continue
if current is None or current < mx:
stats["moved_forward"] += 1
else:
stats["moved_backward"] += 1
stats["updated"] += 1
pending.append((customer.id, mx))
else:
# هیچ بازدیدِ تمام‌شده‌ای پشتوانهٔ این مقدار نیست.
if current is None:
stats["null_and_no_action"] += 1
elif clear_orphans:
stats["set_null"] += 1
pending.append((customer.id, None))
else:
stats["kept_orphan"] += 1
backup_path = None
if exact and not dry_run and pending:
backup_path = self._backup()
self.stdout.write(self.style.SUCCESS(f"بکاپ ساخته شد: {backup_path}"))
if not dry_run and pending:
with transaction.atomic():
for customer_id, target in pending:
Customer.objects.filter(pk=customer_id).update(
last_bazdid_dt=target
)
self._apply(pending)
self._report(stats, dry_run, clear_orphans)
self._report(stats, samples, suspects, dry_run, exact, clear_orphans,
len(pending), backup_path)
def _report(self, stats, dry_run, clear_orphans):
# ------------------------------------------------------------------
def _plan(self, exact, clear_orphans, batch_size):
"""محاسبهٔ تغییرات پیشنهادی بدون هیچ نوشتنی."""
visits = last_visit_map()
stats = {
"scanned": 0, "with_visit": 0, "no_change": 0,
"fill_null": 0, "forward": 0, "backward": 0, "orphan_cleared": 0,
"kept_orphan": 0,
}
pending = []
samples = []
qs = Customer.objects.only("id", "name_family", "last_bazdid_dt").order_by("id")
for customer in qs.iterator(chunk_size=batch_size):
stats["scanned"] += 1
new = visits.get(customer.id)
current = as_aware(customer.last_bazdid_dt)
if new is not None:
stats["with_visit"] += 1
bucket = _bucket(current, new, clear_orphans)
if bucket == "no_change":
stats["no_change"] += 1
if new is None and current is not None:
stats["kept_orphan"] += 1
continue
if not exact and bucket in ("backward", "orphan_cleared"):
stats["no_change"] += 1 # حالت پیش‌فرض فقط رو به جلو حرکت می‌کند
continue
stats[bucket] += 1
pending.append((customer.id, new))
if len(samples) < SAMPLE_LIMIT:
samples.append((customer.id, customer.name_family, current, new, bucket))
return stats, samples, pending, self._hamahangi_suspect_count()
def _apply(self, pending):
"""اعمال تغییرات؛ فقط ستون ``last_bazdid_dt`` مشتری نوشته می‌شود."""
with transaction.atomic():
for customer_id, target in pending:
Customer.objects.filter(pk=customer_id).update(last_bazdid_dt=target)
# ------------------------------------------------------------------
def _backup(self):
"""یک نسخهٔ سالم از دیتابیس با timestamp می‌سازد؛ خطا یعنی توقف کامل اجرا.
اول ``VACUUM INTO`` (سریع و اتمی) و اگر SQLite اجازه نداد (مثلاً داخل یک
تراکنش باز)، نسخهٔ سازگار با روش رسمی ``sqlite3.Connection.backup``
گرفته می‌شود تا در هر دو حالت یک فایل سالم حاصل شود.
"""
db_path = str(connection.settings_dict.get("NAME") or "")
if not db_path:
raise CommandError("مسیر دیتابیس مشخص نیست؛ بکاپ ممکن نشد.")
target_dir = os.path.dirname(os.path.abspath(db_path))
stamp = datetime.now().strftime("%Y%m%d-%H%M%S")
backup_path = os.path.join(target_dir, f"backup-{stamp}.sqlite3")
try:
with connection.cursor() as cursor:
cursor.execute("VACUUM INTO %s", [backup_path])
except Exception as vacuum_error: # noqa: BLE001
try:
self._copy_database(db_path, backup_path)
except Exception as copy_error: # noqa: BLE001
raise CommandError(
"بکاپ گرفتن شکست خورد؛ هیچ تغییری اعمال نشد: "
f"{vacuum_error} / {copy_error}"
)
if not os.path.exists(backup_path) or os.path.getsize(backup_path) == 0:
raise CommandError("فایل بکاپ ساخته نشد یا خالی است؛ اجرا متوقف شد.")
return backup_path
def _copy_database(self, source_path, target_path):
"""نسخهٔ پشتیبان سازگار از فایل SQLite (روش رسمی ``backup``)."""
import sqlite3
source = sqlite3.connect(self._source_uri(source_path), uri=True)
try:
target = sqlite3.connect(target_path)
try:
source.backup(target)
finally:
target.close()
finally:
source.close()
@staticmethod
def _source_uri(source_path):
"""URI فقط‌خواندنی؛ اگر مسیر خودش URI باشد (دیتابیس تست) دست‌نخورده می‌ماند."""
if source_path.startswith("file:"):
return source_path
return f"file:{source_path}?mode=ro"
def _hamahangi_suspect_count(self):
"""تعداد مشتریانی که مقدارشان دقیقاً برابر تاریخ یک رکورد هماهنگی است.
تشخیص **تقریبی** است: ``last_bazdid_dt`` هیچ ارجاعی به رکورد منبع ندارد،
پس برابری مقدار فقط «نشانه» است، نه اثبات.
"""
stamps = {}
rows = Action.objects.filter(
actionType=Action.ActionType.HAMAHANGI,
).values_list("customer_id", "actionDateTime", "finishDateTime")
for customer_id, action_dt, finish_dt in rows.iterator():
stamps.setdefault(customer_id, set()).update(
v for v in (as_aware(action_dt), as_aware(finish_dt)) if v is not None
)
if not stamps:
return 0
count = 0
qs = Customer.objects.filter(id__in=list(stamps)).only("id", "last_bazdid_dt")
for customer in qs.iterator():
if as_aware(customer.last_bazdid_dt) in stamps[customer.id]:
count += 1
return count
def _report(self, stats, samples, suspects, dry_run, exact, clear_orphans,
changed, backup_path=None):
style = self.style
label = "[شبیه‌سازی] " if dry_run else ""
self.stdout.write(style.MIGRATE_LABEL(f"\n{label}گزارش بازمحاسبهٔ آخرین بازدید:"))
mode = "exact" if exact else "default(forward-only)"
self.stdout.write(style.MIGRATE_LABEL(
f"\n{label}گزارش بازمحاسبهٔ آخرین بازدید (حالت: {mode}):"
))
self.stdout.write(f" بررسی‌شده (کل مشتریان): {stats['scanned']:,}")
self.stdout.write(f" دارای بازدیدِ تمام‌شده: {stats['with_visit']:,}")
self.stdout.write(f" از قبل درست: {stats['already_ok']:,}")
self.stdout.write(f" نیازمند اصلاح: {stats['updated']:,}")
self.stdout.write(f" → جدیدتر شد (خروج از معوق): {stats['moved_forward']:,}")
self.stdout.write(f" → عقب‌تر شد: {stats['moved_backward']:,}")
self.stdout.write(f" دارای بازدید معتبر: {stats['with_visit']:,}")
self.stdout.write(f" بدون تغییر: {stats['no_change']:,}")
self.stdout.write(f" پر کردن مقدار خالی: {stats['fill_null']:,}")
self.stdout.write(f" اصلاح رو به جلو: {stats['forward']:,}")
self.stdout.write(f" اصلاح رو به عقب: {stats['backward']:,}")
if clear_orphans:
self.stdout.write(f" → خالی شد (بدون اقدام): {stats['set_null']:,}")
self.stdout.write(f" پاک‌سازی یتیم: {stats['orphan_cleared']:,}")
else:
self.stdout.write(
f" بدون اقدام ولی دارای مقدار (دست‌نخورده): {stats['kept_orphan']:,}"
f" بدون بازدید ولی دارای مقدار (دست‌نخورده): {stats['kept_orphan']:,}"
)
self.stdout.write(
f" NULL و بدون اقدام (طبیعی): {stats['null_and_no_action']:,}"
)
self.stdout.write(style.WARNING(
f" نشانهٔ آلودگی هماهنگی (تقریبی): {suspects:,}"
))
if samples:
self.stdout.write("\n نمونهٔ تغییرات (id | مشتری | فعلی → جدید | دسته):")
for customer_id, name, current, new, bucket in samples:
self.stdout.write(f" {customer_id} | {name} | {current} → {new} | {bucket}")
if dry_run:
self.stdout.write(style.WARNING(
"\n[حالت شبیه‌سازی] هیچ رکوردی ذخیره نشد. برای اعمال، بدون --dry-run اجرا کن."
f"\n[شبیه‌سازی] {changed:,} تغییر پیشنهادی. هیچ رکوردی ذخیره نشد."
))
elif stats["updated"]:
elif changed:
self.stdout.write(style.SUCCESS(
f"\nانجام شد: {stats['updated']:,} رکورد به‌روزرسانی شد."
f"\nانجام شد: {changed:,} مشتری به‌روزرسانی شد (فقط ستون last_bazdid_dt)."
))
else:
self.stdout.write(style.SUCCESS(
"\nانجام شد: هیچ اصلاحی لازم نبود (داده از قبل درست است)."
"\nانجام شد: هیچ اصلاحی لازم نبود (داده از قبل هم‌راستا است)."
))
if backup_path:
self.stdout.write(f" بکاپ: {backup_path}")
@@ -0,0 +1,36 @@
# Generated by Django 3.2.25 on 2026-10-20 14:50
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('admin_panel', '0077_alter_smstemplate_text_balance_help'),
]
operations = [
# ایندکس سه‌ستونیِ قبلی باید ابتدا برداشته شود؛ وگرنه نام ``act_visit_idx``
# تکراری می‌شود. ساخت ایندکس پوشا هم همین‌جا انجام می‌شود تا وضعیت مدل
# (``Meta.indexes``) و دیتابیس دقیقاً یکی باشند.
migrations.RunSQL(
sql=[
'DROP INDEX IF EXISTS "act_visit_idx";',
(
'CREATE INDEX "act_visit_idx" ON "admin_panel_action" '
'("actionType", "status", "customer_id", "actionDateTime", "finishDateTime");'
),
],
reverse_sql=['DROP INDEX IF EXISTS "act_visit_idx";'],
state_operations=[
migrations.AddIndex(
model_name='action',
index=models.Index(
fields=['actionType', 'status', 'customer_id',
'actionDateTime', 'finishDateTime'],
name='act_visit_idx',
),
),
],
),
]
+16
View File
@@ -66,3 +66,19 @@ class Action(models.Model):
nextNazarDT = models.DateTimeField(null=True, blank=True, default=None)
nazarDT = models.DateTimeField(null=True, blank=True, default=None)
grade = models.IntegerField(null=True, blank=True, default=None)
class Meta:
# ایندکس **پوشا (covering)** برای محاسبهٔ «آخرین بازدید»: هر سه ستونِ لازم
# برای فیلتر و تجمیع داخل خودِ ایندکس است، پس دیتابیس به جدول اصلی مراجعه
# نمی‌کند. اندازه‌گیری روی دادهٔ واقعی: ۱۶۴ms → ۲۹ms.
# ستون‌های actionType/status/customer_id برای فیلتر و group، و دو ستون
# تاریخ برای محاسبهٔ «تاریخ مؤثر» در همان ایندکس.
indexes = [
models.Index(
fields=[
'actionType', 'status', 'customer_id',
'actionDateTime', 'finishDateTime',
],
name='act_visit_idx',
),
]
+77 -33
View File
@@ -21,14 +21,14 @@
همیشه خاموش نمی‌ماند.
"""
import logging
import os
from datetime import datetime, time, timedelta
from django.utils import timezone
from ..models import Customer, Sarresid, SMSProviderConfig, SMSTemplate, SMSType
from ..visit_policy import (as_aware, cycle_anchor_map, next_appointment_map,
visit_latest_due, visit_period)
from .enqueue import enqueue_sms
from .timing import DEFAULT_VISIT_MONTHS
logger = logging.getLogger(__name__)
@@ -38,24 +38,39 @@ def _template(sms_type):
def _aware(value):
"""تبدیل تاریخ/ساعت naive به aware (کد قدیمی پروژه جاهایی مقدار naive ذخیره می‌کند)."""
"""تبدیل تاریخ/ساعت naive به aware (کد قدیمی پروژه جاهایی مقدار naive ذخیره می‌کند).
در روزهای تغییر ساعت، نیمه‌شب ممکن است وجود نداشته باشد؛ آن حالت هم پوشش داده
می‌شود تا یک ``installDate`` روی مرز DST اجرای کل اسکن را نشکند.
"""
if value is None:
return None
if isinstance(value, datetime):
if timezone.is_naive(value):
return timezone.make_aware(value, timezone.get_current_timezone())
try:
return timezone.make_aware(value, timezone.get_current_timezone())
except Exception:
return timezone.make_aware(
value, timezone.get_current_timezone(), is_dst=False,
)
return value
# date → آغاز همان روز
return timezone.make_aware(
datetime.combine(value, time.min), timezone.get_current_timezone(),
)
moment = datetime.combine(value, time.min)
try:
return timezone.make_aware(moment, timezone.get_current_timezone())
except Exception:
return timezone.make_aware(
moment, timezone.get_current_timezone(), is_dst=False,
)
class _Budget:
"""ظرفیت صف‌کردن یک اسکنر در یک اجرا (خط شروع + سقف قدیمی + سقف کل)."""
def __init__(self, config):
def __init__(self, config, now=None):
self.since = config.scan_since
# مرجع زمانی «خط شروع»؛ برای اسکن با زمان تزریقی (تست/بازپخش) هم‌زمان می‌شود.
self.now = now
self.allow_backlog = bool(config.allow_backlog)
self.backlog_max = config.backlog_max or 0
self.max_per_run = config.max_per_run or 0
@@ -63,8 +78,21 @@ class _Budget:
self.backlog_count = 0
def is_backlog(self, trigger):
"""آیا این مورد پیش از «خط شروع» واجد شرط شده است؟
مرجع زمانی، «اکنونِ همین اجرا» است (``self.now``)؛ برای اسکن عادی همان
``timezone.now()`` و برای اسکن با زمان تزریقی همان لحظهٔ تزریق‌شده.
"""
trigger = _aware(trigger)
return self.since is not None and trigger < self.since
since = self.since
if since is not None and self.now is not None:
moment = _aware(self.now)
now_ref = timezone.now()
if moment is not None and now_ref is not None:
# پنجره با «همان لحظه‌ای» که اسکن برایش اجرا می‌شود سنجیده شود،
# وگرنه اسکنِ بازپخش/تست همیشه همه‌چیز را «قدیمی» می‌بیند.
since = since + (moment - now_ref)
return since is not None and trigger < since
def can_send(self, trigger):
if self.max_per_run and (self.new_count + self.backlog_count) >= self.max_per_run:
@@ -166,50 +194,66 @@ def scan_nesiye_due(today):
return count
def scan_visit(today):
def scan_visit(today, *, now=None):
"""یادآوری بازدید برای مشتریان عقب‌افتاده از بازدید (منطق صفحهٔ «انتظار»).
لحظهٔ واجد‌شرط‌شدن هر مشتری = «آخرین بازدید + دورهٔ مجاز» (یا تاریخ نصب برای
مشتریانی که هرگز بازدید نشده‌اند). اگر این لحظه پیش از خط شروع باشد، مورد قدیمی
تلقی می‌شود و فقط با اجازهٔ صریح مدیر (و در سقف تعیین‌شده) ارسال می‌گردد.
لحظهٔ واجد‌شرط‌شدن هر مشتری = «آخرین بازدید معتبر + دورهٔ مجاز» (یا تاریخ نصب
برای مشتریانی که هرگز بازدید نشده‌اند). مبنای «آخرین بازدید» و دوره، هر دو از
``admin_panel.visit_policy`` می‌آیند تا این اسکنر و صفحهٔ ``entezar`` هرگز
دو جواب متفاوت ندهند. مشتری دارای «تعویق فعال» (هماهنگی در انتظار) یادآوری
نمی‌گیرد، چون قرار بعدی از قبل با او گذاشته شده است.
``now`` برای تست/بازپخش قابل تزریق است؛ پیش‌فرض «همین حالا» است.
اگر لحظهٔ واجد‌شرط‌شدن پیش از خط شروع باشد، مورد قدیمی تلقی می‌شود و فقط با
اجازهٔ صریح مدیر (و در سقف تعیین‌شده) ارسال می‌گردد.
"""
template = _template(SMSType.VISIT)
if template is None:
return 0
budget = _Budget(SMSProviderConfig.load())
# دورهٔ بازدید: اول از قالب (قابل تنظیم از پنل)، وگرنه متغیر محیطی legacy، وگرنه پیش‌فرض.
months = template.repeat_interval_months
if not months:
try:
months = int(os.environ.get("bazdid", DEFAULT_VISIT_MONTHS))
except (TypeError, ValueError):
months = DEFAULT_VISIT_MONTHS
period = timedelta(weeks=int(months * 4.3))
period_days = max(period.days, 1)
now = timezone.now()
period = visit_period()
now = timezone.now() if now is None else as_aware(now)
budget = _Budget(SMSProviderConfig.load(), now=now)
# لنگر چرخه: آخرین «بازدید» یا «نصب» (تصمیم محصولی). تماس تلفنی چرخه را ریست نمی‌کند.
anchors = cycle_anchor_map(now=now)
# قرار بعدی (هماهنگی توافق‌شده یا بازدید برنامه‌ریزی‌شده) ⇒ یادآوری لازم نیست.
appointments = next_appointment_map(now=now)
# مرز تاریخ‌ها باید به وقت **محلی** گرفته شود؛ `.date()` روی دیتاتایم aware،
# تاریخ UTC می‌دهد که در ساعت‌های اول شب تهران یک روز عقب‌تر است و باعث می‌شد
# مشتریِ دقیقاً روی مرز، از اسکن جا بیفتد.
cutoff_date = (now - period).astimezone(timezone.get_current_timezone()).date()
qs = Customer.objects.filter(
laghv_bazdid=False,
is_active=True,
installDate__lte=(now - period).date(),
installDate__lte=cutoff_date,
).order_by("id")
count = 0
for customer in qs:
anchor = _aware(customer.last_bazdid_dt) or _aware(customer.installDate)
if customer.id in appointments:
continue # قرار بعدی ثبت شده است؛ یادآوری لازم نیست
anchor = anchors.get(customer.id) or _aware(customer.installDate)
if anchor is None:
continue
trigger = anchor + period
if trigger > now:
# «تازه‌ترین» موعدی که رسیده است (۹ ماه، ۱۸ ماه، ۲۷ ماه…).
# قاعدهٔ قبلی همیشه موعد اول را می‌سنجید و همین باعث می‌شد دوره‌های بعدی
# هرگز ساخته نشوند.
trigger = visit_latest_due(anchor, now=now)
# موعد یک «تاریخ» است نه یک لحظه؛ اگر روزِ موعد فرا رسیده باشد باید صف شود.
if trigger is None or trigger.date() > now.astimezone(
timezone.get_current_timezone()
).date():
continue # هنوز عقب‌افتاده نیست
if not budget.can_send(trigger):
continue
# چرخه = شمارهٔ دورهٔ کاملِ سپری‌شده از لنگر. کلید به چرخه گره می‌خورد تا
# حذفِ یادآوری توسط مدیر فقط همان «دوره» را کنسل کند و در دوره‌های بعدی
# دوباره ساخته شود (نه اینکه برای همیشه خاموش بماند).
cycle = max((today - anchor.date()).days // period_days, 0)
# کلید یکتاسازی = «موعد تقویمیِ» همان دوره، نه شمارش روز.
# با این قاعده هر دوره دقیقاً یک کلید دارد: ۹ ماه، ۱۸ ماه، ۲۷ ماه… .
# (قاعدهٔ قبلی — days // 274 — با موعد تقویمی هم‌تراز نبود و باعث
# گم‌شدنِ یک دوره و تکرار/پرش شمارهٔ چرخه می‌شد.)
due_month = trigger.strftime("%Y-%m")
made = enqueue_sms(
customer=customer, sms_type=SMSType.VISIT,
context={"name": customer.name_family},
dedup_key=f"visit:{customer.id}:{cycle}",
dedup_key=f"visit:{customer.id}:{due_month}",
)
if made:
budget.record(trigger)
+83 -60
View File
@@ -1,13 +1,19 @@
import datetime
from django.core.exceptions import ValidationError
from django.db.models import Q
from django.shortcuts import render, redirect, get_object_or_404
from django.http import HttpResponse
from django.contrib import messages
from django.contrib.auth.models import User
from django.contrib.auth.decorators import login_required
from django.utils import timezone
from ..models import Action, Customer
from ..sms.triggers import notify_visit_scheduled
from ..visit_policy import (REASON_CANCEL, REASON_CREATE, REASON_EDIT,
as_aware, normalize_finish_datetime,
refresh_last_bazdid, validate_action_payload)
@login_required(login_url='/panel/p_login/')
@@ -54,46 +60,68 @@ def customerNazar(request ,c_id):
data = {'customer': customer, 'actions': actions, 'allUsers': allUsers}
return render(request, 'admin_panel/customer/nazar.html', data)
def _parse_form_datetime(raw):
"""timestamp میلی‌ثانیه‌ای فرم → دیتاتایم aware (یا ``None``).
کد قبلی ``datetime.datetime.fromtimestamp`` (naive) می‌ساخت که هنگام ذخیره
با ``USE_TZ=True`` هشدار ``RuntimeWarning`` می‌داد؛ اینجا صریح aware می‌شود.
"""
try:
return as_aware(datetime.datetime.fromtimestamp(int(raw) / 1e3))
except (TypeError, ValueError, OverflowError, OSError):
return None
def _action_payload_from_request(request):
"""استخراج و نرمال‌سازی ورودی فرم اقدام (مشترک ایجاد و ویرایش)."""
is_done = bool(request.POST.get('is_done'))
return {
'actionType': (request.POST.get('actionType') or '').strip(),
'status': Action.Status.FINISH if is_done else Action.Status.WAITE,
'actionDateTime': _parse_form_datetime(request.POST.get('dateTimeAnjam')),
'finishDateTime': _parse_form_datetime(request.POST.get('FinishDateTime')),
'desc': request.POST.get('desc', None),
'result': request.POST.get('result', None),
'userID': request.POST.get('userID', None),
'is_done': is_done,
}
def _reject(request, error, redirect_to):
"""پیام فارسی خطا به کاربر و بازگشت بدون ذخیرهٔ چیزی."""
messages.error(request, '؛ '.join(error.messages))
return redirect(redirect_to)
@login_required(login_url='/panel/p_login/')
def customerNewAction(request ,c_id):
actionType = request.POST.get('actionType', None)
dateTimeAnjam = request.POST.get('dateTimeAnjam', None)
#return HttpResponse(float(dateTimeAnjam+".0"))
payload = _action_payload_from_request(request)
try:
dateTimeAnjam = datetime.datetime.fromtimestamp(int(dateTimeAnjam)/1e3)
except:
pass
desc = request.POST.get('desc', None)
userID = request.POST.get('userID', None)
is_done = request.POST.get('is_done', None)
result = request.POST.get('result', None)
FinishDateTime = request.POST.get('FinishDateTime', None)
try:
FinishDateTime = datetime.datetime.fromtimestamp(int(FinishDateTime)/1e3)
except:
pass
validate_action_payload(payload)
except ValidationError as error:
return _reject(request, error, '/panel/customer/' + str(c_id) + '/action')
customer_obj = Customer.objects.get(id=int(c_id))
newAction = Action()
newAction.actionType = actionType
newAction.actionDateTime = dateTimeAnjam
newAction.desc = desc
newAction.forUser = User.objects.get(id=int(userID))
newAction.actionType = payload['actionType']
newAction.actionDateTime = payload['actionDateTime']
newAction.desc = payload['desc']
newAction.forUser = User.objects.get(id=int(payload['userID']))
newAction.customer = customer_obj
if is_done:
newAction.status = 'finish'
newAction.finishDateTime = FinishDateTime
newAction.result = result
if payload['is_done']:
newAction.status = Action.Status.FINISH
# تاریخ اتمام خالی نباید None ذخیره شود (سابقه را پاک می‌کرد).
newAction.finishDateTime = normalize_finish_datetime(payload['finishDateTime'])
newAction.result = payload['result']
else:
newAction.status = 'waite'
newAction.status = Action.Status.WAITE
newAction.save()
if newAction.status == 'finish' and newAction.actionType == 'bazdid':
customer_obj.last_bazdid_dt = newAction.finishDateTime
customer_obj.save()
# last_bazdid_dt فقط از تابع واحد بازمحاسبه می‌شود؛ نه از finishDateTime.
refresh_last_bazdid(customer_obj, reason=REASON_CREATE, user=request.user)
# پیامک «بازدید شما برای تاریخ … ثبت شد» فقط برای اقدام از نوع بازدید.
if newAction.actionType == 'bazdid':
if newAction.actionType == Action.ActionType.BAZDID:
notify_visit_scheduled(customer_obj, newAction, created_by=request.user)
return redirect('/panel/customer/'+str(c_id)+'/action')
@@ -116,42 +144,37 @@ def ActionListUser(request, u_id):
@login_required(login_url='/panel/p_login/')
def customerEditAction(request ,c_id, action_id):
actionType = request.POST.get('actionType', None)
dateTimeAnjam = request.POST.get('dateTimeAnjam', None)
print("ok")
#return HttpResponse(float(dateTimeAnjam+".0"))
payload = _action_payload_from_request(request)
try:
dateTimeAnjam = datetime.datetime.fromtimestamp(int(dateTimeAnjam)/1e3)
except:
pass
desc = request.POST.get('desc', None)
userID = request.POST.get('userID', None)
is_done = request.POST.get('is_done', None)
result = request.POST.get('result', None)
FinishDateTime = request.POST.get('FinishDateTime', None)
try:
FinishDateTime = datetime.datetime.fromtimestamp(int(FinishDateTime)/1e3)
except:
pass
validate_action_payload(payload)
except ValidationError as error:
return _reject(
request, error,
request.META.get('HTTP_REFERER', '/panel/customer/' + str(c_id) + '/action'),
)
customer_obj = Customer.objects.get(id=int(c_id))
newAction = Action.objects.get(id=action_id)
newAction.actionType = actionType
newAction.actionDateTime = dateTimeAnjam
newAction.desc = desc
newAction.forUser = User.objects.get(id=int(userID))
was_done = newAction.status == Action.Status.FINISH
newAction.actionType = payload['actionType']
newAction.actionDateTime = payload['actionDateTime']
newAction.desc = payload['desc']
newAction.forUser = User.objects.get(id=int(payload['userID']))
newAction.customer = customer_obj
if is_done:
newAction.status = 'finish'
newAction.finishDateTime = FinishDateTime
newAction.result = result
if payload['is_done']:
newAction.status = Action.Status.FINISH
newAction.finishDateTime = normalize_finish_datetime(payload['finishDateTime'])
newAction.result = payload['result']
else:
newAction.status = 'waite'
newAction.status = Action.Status.WAITE
newAction.save()
if newAction.status == 'finish' and newAction.actionType == 'bazdid':
customer_obj.last_bazdid_dt = newAction.finishDateTime
customer_obj.save()
# بازمحاسبهٔ واحد: هم پس از ویرایش، هم وقتی رکورد از «انجام‌شده» برگشت می‌خورد
# (تا مقداری که این رکورد ساخته بود با منبع حقیقت هم‌راستا شود).
reason = REASON_CANCEL if (was_done and not payload['is_done']) else REASON_EDIT
refresh_last_bazdid(customer_obj, reason=reason, user=request.user)
#return redirect('/panel/customer/'+str(c_id)+'/action')
return redirect(request.META.get('HTTP_REFERER', 'redirect_if_referer_not_found'))
@@ -205,7 +228,7 @@ def ActionListUserToday(request, u_id):
@login_required(login_url='/panel/p_login/')
def rezayat(request):
statusRezayat = request.GET.get('status', 'waite')
now = datetime.datetime.now()
now = timezone.now()
# محاسبه بازه‌های زمانی ۳۰ روز و ۶۰ روز قبل
dt_30_days_ago = now - datetime.timedelta(days=30)
@@ -246,20 +269,20 @@ def rezayatEdit(request, ac_id):
# عدم موفقیت: تنظیم nextNazarDT برای ۳ روز بعد (nazarDT پر نمی‌شود تا نهایی نشود)
action.nazar = csDesc
action.grade = int(csGrade)
action.nextNazarDT = datetime.datetime.now() + datetime.timedelta(days=3)
action.nextNazarDT = timezone.now() + datetime.timedelta(days=3)
elif csGrade == '-2':
# کنسل کردن: پر کردن nazarDT برای خروج کامل از لیست کارهای در انتظار
action.nazar = csDesc
action.grade = int(csGrade)
action.nazarDT = datetime.datetime.now()
action.nazarDT = timezone.now()
action.nextNazarDT = None
else:
# ثبت امتیاز عادی
action.nazar = csDesc
action.grade = int(csGrade)
action.nazarDT = datetime.datetime.now()
action.nazarDT = timezone.now()
action.nextNazarDT = None
action.save()
+35 -34
View File
@@ -5,6 +5,7 @@ from django.db import models
from django.db.models import Sum, Q
from django.shortcuts import redirect, render, get_object_or_404
from django.core.paginator import Paginator
from django.utils import timezone
from django.views.decorators.csrf import csrf_exempt
from django.contrib import messages
from django.contrib.auth.decorators import login_required, permission_required
@@ -17,6 +18,7 @@ from ..sms.phones import (normalize_primary_values,
save_primary_normalized)
from ..sms.triggers import notify_payment_thanks, notify_welcome
from ..sms.rules import customer_toggle_map
from ..visit_policy import (last_visit_map, next_appointment_map, visit_due_at)
from ._helpers import logger, _parse_jalali_date, _parse_phone_rows
@@ -327,56 +329,55 @@ def customerEdit(request, c_id):
@login_required(login_url='/panel/p_login/')
def entezar(request):
a = datetime.datetime.now()
"""فهرست «منتظر بازدید».
منبع حقیقت تاریخ بازدید، ``visit_policy.last_visit_map`` است (نه ستون کش‌شدهٔ
``last_bazdid_dt``) تا این صفحه و اسکنر پیامک هرگز واگرا نشوند. دورهٔ بازدید هم
از همان قالب پیامک خوانده می‌شود. مشتری دارای «تعویق فعال» (اقدام هماهنگی
در انتظار با تاریخ آینده) از فهرست خارج و به صف خدمات پس از فروش سپرده می‌شود.
"""
searchVal = request.GET.get('searchVal', '')
searchOn = request.GET.get('searchOn', '')
searchStatus = True
full_path = request.get_full_path()
from django.db.models import Q
now = timezone.now()
due_cut = visit_due_at(now=now)
# مرز تاریخ به وقت محلی (نه UTC) تا صفحه و اسکنر پیامک یک جواب بدهند.
due_cut_date = due_cut.astimezone(timezone.get_current_timezone()).date()
bazdid = os.environ.get('bazdid', 9)
bazdid = int ( int(bazdid) * 4.3 )
dateTime6MonthAgo = a - datetime.timedelta(weeks=bazdid)
''' customers = Customer.objects.filter(name_family__isnull=False)
m6mbazdid = []
for customerObj in customers:
try:
actionAa = Action.objects.filter(customer=customerObj, actionType='bazdid', status='finish').last()
if actionAa.status == 'finish':
customerObj.last_bazdid_dt = actionAa.actionDateTime
customerObj.save()
if actionAa.actionDateTime.date() < dateTime6MonthAgo.date():
m6mbazdid.append(actionAa)
except:
pass '''
visits = last_visit_map(now=now)
appointments = next_appointment_map(now=now)
customers_objj = Customer.objects.filter(
laghv_bazdid=False, name_family__isnull=False,
installDate__lte=due_cut_date,
)
if searchOn == 'address':
print("addressaddressaddressaddressaddress")
customers_objj = Customer.objects.filter(Q(last_bazdid_dt__lte=dateTime6MonthAgo) | Q(last_bazdid_dt__isnull=True),
installDate__lte=dateTime6MonthAgo,
laghv_bazdid=False, name_family__isnull=False, address__contains=searchVal).order_by('last_bazdid_dt')
customers_objj = customers_objj.filter(address__contains=searchVal)
else:
searchStatus = False
customers_objj = Customer.objects.filter(Q(last_bazdid_dt__lte=dateTime6MonthAgo) | Q(last_bazdid_dt__isnull=True),
installDate__lte=dateTime6MonthAgo,
laghv_bazdid=False, name_family__isnull=False).order_by('last_bazdid_dt')
#customers_objj = Customer.objects.filter(name_family__isnull=False, last_bazdid_dt__lte=dateTime6MonthAgo)
# مشتریان عقب‌افتاده: بدون بازدید معتبر، یا بازدید معتبرشان پیش از مقطع دوره.
stale_ids = [
customer_id for customer_id in customers_objj.values_list('id', flat=True)
if visits.get(customer_id) is None or visits[customer_id] <= due_cut
]
customers_objj = customers_objj.filter(id__in=stale_ids)
# «قرار بعدی» (هماهنگی توافق‌شده یا بازدید برنامه‌ریزی‌شده) یعنی این مشتری
# در این فهرست جایی ندارد تا آن تاریخ.
pending_ids = [cid for cid in stale_ids if cid in appointments]
if pending_ids:
customers_objj = customers_objj.exclude(id__in=pending_ids)
customers_objj = customers_objj.order_by('last_bazdid_dt', 'id')
try:
entezar_bazdid = CacheTable.objects.get(key='9mahbazdid')
entezar_bazdid.value = Customer.objects.filter(
Q(last_bazdid_dt__lte=dateTime6MonthAgo) | Q(last_bazdid_dt__isnull=True),
installDate__lte=dateTime6MonthAgo,
laghv_bazdid=False, name_family__isnull=False).count()
entezar_bazdid.value = len(stale_ids) - len(pending_ids)
entezar_bazdid.save()
except:
except CacheTable.DoesNotExist:
pass
paginator = Paginator(customers_objj, 20) # Show 25 contacts per page.
+19 -4
View File
@@ -597,6 +597,10 @@ def sms_queue_delete(request, q_id):
if request.method == "POST":
if item.status in DONE_STATUSES:
return _notice(reverse("admin_panel:sms_queue"), "delete_done_blocked")
# تصمیم محصولی: حذف توسط مدیر = «کنسل همین دوره». برای همین کلید یکتاسازی
# پیش از حذف در دفتر ثبت می‌شود؛ وگرنه اسکنر در اجرای بعدی همان پیامک را
# دوباره می‌ساخت.
_remember_keys([item])
item.delete()
return _notice(reverse("admin_panel:sms_queue"), "deleted")
return redirect("admin_panel:sms_queue")
@@ -621,6 +625,7 @@ def sms_queue_bulk(request):
# تاریخچهٔ ارسال‌شده‌ها از طریق صف حذف نمی‌شود.
selected = selected.exclude(status__in=DONE_STATUSES)
count = selected.count()
_remember_keys(selected)
selected.delete()
code, extra = "bulk_deleted", {"count": count}
else:
@@ -629,6 +634,19 @@ def sms_queue_bulk(request):
return redirect("admin_panel:sms_queue")
def _remember_keys(rows):
"""کلیدهای یکتاسازیِ رکوردهای صف را پیش از حذف در دفتر ``SMSDedup`` ثبت می‌کند.
قرارداد: وقتی مدیر یادآوری را حذف می‌کند، یعنی «همین دوره کنسل شد»؛ وگرنه
اسکنر در اجرای بعدی همان پیامک را از نو می‌سازد و حذفِ مدیر بی‌اثر می‌شود.
"""
keys = [k for k in (row.dedup_key for row in rows) if k]
if keys:
SMSDedup.objects.bulk_create(
[SMSDedup(key=key) for key in keys], ignore_conflicts=True,
)
@login_required(login_url='/panel/p_login/')
@_manage_required
def sms_queue_empty(request):
@@ -640,10 +658,7 @@ def sms_queue_empty(request):
"""
if request.method == "POST":
rows = SMSQueue.objects.exclude(status__in=DONE_STATUSES)
keys = [k for k in rows.values_list("dedup_key", flat=True) if k]
SMSDedup.objects.bulk_create(
[SMSDedup(key=k) for k in keys], ignore_conflicts=True,
)
_remember_keys(rows)
count = rows.count()
rows.delete()
return _notice(reverse("admin_panel:sms_queue"), "queue_emptied", count=count)
+514
View File
@@ -0,0 +1,514 @@
"""سیاست بازدید مشتری — تنها منبع حقیقت برای «آخرین بازدید واقعی».
هدف این ماژول پایان‌دادن به سه تعریف واگرا از «آخرین بازدید» است:
* ویوهای ``customerNewAction``/``customerEditAction`` که قبلاً مستقیماً
``last_bazdid_dt = finishDateTime`` می‌نوشتند (بدون بررسی ترتیب و بدون مبنا).
* صفحهٔ ``/panel/entezar/`` که فقط به ستون کش‌شدهٔ ``Customer.last_bazdid_dt`` تکیه می‌کرد.
* اسکنر پیامک (``admin_panel/sms/scanner.py``) که همان ستون را به‌عنوان لنگر می‌خواند.
قواعد ثابت این ماژول:
۱. فقط اقدام ``actionType == 'bazdid' AND status == 'finish'`` در محاسبه دیده می‌شود.
«هماهنگی» (``hamahangi``) و بقیهٔ انواع هرگز وارد
:func:`last_visit_dt` نمی‌شوند — نه امروز، نه در آینده. تعویق/هماهنگی مفهوم جداگانه‌ای
است و فقط از :func:`active_deferrals` خوانده می‌شود.
۲. تاریخ مؤثر هر رکورد = ``actionDateTime`` **اگر از حالا جلوتر نباشد**؛ وگرنه
``finishDateTime`` اگر آن هم از حالا جلوتر نباشد؛ وگرنه «تاریخ نامعتبر».
قاعدهٔ قدیمی «اگر finish کوچک‌تر از action بود، finish را بگیر» عمداً حذف شده است:
در دادهٔ واقعی، آن حالت همان جایی است که ``finishDateTime`` برابر زمان ثبت رکورد
است و «زمان ثبت» را جای «تاریخ بازدید» می‌گذاشت.
۳. همهٔ مقایسه‌ها با تاریخ aware انجام می‌شود؛ مقادیر naive موجود در دیتابیس
(بقایای کد قدیمی) با همان منطقهٔ زمانی پروژه aware می‌شوند.
"""
import logging
from datetime import date, datetime, time, timezone as dt_timezone
from dateutil import relativedelta
from django.core.exceptions import ValidationError
from django.db import models, transaction
from django.db.models import Case, DateTimeField, F, Max, Min, Value, When
from django.utils import timezone
from .models import Action, Customer, SMSTemplate
from .sms.timing import DEFAULT_VISIT_MONTHS
logger = logging.getLogger(__name__)
# دلیل نوشتن ``last_bazdid_dt`` (برای لاگ؛ با ستون‌های حسابرسی اختیاری هم‌خوان است).
REASON_CREATE = "create"
REASON_EDIT = "edit"
REASON_CANCEL = "cancel"
REASON_RECALC = "recalc"
REASON_MANUAL = "manual"
# سقف مجاز «تاریخ آینده» برای بازدید انجام‌شده در اعتبارسنجی HTTP.
FINISH_MAX_FUTURE_DAYS = 30
# فقط این نوع از اقدام‌ها بازدید هستند (ثابت مدل، نه رشتهٔ خام).
_VISIT_TYPE = Action.ActionType.BAZDID
_VISIT_DONE = Action.Status.FINISH
# ---------------------------------------------------------------------------
# تبدیل تاریخ
# ---------------------------------------------------------------------------
def as_aware(value):
"""تاریخ/دیتاتایم naive یا ``date`` را به ``datetime`` aware تبدیل می‌کند.
برای مقادیر ``date``، «آغاز روز» استفاده می‌شود. در روزهای تغییر ساعت
(مثلاً ۲۲ مارس در تهران) نیمه‌شب ممکن است وجود نداشته باشد؛ در آن حالت
``is_dst=None`` خطای ``NonExistentTimeError`` می‌دهد، پس با ``is_dst=False``
ذخیره می‌شود تا هیچ‌گاه یک تاریخِ درست، اجرای اسکن/گزارش را نشکند.
"""
if value is None:
return None
if isinstance(value, datetime):
if timezone.is_naive(value):
try:
return timezone.make_aware(value, timezone.get_current_timezone())
except Exception:
return timezone.make_aware(
value, timezone.get_current_timezone(), is_dst=False,
)
return value
if isinstance(value, date):
moment = datetime.combine(value, time.min)
try:
return timezone.make_aware(moment, timezone.get_current_timezone())
except Exception:
return timezone.make_aware(
moment, timezone.get_current_timezone(), is_dst=False,
)
return None
def _now(now=None):
return as_aware(now) if now is not None else timezone.now()
def effective_visit_dt(action, *, now=None):
"""تاریخ مؤثر یک اقدام بازدید انجام‌شده (یا ``None`` اگر تاریخ معتبری ندارد).
قاعده در docstring ماژول آمده است. رکورد نامعتبر در محاسبه نادیده گرفته می‌شود
و مقدار موجود مشتری پاک نمی‌شود.
"""
if action is None:
return None
now = _now(now)
for value in (as_aware(action.actionDateTime), as_aware(action.finishDateTime)):
if value is not None and value <= now:
return value
return None
def _visit_rows(customer_ids=None):
"""پیمایش سبک رکوردهای بازدید انجام‌شده (فقط فیلدهای لازم)."""
qs = Action.objects.filter(
actionType=_VISIT_TYPE, status=_VISIT_DONE,
).values_list("customer_id", "actionDateTime", "finishDateTime")
if customer_ids is not None:
qs = qs.filter(customer_id__in=list(customer_ids))
return qs.iterator()
def _last_visit_map_python(*, now=None, customer_ids=None):
"""نسخهٔ مرجع (پایتونی) برای تست همسانی و پشتیبانِ اضطراری.
این همان منطقی است که از ابتدا در ``last_visit_map`` بود؛ نسخهٔ SQL باید
دقیقاً همان خروجی را بدهد. در تست‌ها به‌عنوان «منبع مقایسه» استفاده می‌شود.
"""
now = _now(now)
result = {}
invalid = 0
for customer_id, action_dt, finish_dt in _visit_rows(customer_ids):
eff = None
for value in (as_aware(action_dt), as_aware(finish_dt)):
if value is not None and value <= now:
eff = value
break
if eff is None:
invalid += 1
continue
current = result.get(customer_id)
if current is None or eff > current:
result[customer_id] = eff
if invalid:
logger.warning(
"بازدید انجام‌شدهٔ بدون تاریخ معتبر نادیده گرفته شد: %s رکورد", invalid,
)
return result
def last_visit_map(*, now=None, customer_ids=None):
"""نگاشت ``customer_id → تاریخ آخرین بازدید معتبر`` — با تجمیع در دیتابیس.
قاعدهٔ تاریخ مؤثر همان قاعدهٔ قبلی است (هیچ تغییری در معنا):
* فقط ``bazdid + finish``.
* ``actionDateTime`` اگر خالی نباشد و از «حالا» جلوتر نباشد؛
* وگرنه ``finishDateTime`` اگر خالی نباشد و از «حالا» جلوتر نباشد؛
* وگرنه رکورد «تاریخ معتبر» ندارد و باید نادیده گرفته شود.
محاسبه در SQL انجام می‌شود (``CASE`` داخل ``MAX``) نه با حلقهٔ پایتونی روی
۴۵ هزار رکورد؛ خروجی همان دیکشنری قبلی است.
"""
now = _now(now)
# مقایسهٔ «حالا» باید در SQL انجام شود. نکتهٔ مهم: اگر یک datetime naive به ORM
# داده شود، Django آن را با منطقهٔ زمانی محلی دوباره adapt می‌کند (۳:۳۰ ساعت
# جابه‌جایی برای تهران)، و اگر رشته هم بدهیم آن را به‌عنوان datetime تفسیر می‌کند.
# راه امن: مقدار را در پایتون به «naive UTC» تبدیل و سپس با ``Value`` تایپ‌دار
# به‌عنوان DateTimeField بفرستیم تا تبدیل دومی رخ ندهد.
now_utc = now.astimezone(dt_timezone.utc)
effective = Case(
# شاخهٔ اول: تاریخ بازدید (اگر وجود دارد و در آینده نیست).
When(actionDateTime__isnull=False, actionDateTime__lte=now_utc,
then=F("actionDateTime")),
# شاخهٔ دوم: تاریخ اتمام (اگر وجود دارد و در آینده نیست).
When(finishDateTime__isnull=False, finishDateTime__lte=now_utc,
then=F("finishDateTime")),
default=Value(None, output_field=DateTimeField()),
output_field=DateTimeField(),
)
qs = (
Action.objects.filter(actionType=_VISIT_TYPE, status=_VISIT_DONE)
.values("customer_id")
.annotate(latest=Max(effective))
)
if customer_ids is not None:
qs = qs.filter(customer_id__in=list(customer_ids))
result = {}
invalid = 0
for row in qs.iterator():
value = row["latest"]
if value is None:
invalid += 1
continue
result[row["customer_id"]] = as_aware(value)
if invalid:
logger.warning(
"بازدید انجام‌شدهٔ بدون تاریخ معتبر نادیده گرفته شد: %s مشتری", invalid,
)
return result
def last_visit_dt(customer, *, now=None):
"""تاریخ آخرین بازدید معتبر یک مشتری (یا ``None``)."""
if customer is None or customer.pk is None:
return None
return last_visit_map(now=now, customer_ids=[customer.pk]).get(customer.pk)
# ---------------------------------------------------------------------------
# لنگر چرخهٔ یادآوری (بازدید + نصب)
# ---------------------------------------------------------------------------
# تصمیم محصولی: «نصب» هم چرخهٔ ۹ ماههٔ یادآوری را از نو شروع می‌کند؛ ولی «تماس
# تلفنی» (inCall/outCall) و «هماهنگی» خیر. این لنگر **فقط** برای زمان‌بندی
# یادآوری است و هرگز جای ``last_bazdid_dt`` (که یعنی «آخرین بازدید») را نمی‌گیرد.
_CYCLE_RESET_TYPES = (Action.ActionType.BAZDID, Action.ActionType.NASB)
def cycle_anchor_map(*, now=None, customer_ids=None):
"""نگاشت ``customer_id → آخرین رخدادِ شروع‌دوبارهٔ چرخه`` (بازدید یا نصب).
قاعدهٔ تاریخ مؤثر همان قاعدهٔ بازدید است: ``actionDateTime`` اگر از حالا
جلوتر نباشد، وگرنه ``finishDateTime``؛ وگرنه رکورد بی‌اثر است.
"""
now = _now(now)
qs = Action.objects.filter(
actionType__in=_CYCLE_RESET_TYPES, status=_VISIT_DONE,
).values_list("customer_id", "actionDateTime", "finishDateTime")
if customer_ids is not None:
qs = qs.filter(customer_id__in=list(customer_ids))
result = {}
for customer_id, action_dt, finish_dt in qs.iterator():
eff = None
for value in (as_aware(action_dt), as_aware(finish_dt)):
if value is not None and value <= now:
eff = value
break
if eff is None:
continue
current = result.get(customer_id)
if current is None or eff > current:
result[customer_id] = eff
return result
def cycle_anchor_dt(customer, *, now=None):
"""لنگر چرخهٔ یادآوری یک مشتری (بازدید یا نصب؛ یا ``None``)."""
if customer is None or customer.pk is None:
return None
return cycle_anchor_map(now=now, customer_ids=[customer.pk]).get(customer.pk)
# ---------------------------------------------------------------------------
# نوشتن مقدار روی مشتری
# ---------------------------------------------------------------------------
def refresh_last_bazdid(customer, *, reason=REASON_EDIT, user=None, now=None):
"""``Customer.last_bazdid_dt`` را از منبع حقیقت بازمحاسبه می‌کند.
* فقط وقتی مقدار تغییر کند می‌نویسد (idempotent).
* اگر مشتری هیچ بازدید معتبری ندارد، مقدار فعلی **دست‌نخورده** می‌ماند
(پاک‌سازی فقط با دستور ``recalc_last_bazdid --clear-orphans`` مجاز است).
* هرگز ``Action`` را ذخیره/تغییر نمی‌دهد و ``Customer.save()`` کامل صدا نمی‌زند
(تا فیلدهای ``auto_now`` و بقیهٔ ستون‌ها دست‌نخورده بمانند).
"""
if customer is None or customer.pk is None:
return False
new_value = last_visit_dt(customer, now=now)
current = as_aware(customer.last_bazdid_dt)
if new_value is None:
logger.warning(
"مشتری %s بازدید معتبری ندارد؛ last_bazdid_dt دست‌نخورده ماند "
"(مقدار فعلی: %s)", customer.pk, current,
)
return False
if current == new_value:
return False
Customer.objects.filter(pk=customer.pk).update(last_bazdid_dt=new_value)
customer.last_bazdid_dt = new_value
logger.info(
"last_bazdid_dt مشتری %s از %s به %s تغییر کرد (دلیل=%s، کاربر=%s)",
customer.pk, current, new_value, reason,
getattr(user, "pk", None),
)
return True
# ---------------------------------------------------------------------------
# تعویق / قرار بعدی — هرگز در last_visit_dt دیده نمی‌شود
# ---------------------------------------------------------------------------
# تصمیم محصولی: هر کدام از این دو، «قرار بعدی با مشتری» است و مشتری تا آن تاریخ
# از فهرست «منتظر بازدید» خارج می‌شود:
#
# ۱. اقدام **هماهنگی** که با مشتری توافق شده است — چه هنوز در انتظار باشد و چه
# همان لحظه «انجام شد» بسته شود (``finish``). تاریخ قرار = ``actionDateTime``.
# اگر هماهنگیِ فینیش تاریخ نداشته باشد، از ``finishDateTime`` استفاده می‌شود.
# ۲. اقدام **بازدید** که برای آینده برنامه‌ریزی شده است (``waite`` با تاریخ آینده).
#
# «هماهنگی» بازدید نیست؛ پس هیچ‌کدام از این‌ها ``last_bazdid_dt`` را تغییر نمی‌دهد.
def next_appointment_map(*, now=None):
"""نگاشت ``customer_id → زودترین «قرار بعدی» آینده`` — با تجمیع در دیتابیس.
منابع قرار (فقط آینده):
* ``hamahangi`` با وضعیت ``waite`` **یا** ``finish``،
* ``bazdid`` با وضعیت ``waite`` (بازدید برنامه‌ریزی‌شده).
تاریخ قرار = ``actionDateTime`` و اگر خالی بود ``finishDateTime``.
فقط برای ``entezar`` و اسکنر پیامک استفاده می‌شود و هرگز در «آخرین بازدید»
دخالت نمی‌کند.
"""
now = _now(now)
# همان نکتهٔ ``last_visit_map``: ساعت مرجع باید aware بماند تا Django آن را
# دوباره با منطقهٔ محلی adapt نکند.
now_utc = now.astimezone(dt_timezone.utc)
# چرا دو کوئری جدا؟ با یک شرط ``OR`` روی نوع اقدام، بهینه‌ساز SQLite از ایندکس
# ``act_visit_idx`` استفاده نمی‌کند و کوئری ~۱۹۳ms طول می‌کشد. با تقسیم به
# کوئری‌های ساده، هر کدام دقیقاً با ایندکس (actionType + status) اجرا می‌شوند.
# (از ``union()`` استفاده نمی‌کنیم چون در این ترکیب، ردیف‌ها را از دست می‌دهد.)
hamahangi = Action.objects.filter(
actionType=Action.ActionType.HAMAHANGI,
status__in=(Action.Status.WAITE, Action.Status.FINISH),
).filter(
models.Q(actionDateTime__gt=now_utc)
| models.Q(actionDateTime__isnull=True, finishDateTime__gt=now_utc)
).values_list("customer_id", "actionDateTime", "finishDateTime")
planned_visit = Action.objects.filter(
actionType=Action.ActionType.BAZDID,
status=Action.Status.WAITE,
actionDateTime__gt=now_utc,
).values_list("customer_id", "actionDateTime")
combined = {}
def _remember(customer_id, when):
when = as_aware(when)
if when is None:
return
current = combined.get(customer_id)
if current is None or when < current:
combined[customer_id] = when
for customer_id, action_dt, finish_dt in hamahangi.iterator():
# تاریخ قرار: تاریخ اقدام؛ و برای هماهنگیِ بدون تاریخ اقدام، تاریخ اتمام.
_remember(customer_id, action_dt or finish_dt)
for customer_id, action_dt in planned_visit.iterator():
_remember(customer_id, action_dt)
return combined
def next_appointment(customer, *, now=None):
"""زودترین قرار بعدی یک مشتری (یا ``None``)."""
if customer is None or customer.pk is None:
return None
return next_appointment_map(now=now).get(customer.pk)
# نام قدیمی برای سازگاری با فراخوانی‌های موجود.
def active_deferrals(*, now=None):
"""نام قدیمیِ :func:`next_appointment_map` (سازگاری عقب‌رو)."""
return next_appointment_map(now=now)
def active_deferral(customer, *, now=None):
"""نام قدیمیِ :func:`next_appointment` (سازگاری عقب‌رو)."""
return next_appointment(customer, now=now)
# ---------------------------------------------------------------------------
# دورهٔ بازدید (منبع واحد)
# ---------------------------------------------------------------------------
def visit_period_months():
"""تعداد ماهِ دورهٔ بازدید از قالب پیامک (قابل تنظیم از پنل)، وگرنه پیش‌فرض."""
value = (
SMSTemplate.objects.filter(sms_type="visit")
.values_list("repeat_interval_months", flat=True)
.first()
)
return int(value or DEFAULT_VISIT_MONTHS)
def visit_period(months=None):
"""دورهٔ بازدید به‌صورت بازهٔ تقویمی (ماه واقعی، نه ۴٫۳ هفته)."""
months = int(months or visit_period_months())
return relativedelta.relativedelta(months=max(months, 1))
def visit_period_days(months=None):
"""طول تقریبی دوره به روز (فقط برای گزارش/نمایش؛ مبنای چرخه نیست)."""
return max(round(int(months or visit_period_months()) * 365 / 12), 1)
def visit_due_at(*, now=None, months=None):
"""مقطعی که مشتریِ بدون بازدید از آن پیش‌تر «عقب‌افتاده» حساب می‌شود."""
return _now(now) - visit_period(months)
def visit_due_date(anchor, *, months=None):
"""تاریخ موعد (تقویمی) برای یک لنگر: ``anchor + N ماه``.
تنها مرجع محاسبهٔ موعد، هم در صفحهٔ «انتظار» و هم در اسکنر پیامک.
"""
anchor = as_aware(anchor)
if anchor is None:
return None
return anchor + visit_period(months)
def visit_cycle_index(anchor, at, *, months=None):
"""شمارهٔ چرخهٔ یادآوریِ ``at`` نسبت به لنگر، بر مبنای **ماه تقویمی**.
چرخهٔ ۱ یعنی «یک دورهٔ کامل گذشته و یادآوری اول واجد شرط شده است».
چرا ماه تقویمی و نه تقسیم روز؟ در نسخهٔ قبلی شمارهٔ چرخه با
``days // 274`` حساب می‌شد و با موعدِ تقویمی (``relativedelta(months=9)``)
بر یک شبکه نمی‌نشست؛ نتیجه‌اش *گم‌شدنِ* بعضی دوره‌ها (کلید تکراری) و
*پریدن* بعضی دیگر بود. حالا هر دو از یک قاعده می‌آیند:
«تعداد ماهِ کاملِ سپری‌شده از لنگر ÷ طول دوره».
"""
anchor = as_aware(anchor)
at = as_aware(at)
if anchor is None or at is None or at < anchor:
return 0
period_months = max(int(months or visit_period_months()), 1)
months_elapsed = (at.year - anchor.year) * 12 + (at.month - anchor.month)
period = relativedelta.relativedelta(months=period_months)
def due_date(steps):
"""تاریخِ موعدِ ``steps`` دوره بعد، با جمعِ **پله‌ای**.
چرا ضرب نکنیم؟ ``relativedelta * n`` انتهای ماه را تجمعی می‌بندد
(۳۱ ژانویه → ۳۰ آوریل → ۳۰ جولای) در حالی که موعد واقعی ۳۱ جولای است.
جمعِ پله‌ای همان چیزی است که در عمل هم رخ می‌دهد: «یک دوره، بعد یک دورهٔ دیگر».
مقایسه در سطح **تاریخ** انجام می‌شود (نه لحظه)، تا ساعتِ اجرای اسکن
(مثلاً ۱۲:۰۰ همان روز موعد) تأثیری در شمارش نداشته باشد.
"""
value = anchor.date()
for _ in range(steps):
value = (as_aware(value) + period).date()
return value
today = at.date()
# بزرگ‌ترین kای که موعدش رسیده است.
k = max(months_elapsed // period_months, 0)
while k > 0 and due_date(k) > today:
k -= 1
while due_date(k + 1) <= today:
k += 1
return k
def visit_latest_due(anchor, *, now, months=None):
"""تازه‌ترین موعدی که تا ``now`` رسیده است (یا ``None``).
مشتری‌ای که ۲۵ ماه بازدید نشده باید موعد ۱۸ ماهگی را بگیرد، نه موعد ۹ ماهگیِ
قدیمی. قاعدهٔ قبلی هر بار ``anchor + یک دوره`` را می‌سنجید؛ چون آن موعد همیشه
«رسیده» بود و کلیدش هم ثابت، دوره‌های بعدی هرگز بررسی نمی‌شدند.
"""
anchor = as_aware(anchor)
now = as_aware(now)
if anchor is None or now is None:
return None
current = visit_cycle_index(anchor, now, months=months)
if current < 1:
return None
period = visit_period(months)
# جمع پله‌ای روی **تاریخ** (نه روی دیتاتایم aware): با تغییر ساعت، جمع ساعت‌دار
# ممکن است به ۲۳:۰۰ روز قبل بغلتد و موعد یک روز زودتر حساب شود.
due = anchor.date()
for _ in range(current):
due = (as_aware(due) + period).date()
return as_aware(due)
# ---------------------------------------------------------------------------
# اعتبارسنجی لایهٔ HTTP
# ---------------------------------------------------------------------------
def validate_action_payload(payload, *, now=None):
"""اعتبارسنجی ورودی فرم اقدام پیش از هر نوشتنی.
``payload`` نگاشتی با کلیدهای ``actionType``، ``status``، ``actionDateTime``
و ``finishDateTime`` است (مقادیر می‌توانند ``None`` یا ``datetime aware`` باشند).
قواعد:
* نوع اقدام باید یکی از شش مقدار مدل باشد.
* وضعیت باید یکی از سه مقدار مدل باشد.
* برای «بازدید انجام‌شده»: تاریخ بازدید نباید بیش از ۳۰ روز در آینده باشد.
* برای هماهنگی و بقیهٔ انواع: هیچ محدودیت تاریخ آینده‌ای اعمال نمی‌شود.
"""
now = _now(now)
action_type = payload.get("actionType")
status = payload.get("status")
if action_type not in Action.ActionType.values:
raise ValidationError("نوع اقدام نامعتبر است.")
if status not in Action.Status.values:
raise ValidationError("وضعیت اقدام نامعتبر است.")
if action_type == _VISIT_TYPE and status == _VISIT_DONE:
action_dt = as_aware(payload.get("actionDateTime"))
if action_dt is not None and action_dt > now + relativedelta.relativedelta(
days=FINISH_MAX_FUTURE_DAYS
):
raise ValidationError(
"تاریخ بازدید نمی‌تواند بیش از ۳۰ روز در آینده باشد."
)
return True
def normalize_finish_datetime(value, *, now=None):
"""تاریخ اتمام: اگر خالی بود، زمان ثبت قرار می‌گیرد (نه ``None``)."""
value = as_aware(value)
return value if value is not None else _now(now)
+60 -37
View File
@@ -2,25 +2,25 @@
"0": "views/sms.py",
"1": "run_all",
"2": "django_db",
"3": "create_transfer_request",
"3": "views/store.py",
"4": "ModelsPackageContractTests",
"5": "PhoneCleanupTests",
"6": ".load",
"7": "models/sms.py",
"8": "models/__init__.py",
"8": "django_db_models",
"9": "views/customers.py",
"10": "django_conf",
"11": "make_customer",
"12": "resolve_recipients",
"10": "django_db_models_deletion",
"11": "PanelTestCase",
"12": "make_customer",
"13": "EventTriggerTests",
"14": "views/finance.py",
"15": "views/store.py",
"15": "django_contrib",
"16": "sms0098ws.py",
"17": "Admin Panel Customization",
"18": "User",
"19": "timing_for",
"20": "Customer",
"21": "phones.py",
"19": "apply_timing",
"20": "InactiveCustomerHiddenTests",
"21": "normalize_mobile",
"22": "Login Template Forms",
"23": "cheque_inquiry.py",
"24": "factories.py",
@@ -30,10 +30,10 @@
"28": "make_user",
"29": "QueueReviewTests",
"30": "NoticeMechanismTests",
"31": "SmsReportTests",
"32": "scanner.py",
"31": "name_fix.py",
"32": "enqueue_sms",
"33": "ZeroZeroNinetyEightWebServiceTests",
"34": "ProductInStore",
"34": "ScannerTests",
"35": "admin_panel/templates/admin_panel/baseHesabdari.html",
"36": "Reference guide",
"37": "Factor Creation UI",
@@ -44,19 +44,19 @@
"42": "SMS Integration Docs",
"43": "enqueue.py",
"44": "django_shortcuts",
"45": "stock_in",
"45": "test_inventory.py",
"46": "admin_panel/templates/admin_panel/customerAction.html",
"47": "parts_count",
"47": "SmsReportTests",
"48": "write_tree",
"49": "CustomerInfoCardUITests",
"50": "test_sms.py",
"50": "TestCase",
"51": "Phone Cleanup Guide",
"52": ".run_command",
"52": "Customer",
"53": "project_tree.py",
"54": "Agent Operating Guidelines",
"55": "Command",
"56": "BehinSMSProvider",
"57": "normalize_mobile",
"57": "activate_sms",
"58": "Financial Data Objects",
"59": "Hybrid Migration Command",
"60": ".run_cmd",
@@ -65,54 +65,54 @@
"63": "admin_panel/templates/admin_panel/bedehi.html",
"64": "customerIDFactorBackup.html",
"65": "bale/providers.py",
"66": "asgi.py",
"66": "os",
"67": "Graphify Tooling Docs",
"68": "Command",
"69": "ZeroZeroNinetyEightLinkTests",
"70": "SMS Template Seeding",
"71": "admin_panel/entezar.html",
"72": "customerNewBackup.html",
"73": "SMSResult",
"73": "django_conf",
"74": "0070_sms_types_seven.py",
"75": "Endpoint Smoke Tests",
"76": "Timezone Migration History",
"77": "Command",
"78": "django_urls",
"78": "UrlContractTests",
"79": "Brand Scrubbing Migration",
"80": "SMS Dedup Migration",
"81": "Installation Guide",
"82": "Project Honesty Contract",
"83": "Flow Control Migration",
"84": "Permission Label Migration",
"85": "IntegrationAndEfficiencyTests",
"85": "PhoneNumber",
"86": "Output Format Specs",
"87": "Performance Benchmarks",
"88": "SMS Integration Overview",
"89": "CustomerNameFixJob",
"90": "Customer Modal UI",
"91": "PanelTestCase",
"91": "decide_prefix",
"92": "Infrastructure Services",
"93": "Agent Setup Guide",
"94": "First Use Guide",
"95": "ConsoleSMSProvider",
"96": "PhoneNumber",
"95": "ConsoleEncodingTests",
"96": "test_factors.py",
"97": "تجمیع شماره‌های پیامک مشتریان",
"98": "Command",
"99": "Base Template",
"100": "test_customer_name_fix.py",
"100": "test_sms.py",
"101": "Command",
"102": "customer_phone_entries",
"102": "WorkerTests",
"103": "manage.py",
"104": "افزودن پیشوند «آقای/خانم» به نام مشتریان",
"105": "بازمحاسبهٔ «آخرین بازدید» مشتریان",
"105": "Command",
"106": "QueueStatsTests",
"107": "admin_panel/apps.py",
"107": "signals.py",
"108": "SMS Domain Logic",
"109": "Customer Communication Partials",
"110": "SMS Template Management",
"111": "Documentation and Graphing",
"112": "Admin Migration Package",
"113": "primary_phones",
"113": "recipients.py",
"114": "Customer Action Templates",
"115": "Customer Feedback Components",
"116": "Customer Invoice Details",
@@ -163,24 +163,47 @@
"161": "Project Dependencies",
"162": "Test Suite Package",
"163": "Customer",
"164": "SMSQueue",
"165": "signals.py",
"164": "factory.py",
"165": "0075_cheque_funding_before_due.py",
"166": "توقف و ادامه (Resume / Checkpoint)",
"167": "Command",
"168": "0071_sms_settings_permission.py",
"169": "index",
"169": "0056_auto_20260731_1824.py",
"170": "الگوریتم تصمیم (به همین ترتیب)",
"171": "سریع‌ترین مسیر روی Production (خلاصهٔ اجرایی)",
"172": "models/customers.py",
"172": "0073_alter_action_actiontype_choices.py",
"173": "stop.sh",
"174": "SMSDedup",
"175": "rules.py",
"174": "0074_alter_action_status_choices.py",
"175": "remove_query_param",
"176": "اجرای محلی (توسعه)",
"177": "Factor",
"177": ".test_deleted_visit_reminder_rearms_next_period_only",
"178": "اجرا روی سرور Production",
"179": "اجرای محلی (توسعه)",
"180": "ادامهٔ job پس از crash / قطع برق",
"181": "اجرا روی سرور Production",
"182": "wsgi.py",
"189": "django_contrib_auth_models"
"183": "0005_auto_20220121_2054.py",
"184": "0009_remove_storeroom_user.py",
"185": "0014_auto_20220123_1348.py",
"186": "0015_auto_20220124_0858.py",
"187": "0017_factoriteam_garantyesh.py",
"188": "0022_article_typepy.py",
"189": "0026_auto_20220209_1924.py",
"190": "0028_action_grade.py",
"191": "0032_article_paystatus.py",
"192": "0033_article_nahvepardakht.py",
"193": "0034_alter_article_sarresid.py",
"194": "0035_alter_article_sarresid.py",
"195": "0040_customer_laghv_bazdid.py",
"196": "0041_auto_20240216_1607.py",
"197": "0048_alter_productinstore_options.py",
"198": "0053_storeroom_owners.py",
"199": "0055_action_nextnazardt.py",
"200": "0062_sms_link_provider.py",
"201": "0063_alter_smsqueue_status.py",
"202": "0068_auto_20261010_1305.py",
"203": "0072_customernamefixjob.py",
"204": "0077_alter_smstemplate_text_balance_help.py",
"205": ".test_visit_period_comes_from_template",
"206": ".test_period_start_filters_out_very_old_customers"
}
+1 -1
View File
@@ -1 +1 @@
{"0": "038518c7d9194bab", "1": "06dae62b458fc666", "2": "f61ab1111a89fc50", "3": "e61c3927bdba862c", "4": "11012e19786f67ca", "5": "22b97ba625f8378b", "6": "65060bb181af0bf2", "7": "3ce7acc47ea68c9f", "8": "fabc1e3307709f1a", "9": "123d258529fa9882", "10": "237c411f20dd22bb", "11": "fee454d62d24fd21", "12": "a62dcf8197187c85", "13": "96ec15fbcfe2d27a", "14": "9547a4a3dbac41ac", "15": "499026e81d8e6a05", "16": "1b80b390c6d35082", "17": "9eec9953ee3ca67f", "18": "f04ceeae6fdfe7c6", "19": "781d0380d9314204", "20": "895beea340408433", "21": "1e32348234be9dd4", "22": "9701f1591d8fda6e", "23": "8f0f51ff611eb425", "24": "102a0c450b7cb6b3", "25": "75866dc8db907971", "26": "b6a24d75ed17d59c", "27": "9b6b55a8d8dcb4c4", "28": "1f2e16b5709d3936", "29": "1e9c1fa6fabde0d3", "30": "627dae19704f40f5", "31": "11d769da5f6dd93e", "32": "9f3159f2dae4ab4f", "33": "8794530c7ccb06cb", "34": "4864a4651499f51b", "35": "0928f3aa4e597117", "36": "4e9567703b727de7", "37": "d36df3c85181f806", "38": "bc695fcee2050806", "39": "3557c20b0ad29e99", "40": "89444f5d51189c99", "41": "8c2e4ed6cd161bf3", "42": "bbd8a9066e48dada", "43": "6378ca56250cfe26", "44": "00d4d9fc79267971", "45": "a7e9b25451da94c4", "46": "f38c1f1dc0ead50f", "47": "8fd5a6f3cd5970b9", "48": "9d46bbe4641a8543", "49": "7bc001888a685a2a", "50": "4c34de7757ef1e08", "51": "afc6d855b9fc1b76", "52": "e9a7c031e64eeadb", "53": "afa933e731f6344a", "54": "34401beb9f60ebfd", "55": "05213eed8eed2d6f", "56": "330644cc56a0e06c", "57": "20956521d16ce674", "58": "90146abe85d9419f", "59": "88f880da5a61f9dc", "60": "f723d0b8f5c4997b", "61": "dbfbf8557361f5ea", "62": "6e90b41fbd82c265", "63": "6af26e59b0e8c354", "64": "8ae1cb991df55ba5", "65": "7b932cfef527d130", "66": "e0d0013fbf063aa4", "67": "0f632fc57e9feeb0", "68": "92d7e4efe25770db", "69": "7650c66736fc59da", "70": "df7ea8ce3fa52be3", "71": "fd47ef3914adf11e", "72": "9b3249f4a4e6fbfe", "73": "e00f9e12dd9fb95f", "74": "a534e43d03671a7c", "75": "5290f7a7558a9c3e", "76": "a19c211702401a5b", "77": "35aa0fc2a0626f70", "78": "3100956d3076c59f", "79": "da683f642f8c61ac", "80": "40313433c93b7054", "81": "81af0a28b68936d9", "82": "25287261c3d1c44b", "83": "bda9be183e0f933e", "84": "63526d01888c4f22", "85": "e5577b2d90500462", "86": "694a38d63caf7217", "87": "f220eaa4fac26e3e", "88": "d78c8e21a45613e9", "89": "8cb58c63b0d606b1", "90": "03f8896e9b60ca96", "91": "50363e32255931f4", "92": "4bfa64a1cd8c6a47", "93": "057eae5a523d2004", "94": "5de74655cbc56e06", "95": "b8ea4a512b424113", "96": "e0d3010c0e1a9d7a", "97": "de6816239e70e6e8", "98": "7508d3e84c588617", "99": "15f6a40009e539bb", "100": "ed5eb60fa9b1adb6", "101": "4b7f81f0e7e4c044", "102": "24252b994a79d741", "103": "85ecb40ed558ed43", "104": "756a405f723988ce", "105": "e618d1b582eac9a5", "106": "825ba1cf2cfa5500", "107": "6c05d0c0a89d1816", "108": "b738c5aefe939cff", "109": "d2001d7d283134ac", "110": "6b8e6100893a6b6a", "111": "66ed76491a412a72", "112": "e67668b62ee6c62c", "113": "800f5b95a2eac8d1", "114": "68aee39cd24db354", "115": "054401d27ffaa4b2", "116": "dace81c1995fb321", "117": "19e301aeace95bb2", "118": "d6fd0cec4634a0b0", "119": "0230d355a6dabb54", "120": "5cebbb5528321795", "121": "536fe28d24c361f5", "122": "b08bdc2712cb0f12", "123": "6ce8444f504022cc", "124": "8021f52559633383", "125": "d8e34a3b3eb43ee0", "126": "171c34c74a95481b", "127": "73e9eca6f2ca1c9b", "128": "dcc2230ae751c29a", "129": "2a617b932f5d92b2", "130": "d55cb638532fdfeb", "131": "06cae4389208269f", "132": "e9c0a7be255e9b26", "133": "455b4ca39e55ecb0", "134": "8105c55538c34081", "135": "74304670b54c018f", "136": "a27f876eabb044b2", "137": "73f74dd7a40776d6", "138": "29d433d164b99f89", "139": "3f44aa48aa05d597", "140": "ca8a7efba0a3e579", "141": "5cc770af9d184d77", "142": "2d31fa723128b164", "143": "be720fc0071f0c3d", "144": "9d814c2520329846", "145": "f97d1a5c3a066598", "146": "14f84a6e4cd311d6", "147": "3a592d27b1f4ccc9", "148": "0bf6f9d0aeab4fd8", "149": "8b491bb3ff0649dd", "150": "638812cee15d1b9b", "151": "3dd16d68928a479c", "152": "03a0ee43e18f7934", "153": "60e9520e0a9facae", "154": "435331945d6aad17", "155": "968e1a135dc062b7", "156": "d1a987a548e55a76", "157": "a9bf69b3d550abef", "158": "b039e1c69c8fb7b9", "159": "60029bf0948fc02a", "160": "72dad91d66d471ce", "161": "353a9d09eaae47ea", "162": "43bbf35b96423d46", "163": "742c12886e57d4fa", "164": "6b4bf464232edaa0", "165": "d0c2310f7d93caf6", "166": "bca6f6760a942d10", "167": "ce80aac5f4c439da", "168": "1abe2e7b0bcbeca9", "169": "ce5f237abed01c9e", "170": "c9f25e20d3a7d5d8", "171": "7e818e8715c414bd", "172": "22f3057d405a4f06", "173": "827420b645a55301", "174": "adfc2fda7e4fc28a", "175": "59ba9ce54759cbf2", "176": "adac855b05ea55ee", "177": "6ba88c862239632d", "178": "c67bd11d1973d6ac", "179": "63c999649e523e73", "180": "56f7f3573c34f651", "181": "f8f5346df7d0a239", "182": "9c5614d4fee3efc0", "189": "36b8b06281e0571e"}
{"0": "1dfdd2cf317807f3", "1": "17cefe4501ef5349", "2": "b498606dcac72b73", "3": "bd6e67d4a5f28a70", "4": "28cdf3baa198be4d", "5": "fc9b619d52672e4e", "6": "50f8029c1ec04918", "7": "97d81e46229f8a49", "8": "313943d53fc2bd9f", "9": "627a6196750653eb", "10": "b61d115e59860e03", "11": "9e67a33b989286ec", "12": "e88164ad40862751", "13": "3b56f304a0c6c939", "14": "6dce744bd0131ce7", "15": "e41d0c7172d175f1", "16": "7d8afbd7fea7cf7b", "17": "9eec9953ee3ca67f", "18": "d947d20b351370eb", "19": "d26d5b0aeca2417c", "20": "a8997e767700eb26", "21": "80296933c90021fc", "22": "9701f1591d8fda6e", "23": "8f0f51ff611eb425", "24": "2fdec3396c6901a3", "25": "75866dc8db907971", "26": "bcc559e489b72153", "27": "9b6b55a8d8dcb4c4", "28": "35722e3c74f50804", "29": "e0f6e325bac28363", "30": "bf1e337abf82f763", "31": "e4282689f6251d51", "32": "e5bd8cd35998e36a", "33": "3746e81c1256465f", "34": "817f04c7b50c9b76", "35": "0928f3aa4e597117", "36": "4e9567703b727de7", "37": "d36df3c85181f806", "38": "bc695fcee2050806", "39": "7b572aa5bf8298b3", "40": "4cc746f3f10c71a6", "41": "59b1c6760dbaf1b9", "42": "bbd8a9066e48dada", "43": "b6e84eea788411f9", "44": "4b603c000aa79db9", "45": "8cda0f1879c40078", "46": "f38c1f1dc0ead50f", "47": "93e8d94e7c998882", "48": "9d46bbe4641a8543", "49": "df2dbdd967da3eaf", "50": "28183579704b35fd", "51": "afc6d855b9fc1b76", "52": "5e7f77f5e6dac074", "53": "e97ba10a1e6a2df3", "54": "34401beb9f60ebfd", "55": "ffa54e3d7035005e", "56": "330644cc56a0e06c", "57": "9e17a2dab584e04c", "58": "90146abe85d9419f", "59": "88f880da5a61f9dc", "60": "356d13eb0dd615ee", "61": "dbfbf8557361f5ea", "62": "6e90b41fbd82c265", "63": "6af26e59b0e8c354", "64": "8ae1cb991df55ba5", "65": "7b932cfef527d130", "66": "3de85332d944dae4", "67": "0f632fc57e9feeb0", "68": "92d7e4efe25770db", "69": "2937001282a8a8b6", "70": "df7ea8ce3fa52be3", "71": "fd47ef3914adf11e", "72": "9b3249f4a4e6fbfe", "73": "39cd7adefdc0d3ce", "74": "a534e43d03671a7c", "75": "5290f7a7558a9c3e", "76": "a19c211702401a5b", "77": "35aa0fc2a0626f70", "78": "996c5e480613f739", "79": "da683f642f8c61ac", "80": "40313433c93b7054", "81": "81af0a28b68936d9", "82": "25287261c3d1c44b", "83": "bda9be183e0f933e", "84": "63526d01888c4f22", "85": "ad4f4a79134b4346", "86": "694a38d63caf7217", "87": "f220eaa4fac26e3e", "88": "d78c8e21a45613e9", "89": "e5ca736ea1d10113", "90": "03f8896e9b60ca96", "91": "5b7e9b89423f4164", "92": "4bfa64a1cd8c6a47", "93": "057eae5a523d2004", "94": "5de74655cbc56e06", "95": "7767ac1c95366fb0", "96": "f36a8effec98632a", "97": "de6816239e70e6e8", "98": "7508d3e84c588617", "99": "15f6a40009e539bb", "100": "f4ecac15615b820e", "101": "4b7f81f0e7e4c044", "102": "7c85c7a24cf3cbed", "103": "3ec6277e63307488", "104": "756a405f723988ce", "105": "d55977ec0e3ae213", "106": "64fb409201f034b8", "107": "19d9362d6aa40786", "108": "b738c5aefe939cff", "109": "d2001d7d283134ac", "110": "6b8e6100893a6b6a", "111": "66ed76491a412a72", "112": "e67668b62ee6c62c", "113": "6bb5ee4a70642036", "114": "68aee39cd24db354", "115": "054401d27ffaa4b2", "116": "dace81c1995fb321", "117": "19e301aeace95bb2", "118": "d6fd0cec4634a0b0", "119": "0230d355a6dabb54", "120": "5cebbb5528321795", "121": "536fe28d24c361f5", "122": "b08bdc2712cb0f12", "123": "6ce8444f504022cc", "124": "8021f52559633383", "125": "d8e34a3b3eb43ee0", "126": "171c34c74a95481b", "127": "73e9eca6f2ca1c9b", "128": "dcc2230ae751c29a", "129": "2a617b932f5d92b2", "130": "d55cb638532fdfeb", "131": "06cae4389208269f", "132": "e9c0a7be255e9b26", "133": "455b4ca39e55ecb0", "134": "8105c55538c34081", "135": "74304670b54c018f", "136": "a27f876eabb044b2", "137": "73f74dd7a40776d6", "138": "29d433d164b99f89", "139": "3f44aa48aa05d597", "140": "ca8a7efba0a3e579", "141": "5cc770af9d184d77", "142": "2d31fa723128b164", "143": "be720fc0071f0c3d", "144": "9d814c2520329846", "145": "f97d1a5c3a066598", "146": "14f84a6e4cd311d6", "147": "3a592d27b1f4ccc9", "148": "0bf6f9d0aeab4fd8", "149": "8b491bb3ff0649dd", "150": "638812cee15d1b9b", "151": "3dd16d68928a479c", "152": "03a0ee43e18f7934", "153": "b672f7977ddfa52e", "154": "435331945d6aad17", "155": "968e1a135dc062b7", "156": "d1a987a548e55a76", "157": "a9bf69b3d550abef", "158": "b039e1c69c8fb7b9", "159": "60029bf0948fc02a", "160": "72dad91d66d471ce", "161": "353a9d09eaae47ea", "162": "43bbf35b96423d46", "163": "4faac8c35fa8b703", "164": "76d6488c0976adbc", "165": "652eabd3db52041c", "166": "bca6f6760a942d10", "167": "ce80aac5f4c439da", "168": "1abe2e7b0bcbeca9", "169": "cfbcf8cef78c5757", "170": "c9f25e20d3a7d5d8", "171": "7e818e8715c414bd", "172": "8d68a258e8ec738b", "173": "827420b645a55301", "174": "19e1beef5d40e7ac", "175": "d6451fb8a1e2d3ca", "176": "adac855b05ea55ee", "177": "95a53382a8e217be", "178": "c67bd11d1973d6ac", "179": "63c999649e523e73", "180": "56f7f3573c34f651", "181": "f8f5346df7d0a239", "182": "9c5614d4fee3efc0", "183": "6886ad7a5751faca", "184": "a1a1f3c1cdff72cf", "185": "32f7ccd6bd4068c7", "186": "1d89c741fb1f9b27", "187": "a800ecffc01fc9d5", "188": "f97753a176d20dae", "189"Line truncated
+162 -174
View File
@@ -1,17 +1,17 @@
# Graph Report - khatibi (2026-10-16)
# Graph Report - khatibi (2026-10-20)
## Corpus Check
- 193 files · ~286,477 words
- 197 files · ~287,853 words
- Verdict: corpus is large enough that graph structure adds value.
- Unclassified: 8 file(s) not represented in the graph (top: (none) 5, .env 2, .csv 1)
## Summary
- 1960 nodes · 3584 edges · 184 communities (95 shown, 89 thin omitted)
- 1991 nodes · 3632 edges · 207 communities (87 shown, 120 thin omitted)
- Extraction: 95% EXTRACTED · 5% INFERRED · 0% AMBIGUOUS · INFERRED: 188 edges (avg confidence: 0.89)
- Token cost: 0 input · 0 output
## Graph Freshness
- Built from commit: `7e5c83be`
- Built from commit: `1fd27d04`
- Run `git rev-parse HEAD` and compare to check if the graph is stale.
- Run `graphify update .` after code changes (no API cost).
@@ -19,25 +19,23 @@
- views/sms.py
- run_all
- django_db
- create_transfer_request
- views/store.py
- ModelsPackageContractTests
- PhoneCleanupTests
- .load
- models/sms.py
- models/__init__.py
- views/customers.py
- django_conf
- django_db_models_deletion
- PanelTestCase
- make_customer
- resolve_recipients
- EventTriggerTests
- views/finance.py
- views/store.py
- sms0098ws.py
- Admin Panel Customization
- User
- timing_for
- Customer
- phones.py
- apply_timing
- InactiveCustomerHiddenTests
- normalize_mobile
- Login Template Forms
- cheque_inquiry.py
- factories.py
@@ -47,10 +45,10 @@
- make_user
- QueueReviewTests
- NoticeMechanismTests
- SmsReportTests
- scanner.py
- name_fix.py
- enqueue_sms
- ZeroZeroNinetyEightWebServiceTests
- ProductInStore
- ScannerTests
- admin_panel/templates/admin_panel/baseHesabdari.html
- Reference guide
- Factor Creation UI
@@ -61,19 +59,19 @@
- SMS Integration Docs
- enqueue.py
- django_shortcuts
- stock_in
- test_inventory.py
- admin_panel/templates/admin_panel/customerAction.html
- parts_count
- SmsReportTests
- write_tree
- CustomerInfoCardUITests
- test_sms.py
- TestCase
- Phone Cleanup Guide
- .run_command
- Customer
- project_tree.py
- Agent Operating Guidelines
- Command
- BehinSMSProvider
- normalize_mobile
- activate_sms
- Financial Data Objects
- Hybrid Migration Command
- .run_cmd
@@ -82,51 +80,52 @@
- admin_panel/templates/admin_panel/bedehi.html
- customerIDFactorBackup.html
- bale/providers.py
- os
- Graphify Tooling Docs
- Command
- ZeroZeroNinetyEightLinkTests
- SMS Template Seeding
- admin_panel/entezar.html
- customerNewBackup.html
- SMSResult
- django_conf
- 0070_sms_types_seven.py
- Endpoint Smoke Tests
- Timezone Migration History
- Command
- django_urls
- UrlContractTests
- Brand Scrubbing Migration
- SMS Dedup Migration
- Installation Guide
- Project Honesty Contract
- Flow Control Migration
- Permission Label Migration
- IntegrationAndEfficiencyTests
- PhoneNumber
- Output Format Specs
- Performance Benchmarks
- SMS Integration Overview
- CustomerNameFixJob
- Customer Modal UI
- PanelTestCase
- decide_prefix
- Infrastructure Services
- Agent Setup Guide
- First Use Guide
- ConsoleSMSProvider
- PhoneNumber
- ConsoleEncodingTests
- test_factors.py
- تجمیع شماره‌های پیامک مشتریان
- Command
- Base Template
- test_customer_name_fix.py
- test_sms.py
- Command
- customer_phone_entries
- WorkerTests
- manage.py
- افزودن پیشوند «آقای/خانم» به نام مشتریان
- بازمحاسبهٔ «آخرین بازدید» مشتریان
- Command
- QueueStatsTests
- admin_panel/apps.py
- signals.py
- Customer Communication Partials
- SMS Template Management
- Documentation and Graphing
- primary_phones
- recipients.py
- Customer Action Templates
- Customer Feedback Components
- Customer Invoice Details
@@ -160,35 +159,56 @@
- Data Cleanup Documentation
- Project Dependencies
- Customer
- SMSQueue
- signals.py
- factory.py
- 0075_cheque_funding_before_due.py
- توقف و ادامه (Resume / Checkpoint)
- Command
- 0071_sms_settings_permission.py
- index
- 0056_auto_20260731_1824.py
- الگوریتم تصمیم (به همین ترتیب)
- سریع‌ترین مسیر روی Production (خلاصهٔ اجرایی)
- models/customers.py
- 0073_alter_action_actiontype_choices.py
- stop.sh
- SMSDedup
- rules.py
- 0074_alter_action_status_choices.py
- remove_query_param
- اجرای محلی (توسعه)
- Factor
- .test_deleted_visit_reminder_rearms_next_period_only
- اجرا روی سرور Production
- اجرای محلی (توسعه)
- ادامهٔ job پس از crash / قطع برق
- اجرا روی سرور Production
- django_contrib_auth_models
- 0005_auto_20220121_2054.py
- 0009_remove_storeroom_user.py
- 0014_auto_20220123_1348.py
- 0015_auto_20220124_0858.py
- 0017_factoriteam_garantyesh.py
- 0022_article_typepy.py
- 0026_auto_20220209_1924.py
- 0028_action_grade.py
- 0032_article_paystatus.py
- 0033_article_nahvepardakht.py
- 0034_alter_article_sarresid.py
- 0035_alter_article_sarresid.py
- 0040_customer_laghv_bazdid.py
- 0041_auto_20240216_1607.py
- 0048_alter_productinstore_options.py
- 0053_storeroom_owners.py
- 0055_action_nextnazardt.py
- 0062_sms_link_provider.py
- 0063_alter_smsqueue_status.py
- 0068_auto_20261010_1305.py
- 0072_customernamefixjob.py
- 0077_alter_smstemplate_text_balance_help.py
## God Nodes (most connected - your core abstractions)
1. `make_customer()` - 89 edges
2. `make_user()` - 28 edges
3. `normalize_mobile()` - 27 edges
4. `Customer` - 24 edges
5. `PhoneNumber` - 24 edges
6. `Command` - 24 edges
7. `run_all()` - 24 edges
8. `enqueue_sms()` - 23 edges
2. `run_all()` - 28 edges
3. `make_user()` - 28 edges
4. `normalize_mobile()` - 27 edges
5. `Customer` - 24 edges
6. `PhoneNumber` - 24 edges
7. `Command` - 24 edges
8. `enqueue_sms()` - 24 edges
9. `SMSResult` - 23 edges
10. `Rip'n Fast. Fewer Tokens. Better Code.` - 23 edges
@@ -235,87 +255,75 @@
- **New Factor Creation Flow** — admin_panel_templates_admin_panel_customeridbackup_html, django_url_admin_panel_productsjson, api_endpoint_panel_productmojodi, admin_panel_templates_admin_panel_customeridbackup_html_orderlist, admin_panel_templates_admin_panel_customeridbackup_html_buildorderlist, django_url_admin_panel_newfactor [INFERRED]
- **Store and Inventory Management Flow** — admin_panel_templates_admin_panel_basestore, concept_url_admin_panel_stores, concept_url_admin_panel_productadd, concept_url_admin_panel_operations, concept_url_admin_panel_store_transfers [INFERRED]
## Communities (184 total, 89 thin omitted)
## Communities (207 total, 120 thin omitted)
### Community 0 - "views/sms.py"
Cohesion: 0.12
Nodes (18): _approve(), customer_sms_history(), _customer_url(), _notice(), parse_jalali_date(), parse_scan_since(), resolve_recipients_test(), sms_customer_toggle() (+10 more)
### Community 1 - "run_all"
Cohesion: 0.05
Nodes (9): Command, run_all(), apply_timing(), activate_sms(), AutoSendTests, ColdStartTests, ScannerTests, keys() (+1 more)
### Community 2 - "django_db"
Cohesion: 0.03
Nodes (38): Migration, Migration, Migration, Migration, Migration, Migration, Migration, Migration (+30 more)
Cohesion: 0.05
Nodes (20): Migration, Migration, Migration, Migration, Migration, Migration, Migration, Migration (+12 more)
### Community 3 - "create_transfer_request"
Cohesion: 0.10
Nodes (12): StoreTransferRequest, Meta, Status, approve_transfer_request(), cancel_transfer_request(), _check_source_store_permission(), _check_target_store_permission(), create_transfer_request() (+4 more)
### Community 3 - "views/store.py"
Cohesion: 0.05
Nodes (32): Product, Meta, ProductCategory, ProductInStore, Meta, MovementType, ProductStockHistory, Meta (+24 more)
### Community 5 - "PhoneCleanupTests"
Cohesion: 0.08
Nodes (9): Command, Migration, scrub_legacy_phones(), _append_note(), clean_all(), clean_customer(), clean_legacy_cell(), _numeric_tokens() (+1 more)
### Community 6 - ".load"
Cohesion: 0.12
Nodes (6): SMSProviderConfig, Meta, 12. Supported languages and formats, Languages, SettingsPanelTests, TestMessageSettingsTests
Cohesion: 0.19
Nodes (4): 12. Supported languages and formats, Languages, SettingsPanelTests, TestMessageSettingsTests
### Community 7 - "models/sms.py"
Cohesion: 0.12
Nodes (7): CustomerSMSSetting, Meta, SMSLog, Meta, SMSTemplate, Meta, SMSType
### Community 8 - "models/__init__.py"
Cohesion: 0.10
Nodes (4): remove_query_param(), check_financial_discrepancy(), run_360_audit(), run_audit()
Cohesion: 0.06
Nodes (15): CustomerSMSSetting, Meta, SMSDedup, Meta, SMSLog, Meta, SMSProviderConfig, Meta (+7 more)
### Community 9 - "views/customers.py"
Cohesion: 0.16
Nodes (11): customer(), customerDel(), customerFactor(), customerFactors(), customerHesabdari(), customerNew(), customerNew2(), customers() (+3 more)
Cohesion: 0.10
Nodes (18): normalize_primary_values(), customer_toggle_map(), notify_welcome(), customer(), customerDel(), customerEdit(), customerFactor(), customerFactors() (+10 more)
### Community 10 - "django_conf"
### Community 10 - "django_db_models_deletion"
Cohesion: 0.05
Nodes (23): Migration, Migration, Migration, Migration, Migration, Migration, Migration, Migration (+15 more)
Nodes (19): Migration, Migration, Migration, Migration, Migration, Migration, Migration, Migration (+11 more)
### Community 11 - "make_customer"
### Community 11 - "PanelTestCase"
Cohesion: 0.15
Nodes (3): make_customer(), EmptyQueueTests, PrimaryNumberPanelTests
Nodes (3): EmptyQueueTests, PanelTestCase, SentQueueProtectionTests
### Community 12 - "resolve_recipients"
Cohesion: 0.15
Nodes (4): resolve_recipients(), تست‌ها, mark_primary(), RecipientTests
### Community 12 - "make_customer"
Cohesion: 0.10
Nodes (7): resolve_recipients(), تست‌ها, make_customer(), DeletedStaysDeletedTests, mark_primary(), PrimaryNumberPanelTests, RecipientTests
### Community 14 - "views/finance.py"
Cohesion: 0.17
Nodes (20): Article, Meta, notify_payment_thanks(), customerHesabdariAdd(), addSarresid(), bedehi(), bedehiKhadamat(), bedehiNasb() (+12 more)
### Community 15 - "views/store.py"
Cohesion: 0.14
Nodes (16): Product, Meta, StoreRoom, Meta, newOperation(), newStore(), operation(), operationProduct() (+8 more)
Cohesion: 0.20
Nodes (18): Article, Meta, addSarresid(), bedehi(), bedehiKhadamat(), bedehiNasb(), financial_dashboard(), hesabdari() (+10 more)
### Community 16 - "sms0098ws.py"
Cohesion: 0.27
Nodes (3): classify_delivery(), DeliveryCode, describe_send_code()
Cohesion: 0.11
Nodes (5): to_local(), classify_delivery(), DeliveryCode, describe_send_code(), _as_text()
### Community 17 - "Admin Panel Customization"
Cohesion: 0.12
Nodes (10): CustomerSMSSettingAdmin, SMSLogAdmin, SMSProviderConfigAdmin, SMSProviderConfigForm, Meta, SMSQueueAdmin, SMSTemplateAdmin, StoreRoomAdmin (+2 more)
### Community 18 - "User"
Cohesion: 0.17
Nodes (18): User, Action, ActionType, Status, notify_visit_scheduled(), ActionList(), ActionListAll(), ActionListToday() (+10 more)
Cohesion: 0.07
Nodes (34): User, Action, ActionType, Status, VisitQueue, Factor, Meta, StoreRoom (+26 more)
### Community 19 - "timing_for"
### Community 19 - "apply_timing"
Cohesion: 0.12
Nodes (6): current_timing_value(), timing_for(), sms_template_edit(), sms_templates(), _templates_redirect(), TimingsTests
Nodes (7): apply_timing(), current_timing_value(), timing_for(), sms_template_edit(), sms_templates(), _templates_redirect(), TimingsTests
### Community 20 - "Customer"
### Community 20 - "InactiveCustomerHiddenTests"
Cohesion: 0.12
Nodes (5): Customer, login_superuser(), CustomerCrudTests, CustomerDeleteGuardTests, InactiveCustomerHiddenTests
Nodes (3): CustomerCrudTests, CustomerDeleteGuardTests, InactiveCustomerHiddenTests
### Community 21 - "phones.py"
Cohesion: 0.19
Nodes (7): normalize_primary_values(), resolved_recipients(), save_primary_normalized(), stored_primary_normalized(), usable_entries(), قالبِ ذخیره‌شده: `+989xxxxxxxxx`, قواعد اعتبارسنجی و قالب شماره
### Community 21 - "normalize_mobile"
Cohesion: 0.10
Nodes (13): customer_phone_entries(), _iter_sources(), PhoneEntry, resolved_recipients(), save_primary_normalized(), stored_primary_normalized(), to_display(), usable_entries() (+5 more)
### Community 22 - "Login Template Forms"
Cohesion: 0.11
@@ -326,7 +334,7 @@ Cohesion: 0.17
Nodes (6): api_check_sabt_inquiry(), api_check_status_inquiry(), parse_cheque_result_html(), run_bpi_cheque_inquiry_logic(), run_cbi_sayad_inquiry_logic(), solve_base64_captcha()
### Community 24 - "factories.py"
Cohesion: 0.18
Cohesion: 0.31
Nodes (5): ProductCategory, make_category(), make_product(), make_store(), _next()
### Community 25 - "Rip'n Fast. Fewer Tokens. Better Code."
@@ -334,8 +342,8 @@ Cohesion: 0.11
Nodes (18): Against the leading graph-database code-context MCP server, Better Code: It automates the review judgments nobody has time to make — every lens from published research, Documentation, Graph-Ranked Retrieval: It finds the right files more often than the alternatives, If it works on your codebase, tell us what it got wrong, Improve it with your agent, Licence, Quickstart (+10 more)
### Community 26 - "views/factors.py"
Cohesion: 0.14
Nodes (9): FactorIteam, notify_factor_survey(), FactorDel(), newFactor(), newFactor2(), InventoryException, _parse_jalali_date(), _parse_timestamp() (+1 more)
Cohesion: 0.12
Nodes (10): FactorIteam, notify_factor_survey(), notify_payment_thanks(), FactorDel(), newFactor(), newFactor2(), InventoryException, _parse_jalali_date() (+2 more)
### Community 27 - "sms0098.py"
Cohesion: 0.20
@@ -345,17 +353,17 @@ Nodes (3): _configured_codes(), _response_code(), ZeroZeroNinetyEightSMSProvider
Cohesion: 0.17
Nodes (3): dashBoxData(), resolve(), NoticeMechanismTests
### Community 32 - "scanner.py"
Cohesion: 0.23
Nodes (6): _aware(), _Budget, scan_cheque_funding(), scan_nesiye_due(), scan_visit(), _template()
### Community 31 - "name_fix.py"
Cohesion: 0.13
Nodes (6): build_name_index(), DatasetError, load_name_index(), normalize_name_key(), validate_dataset(), NameIndexTests
### Community 32 - "enqueue_sms"
Cohesion: 0.21
Nodes (7): enqueue_sms(), _aware(), _Budget, scan_cheque_funding(), scan_nesiye_due(), scan_visit(), _template()
### Community 33 - "ZeroZeroNinetyEightWebServiceTests"
Cohesion: 0.11
Nodes (5): SMSDeliveryResult, _as_text(), ZeroZeroNinetyEightWebServiceProvider, SMSDeliveryCommandTests, ZeroZeroNinetyEightWebServiceTests
### Community 34 - "ProductInStore"
Cohesion: 0.33
Nodes (3): ProductInStore, Meta, MovementType
Cohesion: 0.12
Nodes (4): SMSDeliveryResult, ZeroZeroNinetyEightWebServiceProvider, SMSDeliveryCommandTests, ZeroZeroNinetyEightWebServiceTests
### Community 35 - "admin_panel/templates/admin_panel/baseHesabdari.html"
Cohesion: 0.13
@@ -374,7 +382,7 @@ Cohesion: 0.19
Nodes (11): admin_panel:customerNew, admin_panel:pardakhtNesiye, /panel/passCheck/, all_sarresid, mandehesabKP, admin_panel.ViewReportMali, jQuery, pDatepicker JS Library (+3 more)
### Community 39 - "requests"
Cohesion: 0.21
Cohesion: 0.25
Nodes (4): AnnouncementRejected, place_announcement_call(), place_voip_call(), VoipCallResult
### Community 40 - "CustomerSmsHistoryTests"
@@ -386,40 +394,40 @@ Cohesion: 0.15
Nodes (12): **جدول کدهای خطای لینک ارتباطی**, **جدول کدهای خطای وب‌سرویس و وضعیت‌های دلیوری**, **راهنمای استفاده از Web Service**, **راهنمای استفاده از لینک ارتباطی (URL GET)**, **راهنمای استفاده از لینک ارتباطی و وب‌سرویس سامانه پیامک (0098vip.com و 0098sms.com)**, **فهرست مطالب**, **مقدمات اتصال**, **نمونه کدهای پیاده‌سازی** (+4 more)
### Community 43 - "enqueue.py"
Cohesion: 0.19
Nodes (5): format_amount(), format_jalali(), render_template(), _SafeDict, _sample_context()
Cohesion: 0.13
Nodes (6): customer_balance(), format_amount(), format_jalali(), render_template(), _SafeDict, _sample_context()
### Community 44 - "django_shortcuts"
Cohesion: 0.13
Nodes (3): Migration, ArticleType, Sarresid
Nodes (5): ArticleType, Sarresid, index(), `nest=` is a max, so it cannot tell a long function from a tangled one, The quality panel
### Community 45 - "stock_in"
Cohesion: 0.12
### Community 45 - "test_inventory.py"
Cohesion: 0.11
Nodes (11): Factor, FactorIteam, Product, ProductInStore, StoreRoom, VisitQueue, current_stock(), stock_in() (+3 more)
### Community 46 - "admin_panel/templates/admin_panel/customerAction.html"
Cohesion: 0.18
Nodes (9): admin_panel/templates/admin_panel/customerActionInc.html, admin_panel:customerNewAction URL, Action Data Model, DataTables Library, jQuery, Django 'url' template tag, Bulma CSS, DataTables.js (+1 more)
### Community 47 - "parts_count"
Cohesion: 0.16
Nodes (6): describe(), detect_encoding(), encoded_length(), is_gsm7(), parts_count(), SmsPartsTests
### Community 47 - "SmsReportTests"
Cohesion: 0.10
Nodes (7): describe(), detect_encoding(), encoded_length(), is_gsm7(), parts_count(), SmsPartsTests, SmsReportTests
### Community 48 - "write_tree"
Cohesion: 0.21
Nodes (6): Counts, format_header(), generate_snapshot(), normalized_path(), write_tree(), visit()
### Community 50 - "test_sms.py"
Cohesion: 0.21
Nodes (4): enqueue_sms(), is_sms_enabled(), EnqueueIdempotencyTests, RuleTests
### Community 50 - "TestCase"
Cohesion: 0.20
Nodes (3): is_sms_enabled(), EnqueueIdempotencyTests, RuleTests
### Community 51 - "Phone Cleanup Guide"
Cohesion: 0.17
Nodes (11): اجرا روی سرور اصلی (production), اجرای دستور, اصلاح دستی شماره‌های قدیمی مشتریان, راه جایگزین: مهاجرت (migration), قاعدهٔ پاک‌سازی, مشاهدهٔ نتیجه, نکته‌ها, چرا؟ (+3 more)
### Community 52 - ".run_command"
Cohesion: 0.13
Nodes (6): ApplyTests, CommandTestMixin, DryRunTests, ResilienceTests, flaky(), sha256_of()
### Community 52 - "Customer"
Cohesion: 0.10
Nodes (8): Customer, ApplyTests, CommandTestMixin, DatasetFixtureMixin, DryRunTests, ResilienceTests, flaky(), sha256_of()
### Community 53 - "project_tree.py"
Cohesion: 0.23
@@ -429,21 +437,13 @@ Nodes (6): is_excluded_file(), main(), non_negative_integer(), parse_args(), sor
Cohesion: 0.18
Nodes (8): 1. Codebase Navigation & Analysis (Ripwire vs. Graphify), 2. Dynamic Help, Self-Correction & Staleness, 3. Git Operations & Safety, 4. Development Server Lifecycle, 5. Environment, Frontend & PWA Safety, 6. End-to-End Browser Tests (Playwright), Project Operating & Agent Guidelines, Routing Gate & Pipeline
### Community 55 - "Command"
Cohesion: 0.05
Nodes (12): Command, build_name_index(), DatasetError, decide_prefix(), Decision, load_name_index(), NameIndex, normalize_name_key() (+4 more)
### Community 57 - "normalize_mobile"
Cohesion: 0.18
Nodes (5): to_display(), _clean(), normalize_mobile(), to_local(), NormalizeTests
### Community 58 - "Financial Data Objects"
Cohesion: 0.20
Nodes (9): Customer Data Object, Factor Data Object, Sarresid Data Object, admin_panel:customerNew URL, admin_panel:pardakhtNesiye URL, /panel/passCheck/ URL, admin_panel.ViewReportMali Permission, jQuery JS Library (+1 more)
### Community 60 - ".run_cmd"
Cohesion: 0.11
Nodes (5): ApplyTests, BatchAndCheckpointTests, flaky(), CommandTestCase, OverflowTests
Cohesion: 0.10
Nodes (6): ApplyTests, BatchAndCheckpointTests, flaky(), CommandTestCase, DryRunTests, make_phone()
### Community 62 - "SMS Timing Migration"
Cohesion: 0.22
@@ -461,6 +461,10 @@ Nodes (7): Client-side script in customerIDFactorBackup.html, URL: admin_panel:c
Cohesion: 0.36
Nodes (4): BaleBotProvider, ConsoleBaleProvider, get_bale_provider(), NotificationResult
### Community 66 - "os"
Cohesion: 0.20
Nodes (3): check_financial_discrepancy(), run_360_audit(), run_audit()
### Community 67 - "Graphify Tooling Docs"
Cohesion: 0.22
Nodes (8): Copilot Chat, graphify, graphify-out/graph.json, graphify-out/GRAPH_REPORT.md, graphify-out/wiki/index.md, graphify explain, graphify path, graphify query
@@ -473,9 +477,9 @@ Nodes (6): Customer Data Structure, kamaDatepicker Library, URL: admin_panel:cus
Cohesion: 0.25
Nodes (7): admin_panel:newFactor URL, Category Data, Client-side Form Logic, Customer Data, CSRF Token, custom_tags, static
### Community 73 - "SMSResult"
Cohesion: 0.11
Nodes (8): SMSProvider, SMSResult, webotp_message(), get_provider_class(), get_sms_provider(), reset_sms_provider(), HTTPSMSProvider, _FailingProvider
### Community 73 - "django_conf"
Cohesion: 0.13
Nodes (7): SMSProvider, SMSResult, webotp_message(), console_write(), ConsoleSMSProvider, HTTPSMSProvider, _FailingProvider
### Community 76 - "Timezone Migration History"
Cohesion: 0.29
@@ -489,6 +493,10 @@ Nodes (5): 3.1 Install a prebuilt binary, 3.2 Build from source, 3.3 Install the
Cohesion: 0.40
Nodes (5): In the numbers, In the output, In the tests, The honesty contract, What it misses, and what to run next
### Community 85 - "PhoneNumber"
Cohesion: 0.20
Nodes (4): PhoneNumber, IntegrationAndEfficiencyTests, OverflowTests, WelcomeTriggerTests
### Community 86 - "Output Format Specs"
Cohesion: 0.50
Nodes (4): 6.1 Output example, 6.2 Exit codes, 6.3 JSON output, 6. Output format
@@ -502,16 +510,16 @@ Cohesion: 0.67
Nodes (3): SMS Gateway Integration Guide, SMS Settings UI, Python Dependencies
### Community 89 - "CustomerNameFixJob"
Cohesion: 0.25
Nodes (6): CustomerNameFixJob, Meta, Status, مدل وضعیت job, چرا؟, سریع‌ترین مسیر روی Production (اگر همین یک دستور را می‌خواهی)
Cohesion: 0.18
Nodes (7): CacheTable, CustomerNameFixJob, Meta, Status, مدل وضعیت job, چرا؟, سریع‌ترین مسیر روی Production (اگر همین یک دستور را می‌خواهی)
### Community 90 - "Customer Modal UI"
Cohesion: 0.67
Nodes (3): createCS click handler, openRow click handler, Customer Modals UI
### Community 91 - "PanelTestCase"
Cohesion: 0.24
Nodes (3): DeletedStaysDeletedTests, PanelTestCase, SentQueueProtectionTests
### Community 91 - "decide_prefix"
Cohesion: 0.18
Nodes (4): decide_prefix(), Decision, NameIndex, DecidePrefixTests
### Community 92 - "Infrastructure Services"
Cohesion: 0.67
@@ -525,14 +533,6 @@ Nodes (3): 1. (Optional) Register the MCP server, 2. Install the skills, Set it
Cohesion: 0.67
Nodes (3): 4.1 Make the first map, 4.2 Use the common commands, 4. First use
### Community 95 - "ConsoleSMSProvider"
Cohesion: 0.21
Nodes (3): console_write(), ConsoleSMSProvider, ConsoleEncodingTests
### Community 96 - "PhoneNumber"
Cohesion: 0.22
Nodes (4): PhoneNumber, DryRunTests, make_phone(), WelcomeTriggerTests
### Community 97 - "تجمیع شماره‌های پیامک مشتریان"
Cohesion: 0.14
Nodes (13): الگوریتم (برای هر مشتری), بازگردانی (اگر نتیجه را نخواستی), بررسی صحت داده (SQL), تجمیع شماره‌های پیامک مشتریان, تعریف کلیدهای گزارش, خطاها و اقدام درست, عملکرد و بار روی دیتابیس, نمونه‌ها (+5 more)
@@ -541,46 +541,38 @@ Nodes (13): الگوریتم (برای هر مشتری), بازگردانی (ا
Cohesion: 0.15
Nodes (11): Base Template, Customer Base Template, New Customer Base Template, Create Factor View, Customer List View, Django Custom Tags: custom_tags, URL: admin_panel:newFactor, URL: admin_panel:operations (+3 more)
### Community 100 - "test_customer_name_fix.py"
Cohesion: 0.09
### Community 100 - "test_sms.py"
Cohesion: 0.10
Nodes (3): digit_string(), is_landline(), PureHelperTests
### Community 102 - "customer_phone_entries"
Cohesion: 0.20
Nodes (3): customer_phone_entries(), _iter_sources(), PhoneEntry
### Community 103 - "manage.py"
Cohesion: 0.24
Cohesion: 0.22
Nodes (5): entrypoint.sh script, main(), run.sh script, start_service(), usage()
### Community 104 - "افزودن پیشوند «آقای/خانم» به نام مشتریان"
Cohesion: 0.15
Nodes (12): SQLite، قفل دیتابیس و بار روی سایت, افزودن پیشوند «آقای/خانم» به نام مشتریان, بررسی صحت داده (SQL، پیش از/پس از اجرا), بکاپ/بازیابی خودکار نیست, بکاپ پیش از اجرای واقعی, تست‌ها, تعریف کلیدهای آماری, خطاهای رایج و اقدام درست (+4 more)
### Community 105 - "بازمحاسبهٔ «آخرین بازدید» مشتریان"
Cohesion: 0.20
Nodes (9): اجرا روی production, بازمحاسبهٔ «آخرین بازدید» مشتریان, بررسی صحت داده (SQL، پیش از اجرا), دستور, دقیقاً چه می‌کند؟, نمونهٔ خروجی, گزینه‌ها, ۱) اول «شبیه‌سازی» — بدون هیچ تغییری (+1 more)
### Community 107 - "signals.py"
Cohesion: 0.12
Nodes (4): AdminConfig, AdminPanelConfig, make_std_streams_unicode_safe(), configure_sqlite_connection()
### Community 113 - "primary_phones"
Cohesion: 0.25
Nodes (5): primary_phones(), primary_phones(), customerEdit(), _parse_phone_rows(), ⚠️ هشدار معنای داده (مهم‌ترین نکتهٔ این سند)
### Community 113 - "recipients.py"
Cohesion: 0.33
Nodes (3): primary_phones(), primary_phones(), ⚠️ هشدار معنای داده (مهم‌ترین نکتهٔ این سند)
### Community 163 - "Customer"
Cohesion: 0.29
Nodes (4): Customer, Meta, notify_welcome(), customersAdd()
Nodes (3): Customer, Meta, PhoneNumber
### Community 164 - "SMSQueue"
### Community 164 - "factory.py"
Cohesion: 0.29
Nodes (4): SMSQueue, DeliveryStatus, Meta, Status
Nodes (3): get_provider_class(), get_sms_provider(), reset_sms_provider()
### Community 166 - "توقف و ادامه (Resume / Checkpoint)"
Cohesion: 0.40
Nodes (5): اتمیک بودن, توقف و ادامه (Resume / Checkpoint), سلسله‌مراتب تصمیم نقطهٔ شروع, فایل وضعیت, گزینه‌های ادامه
### Community 169 - "index"
Cohesion: 0.67
Nodes (3): index(), `nest=` is a max, so it cannot tell a long function from a tangled one, The quality panel
### Community 170 - "الگوریتم تصمیم (به همین ترتیب)"
Cohesion: 0.50
Nodes (4): الگوریتم تصمیم (به همین ترتیب), برخورد با نام‌های تکراری و متناقض, نرمال‌سازی (فقط برای کلید جست‌وجو), نمونه‌های رفتاری
@@ -613,32 +605,28 @@ Nodes (3): ادامهٔ job پس از crash / قطع برق, دستورهای د
Cohesion: 0.67
Nodes (3): اجرا روی سرور Production, ترتیب دقیق, نکته‌ها برای Production
### Community 189 - "django_contrib_auth_models"
Cohesion: 0.12
Nodes (6): VisitQueue, ProductCategory, ProductStockHistory, Meta, StoreRoomOwner, UserProfile
## Ambiguous Edges - Review These
- `admin_panel/templates/admin_panel/baseHesabdari.html` → `Base Template` [AMBIGUOUS]
admin_panel/templates/admin_panel/baseHesabdari.html · relation: conceptually_related_to
## Knowledge Gaps
- **118 isolated node(s):** `base.html`, `toCurrency`, `DataTables Library`, `Customer Data Object`, `Factor Data Object` (+113 more)
These have ≤1 connection - possible missing edges or undocumented components. (Counts symbols only; 841 node(s) total have ≤1 connection when file, concept and rationale nodes are included.)
- **89 thin communities (<3 nodes) omitted from report** — run `graphify query` to explore isolated nodes.
These have ≤1 connection - possible missing edges or undocumented components. (Counts symbols only; 858 node(s) total have ≤1 connection when file, concept and rationale nodes are included.)
- **120 thin communities (<3 nodes) omitted from report** — run `graphify query` to explore isolated nodes.
## Suggested Questions
_Questions this graph is uniquely positioned to answer:_
- **Why does `make_customer()` connect `make_customer` to `run_all`, `PhoneCleanupTests`, `resolve_recipients`, `EventTriggerTests`, `Customer`, `factories.py`, `make_user`, `QueueReviewTests`, `NoticeMechanismTests`, `CustomerSmsHistoryTests`, `stock_in`, `CustomerInfoCardUITests`, `test_sms.py`, `Command`, `.run_cmd`, `IntegrationAndEfficiencyTests`, `PanelTestCase`, `ConsoleSMSProvider`, `PhoneNumber`, `test_customer_name_fix.py`, `customer_phone_entries`, `QueueStatsTests`?**
_High betweenness centrality (0.075) - this node is a cross-community bridge._
- **Why does `make_customer()` connect `make_customer` to `run_all`, `PhoneCleanupTests`, `PanelTestCase`, `EventTriggerTests`, `InactiveCustomerHiddenTests`, `factories.py`, `make_user`, `QueueReviewTests`, `NoticeMechanismTests`, `CustomerSmsHistoryTests`, `test_inventory.py`, `CustomerInfoCardUITests`, `TestCase`, `.test_deleted_visit_reminder_rearms_next_period_only`, `Customer`, `activate_sms`, `.run_cmd`, `.test_visit_period_comes_from_template`, `.test_period_start_filters_out_very_old_customers`, `PhoneNumber`, `ConsoleEncodingTests`, `test_factors.py`, `test_sms.py`, `WorkerTests`, `QueueStatsTests`?**
_High betweenness centrality (0.071) - this node is a cross-community bridge._
- **Are the 2 inferred relationships involving `make_customer()` (e.g. with `Customer` and `PhoneNumber`) actually correct?**
_`make_customer()` has 2 INFERRED edges - model-reasoned connections that need verification._
- **What connects `base.html`, `toCurrency`, `DataTables Library` to the rest of the system?**
_118 weakly-connected nodes found - possible documentation gaps or missing edges._
- **Should `views/sms.py` be split into smaller, more focused modules?**
_Cohesion score 0.1226890756302521 - nodes in this community are weakly interconnected._
- **Why does `Reference guide` connect `Reference guide` to `.load`, `Installation Guide`, `Output Format Specs`, `Rip'n Fast. Fewer Tokens. Better Code.`, `First Use Guide`?**
_High betweenness centrality (0.038) - this node is a cross-community bridge._
_Cohesion score 0.11746031746031746 - nodes in this community are weakly interconnected._
- **Why does `Rip'n Fast. Fewer Tokens. Better Code.` connect `Rip'n Fast. Fewer Tokens. Better Code.` to `Reference guide`, `.load`, `django_shortcuts`, `Project Honesty Contract`, `Agent Operating Guidelines`, `Performance Benchmarks`, `Agent Setup Guide`?**
_High betweenness centrality (0.053) - this node is a cross-community bridge._
- **Are the 2 inferred relationships involving `normalize_mobile()` (e.g. with `الگوریتم (برای هر مشتری)` and `قالبِ ذخیره‌شده: `+989xxxxxxxxx``) actually correct?**
_`normalize_mobile()` has 2 INFERRED edges - model-reasoned connections that need verification._
+4 -4
View File
@@ -63,12 +63,12 @@
</div>
<div id="legend"></div>
</div>
<div id="stats">1960 nodes &middot; 3584 edges &middot; 184 communities</div>
<div id="stats">1991 nodes &middot; 3632 edges &middot; 207 communities</div>
</div>
<script>
const RAW_NODES = [{"id": "admin_panel_management_commands_sms_scan_command", "label": "Command", "color": {"background": "#F28E2B", "border": "#F28E2B", "highlight": {"background": "#ffffff", "border": "#F28E2B"}}, "size": 12.2, "font": {"size": 0, "color": "#ffffff"}, "title": "Command", "community": 1, "community_name": "run_all", "source_file": "admin_panel/management/commands/sms_scan.py", "file_type": "code", "degree": 7}, {"id": "tests_test_sms_autosendtests", "label": "AutoSendTests", "color": {"background": "#F28E2B", "border": "#F28E2B", "highlight": {"background": "#ffffff", "border": "#F28E2B"}}, "size": 13.8, "font": {"size": 0, "color": "#ffffff"}, "title": "AutoSendTests", "community": 1, "community_name": "run_all", "source_file": "tests/test_sms.py", "file_type": "code", "degree": 12}, {"id": "tests_test_sms_coldstarttests", "label": "ColdStartTests", "color": {"background": "#F28E2B", "border": "#F28E2B", "highlight": {"background": "#ffffff", "border": "#F28E2B"}}, "size": 13.8, "font": {"size": 0, "color": "#ffffff"}, "title": "ColdStartTests", "community": 1, "community_name": "run_all", "source_file": "tests/test_sms.py", "file_type": "code", "degree": 12}, {"id": "tests_test_sms_scannertests", "label": "ScannerTests", "color": {"background": "#F28E2B", "border": "#F28E2B", "highlight": {"background": "#ffffff", "border": "#F28E2B"}}, "size": 14.7, "font": {"size": 12, "color": "#ffffff"}, "title": "ScannerTests", "community": 1, "community_name": "run_all", "source_file": "tests/test_sms.py", "file_type": "code", "degree": 15}, {"id": "tests_test_sms_workertests", "label": "WorkerTests", "color": {"background": "#F28E2B", "border": "#F28E2B", "highlight": {"background": "#ffffff", "border": "#F28E2B"}}, "size": 12.8, "font": {"size": 0, "color": "#ffffff"}, "title": "WorkerTests", "community": 1, "community_name": "run_all", "source_file": "tests/test_sms.py", "file_type": "code", "degree": 9}, {"id": "admin_panel_migrations_0001_initial_migration", "label": "Migration", "color": {"background": "#4E79A7", "border": "#4E79A7", "highlight": {"background": "#ffffff", "border": "#4E79A7"}}, "size": 10.3, "font": {"size": 0, "color": "#ffffff"}, "title": "Migration", "community": 10, "community_name": "django_conf", "source_file": "admin_panel/migrations/0001_initial.py", "file_type": "code", "degree": 1}, {"id": "admin_panel_migrations_0007_productinstore_factor_migration", "label": "Migration", "color": {"background": "#4E79A7", "border": "#4E79A7", "highlight": {"background": "#ffffff", "border": "#4E79A7"}}, "size": 10.3, "font": {"size": 0, "color": "#ffffff"}, "title": "Migration", "community": 10, "community_name": "django_conf", "source_file": "admin_panel/migrations/0007_productinstore_factor.py", "file_type": "code", "degree": 1}, {"id": "admin_panel_migrations_0008_storeroom_user_migration", "label": "Migration", "color": {"background": "#4E79A7", "border": "#4E79A7", "highlight": {"background": "#ffffff", "border": "#4E79A7"}}, "size": 10.3, "font": {"size": 0, "color": "#ffffff"}, "title": "Migration", "community": 10, "community_name": "django_conf", "source_file": "admin_panel/migrations/0008_storeroom_user.py", "file_type": "code", "degree": 1}, {"id": "admin_panel_migrations_0011_storeroom_user_migration", "label": "Migration", "color": {"background": "#4E79A7", "border": "#4E79A7", "highlight": {"background": "#ffffff", "border": "#4E79A7"}}, "size": 10.3, "font": {"size": 0, "color": "#ffffff"}, "title": "Migration", "community": 10, "community_name": "django_conf", "source_file": "admin_panel/migrations/0011_storeroom_user.py", "file_type": "code", "degree": 1}, {"id": "admin_panel_migrations_0012_visitqueue_user_migration", "label": "Migration", "color": {"background": "#4E79A7", "border": "#4E79A7", "highlight": {"background": "#ffffff", "border": "#4E79A7"}}, "size": 10.3, "font": {"size": 0, "color": "#ffffff"}, "title": "Migration", "community": 10, "community_name": "django_conf", "source_file": "admin_panel/migrations/0012_visitqueue_user.py", "file_type": "code", "degree": 1}, {"id": "admin_panel_migrations_0015_auto_20220124_0858_migration", "label": "Migration", "color": {"background": "#4E79A7", "border": "#4E79A7", "highlight": {"background": "#ffffff", "border": "#4E79A7"}}, "size": 10.3, "font": {"size": 0, "color": "#ffffff"}, "title": "Migration", "community": 10, "community_name": "django_conf", "source_file": "admin_panel/migrations/0015_auto_20220124_0858.py", "file_type": "code", "degree": 1}, {"id": "admin_panel_migrations_0016_factor_user_migration", "label": "Migration", "color": {"background": "#4E79A7", "border": "#4E79A7", "highlight": {"background": "#ffffff", "border": "#4E79A7"}}, "size": 10.3, "font": {"size": 0, "color": "#ffffff"}, "title": "Migration", "community": 10, "community_name": "django_conf", "source_file": "admin_panel/migrations/0016_factor_user.py", "file_type": "code", "degree": 1}, {"id": "admin_panel_migrations_0018_alterLine truncated
const RAW_EDGES = [{"from": "admin_panel_management_commands_sms_scan", "to": "admin_panel_management_commands_sms_scan_command", "label": "contains", "title": "contains [EXTRACTED]", "dashes": false, "width": 2, "color": {"opacity": 0.7}, "confidence": "EXTRACTED"}, {"from": "admin_panel_management_commands_sms_scan_command", "to": "admin_panel_management_commands_sms_scan_py_basecommand", "label": "inherits", "title": "inherits [EXTRACTED]", "dashes": false, "width": 2, "color": {"opacity": 0.7}, "confidence": "EXTRACTED"}, {"from": "admin_panel_management_commands_sms_scan_command", "to": "admin_panel_management_commands_sms_scan_command_init", "label": "method", "title": "method [EXTRACTED]", "dashes": false, "width": 2, "color": {"opacity": 0.7}, "confidence": "EXTRACTED"}, {"from": "admin_panel_management_commands_sms_scan_command", "to": "admin_panel_management_commands_sms_scan_command_add_arguments", "label": "method", "title": "method [EXTRACTED]", "dashes": false, "width": 2, "color": {"opacity": 0.7}, "confidence": "EXTRACTED"}, {"from": "admin_panel_management_commands_sms_scan_command", "to": "admin_panel_management_commands_sms_scan_command_handle", "label": "method", "title": "method [EXTRACTED]", "dashes": false, "width": 2, "color": {"opacity": 0.7}, "confidence": "EXTRACTED"}, {"from": "admin_panel_management_commands_sms_scan_command", "to": "admin_panel_management_commands_sms_scan_command_handle_signal", "label": "method", "title": "method [EXTRACTED]", "dashes": false, "width": 2, "color": {"opacity": 0.7}, "confidence": "EXTRACTED"}, {"from": "admin_panel_management_commands_sms_scan_command", "to": "admin_panel_management_commands_sms_scan_command_emit", "label": "method", "title": "method [EXTRACTED]", "dashes": false, "width": 2, "color": {"opacity": 0.7}, "confidence": "EXTRACTED"}, {"from": "tests_test_sms", "to": "tests_test_sms_autosendtests", "label": "contains", "title": "contains [EXTRACTED]", "dashes": false, "width": 2, "color": {"opacity": 0.7}, "confidence": "EXTRACTED"}, {"from": "tests_test_sms_autosendtests", "to": "tests_test_sms_paneltestcase", "label": "inherits", "title": "inherits [EXTRACTED]", "dashes": false, "width": 2, "color": {"opacity": 0.7}, "confidence": "EXTRACTED"}, {"from": "tests_test_sms_autosendtests", "to": "tests_test_sms_autosendtests_overdue_visit_customer", "label": "method", "title": "method [EXTRACTED]", "dashes": false, "width": 2, "color": {"opacity": 0.7}, "confidence": "EXTRACTED"}, {"from": "tests_test_sms_autosendtests", "to": "tests_test_sms_autosendtests_test_default_is_pending_so_manager_must_approve", "label": "method", "title": "method [EXTRACTED]", "dashes": false, "width": 2, "color": {"opacity": 0.7}, "confidence": "EXTRACTED"}, {"from": "tests_test_sms_autosendtests", "to": "tests_test_sms_autosendtests_test_auto_send_enqueues_approved_without_manager", "label": "method", "title": "method [EXTRACTED]", "dashes": false, "width": 2, "color": {"opacity": 0.7}, "confidence": "EXTRACTED"}, {"from": "tests_test_sms_autosendtests", "to": "tests_test_sms_autosendtests_test_auto_send_does_not_bypass_opt_out_or_disabled", "label": "method", "title": "method [EXTRACTED]", "dashes": false, "width": 2, "color": {"opacity": 0.7}, "confidence": "EXTRACTED"}, {"from": "tests_test_sms_autosendtests", "to": "tests_test_sms_autosendtests_test_welcome_auto_send_skips_approval_on_customer_creation", "label": "method", "title": "method [EXTRACTED]", "dashes": false, "width": 2, "color": {"opacity": 0.7}, "confidence": "EXTRACTED"}, {"from": "tests_test_sms_autosendtests", "to": "tests_test_sms_autosendtests_test_template_edit_persists_auto_send", "label": "method", "title": "method [EXTRACTED]", "dashes": false, "width": 2, "color": {"opacity": 0.7}, "confidence": "EXTRACTED"}, {"from": "tests_test_sms_autosendtests", "to": "tests_test_sms_autosendtests_test_templates_page_shows_auto_send_switch", "label": "method", "title": "method [EXTRACTED]", "dashes": false, "width": 2, "color": {"opacity": 0.7}, "confidence": "EXTRACTED"}, {"from": "tests_test_sms_rationale_1069", "to": "tests_test_sms_autosendtests", "label": "rationale_for", "title": "rationale_for [EXTRACTED]", "dashes": false, "width": 2, "color": {"opacity": 0.7}, "confidence": "EXTRACTED"}, {"from": "tests_test_sms_autosendtests", "to": "admin_models_phonenumber", "label": "uses", "title": "uses [INFERRED]", "dashes": true, "width": 1, "color": {"opacity": 0.35}, "confidence": "INFERRED"}, {"from": "tests_test_sms_autosendtests", "to": "admin_models_customer", "label": "uses", "title": "uses [INFERRED]", "dashes": true, "width": 1, "color": {"opacity": 0.35}, "confidence": "INFERRED"}, {"from": "tests_test_sms", "to": "tests_test_sms_coldstarttests", "label": "contains", "title": "contains [EXTRACTED]", "dashes": false, "width": 2, "color": {"opacity": 0.7}, "confidence": "EXTRACTED"}, {"from": "tests_test_sms_coldstarttests", "to": "tests_test_sms_py_testcase", "label": "inherits", "Line truncated
const LEGEND = [{"cid": 0, "color": "#4E79A7", "label": "views/sms.py", "count": 35}, {"cid": 1, "color": "#F28E2B", "label": "run_all", "count": 65}, {"cid": 2, "color": "#E15759", "label": "django_db", "count": 79}, {"cid": 3, "color": "#76B7B2", "label": "create_transfer_request", "count": 42}, {"cid": 4, "color": "#59A14F", "label": "ModelsPackageContractTests", "count": 14}, {"cid": 5, "color": "#EDC948", "label": "PhoneCleanupTests", "count": 33}, {"cid": 6, "color": "#B07AA1", "label": ".load", "count": 19}, {"cid": 7, "color": "#FF9DA7", "label": "models/sms.py", "count": 16}, {"cid": 8, "color": "#9C755F", "label": "models/__init__.py", "count": 39}, {"cid": 9, "color": "#BAB0AC", "label": "views/customers.py", "count": 19}, {"cid": 10, "color": "#4E79A7", "label": "django_conf", "count": 50}, {"cid": 11, "color": "#F28E2B", "label": "make_customer", "count": 20}, {"cid": 12, "color": "#E15759", "label": "resolve_recipients", "count": 19}, {"cid": 13, "color": "#76B7B2", "label": "EventTriggerTests", "count": 22}, {"cid": 14, "color": "#59A14F", "label": "views/finance.py", "count": 27}, {"cid": 15, "color": "#EDC948", "label": "views/store.py", "count": 26}, {"cid": 16, "color": "#B07AA1", "label": "sms0098ws.py", "count": 10}, {"cid": 17, "color": "#FF9DA7", "label": "Admin Panel Customization", "count": 22}, {"cid": 18, "color": "#9C755F", "label": "User", "count": 24}, {"cid": 19, "color": "#BAB0AC", "label": "timing_for", "count": 20}, {"cid": 20, "color": "#4E79A7", "label": "Customer", "count": 22}, {"cid": 21, "color": "#F28E2B", "label": "phones.py", "count": 14}, {"cid": 22, "color": "#E15759", "label": "Login Template Forms", "count": 19}, {"cid": 23, "color": "#76B7B2", "label": "cheque_inquiry.py", "count": 20}, {"cid": 24, "color": "#59A14F", "label": "factories.py", "count": 21}, {"cid": 25, "color": "#EDC948", "label": "Rip&#x27;n Fast. Fewer Tokens. Better Code.", "count": 18}, {"cid": 26, "color": "#B07AA1", "label": "views/factors.py", "count": 25}, {"cid": 27, "color": "#FF9DA7", "label": "sms0098.py", "count": 14}, {"cid": 28, "color": "#9C755F", "label": "make_user", "count": 23}, {"cid": 29, "color": "#BAB0AC", "label": "QueueReviewTests", "count": 9}, {"cid": 30, "color": "#4E79A7", "label": "NoticeMechanismTests", "count": 16}, {"cid": 31, "color": "#F28E2B", "label": "SmsReportTests", "count": 17}, {"cid": 32, "color": "#E15759", "label": "scanner.py", "count": 16}, {"cid": 33, "color": "#76B7B2", "label": "ZeroZeroNinetyEightWebServiceTests", "count": 38}, {"cid": 34, "color": "#59A14F", "label": "ProductInStore", "count": 6}, {"cid": 35, "color": "#EDC948", "label": "admin_panel/templates/admin_panel/baseHesabdari.html", "count": 15}, {"cid": 36, "color": "#B07AA1", "label": "Reference guide", "count": 14}, {"cid": 37, "color": "#FF9DA7", "label": "Factor Creation UI", "count": 14}, {"cid": 38, "color": "#9C755F", "label": "hesabdariResidNashode.html", "count": 14}, {"cid": 39, "color": "#BAB0AC", "label": "requests", "count": 13}, {"cid": 40, "color": "#4E79A7", "label": "CustomerSmsHistoryTests", "count": 24}, {"cid": 41, "color": "#F28E2B", "label": "TemplatesPageUITests", "count": 14}, {"cid": 42, "color": "#E15759", "label": "SMS Integration Docs", "count": 13}, {"cid": 43, "color": "#76B7B2", "label": "enqueue.py", "count": 14}, {"cid": 44, "color": "#59A14F", "label": "django_shortcuts", "count": 15}, {"cid": 45, "color": "#EDC948", "label": "stock_in", "count": 27}, {"cid": 46, "color": "#B07AA1", "label": "admin_panel/templates/admin_panel/customerAction.html", "count": 13}, {"cid": 47, "color": "#FF9DA7", "label": "parts_count", "count": 22}, {"cid": 48, "color": "#9C755F", "label": "write_tree", "count": 14}, {"cid": 49, "color": "#BAB0AC", "label": "CustomerInfoCardUITests", "count": 12}, {"cid": 50, "color": "#4E79A7", "label": "test_sms.py", "count": 14}, {"cid": 51, "color": "#F28E2B", "label": "Phone Cleanup Guide", "count": 12}, {"cid": 52, "color": "#E15759", "label": ".run_command", "count": 40}, {"cid": 53, "color": "#76B7B2", "label": "project_tree.py", "count": 14}, {"cid": 54, "color": "#59A14F", "label": "Agent Operating Guidelines", "count": 11}, {"cid": 55, "color": "#EDC948", "label": "Command", "count": 72}, {"cid": 56, "color": "#B07AA1", "label": "BehinSMSProvider", "count": 8}, {"cid": 57, "color": "#FF9DA7", "label": "normalize_mobile", "count": 17}, {"cid": 58, "color": "#9C755F", "label": "Financial Data Objects", "count": 10}, {"cid": 59, "color": "#BAB0AC", "label": "Hybrid Migration Command", "count": 10}, {"cid": 60, "color": "#4E79A7", "label": ".run_cmd", "count": 34}, {"cid": 61, "color": "#F28E2B", "label": "Resource Cleanup Handler", "count": 10}, {"cid": 62, "color": "#E15759", "label": "SMS Timing Migration", "count": 10}, {"cid": 63, "color": "#76B7B2", "label": "admin_panel/templates/admin_panel/bedehi.html", "count": 10}, {"cid": 64, "color": "#59A14F", "label": "customerIDFactorBackup.html", "count": 10}, {"cid": 65, "colLine truncated
const RAW_NODES = [{"id": "tests_test_sms_autosendtests", "label": "AutoSendTests", "color": {"background": "#F28E2B", "border": "#F28E2B", "highlight": {"background": "#ffffff", "border": "#F28E2B"}}, "size": 13.7, "font": {"size": 0, "color": "#ffffff"}, "title": "AutoSendTests", "community": 1, "community_name": "run_all", "source_file": "tests/test_sms.py", "file_type": "code", "degree": 12}, {"id": "admin_panel_migrations_0001_initial_migration", "label": "Migration", "color": {"background": "#4E79A7", "border": "#4E79A7", "highlight": {"background": "#ffffff", "border": "#4E79A7"}}, "size": 10.3, "font": {"size": 0, "color": "#ffffff"}, "title": "Migration", "community": 10, "community_name": "django_db_models_deletion", "source_file": "admin_panel/migrations/0001_initial.py", "file_type": "code", "degree": 1}, {"id": "admin_panel_migrations_0007_productinstore_factor_migration", "label": "Migration", "color": {"background": "#4E79A7", "border": "#4E79A7", "highlight": {"background": "#ffffff", "border": "#4E79A7"}}, "size": 10.3, "font": {"size": 0, "color": "#ffffff"}, "title": "Migration", "community": 10, "community_name": "django_db_models_deletion", "source_file": "admin_panel/migrations/0007_productinstore_factor.py", "file_type": "code", "degree": 1}, {"id": "admin_panel_migrations_0008_storeroom_user_migration", "label": "Migration", "color": {"background": "#4E79A7", "border": "#4E79A7", "highlight": {"background": "#ffffff", "border": "#4E79A7"}}, "size": 10.3, "font": {"size": 0, "color": "#ffffff"}, "title": "Migration", "community": 10, "community_name": "django_db_models_deletion", "source_file": "admin_panel/migrations/0008_storeroom_user.py", "file_type": "code", "degree": 1}, {"id": "admin_panel_migrations_0011_storeroom_user_migration", "label": "Migration", "color": {"background": "#4E79A7", "border": "#4E79A7", "highlight": {"background": "#ffffff", "border": "#4E79A7"}}, "size": 10.3, "font": {"size": 0, "color": "#ffffff"}, "title": "Migration", "community": 10, "community_name": "django_db_models_deletion", "source_file": "admin_panel/migrations/0011_storeroom_user.py", "file_type": "code", "degree": 1}, {"id": "admin_panel_migrations_0012_visitqueue_user_migration", "label": "Migration", "color": {"background": "#4E79A7", "border": "#4E79A7", "highlight": {"background": "#ffffff", "border": "#4E79A7"}}, "size": 10.3, "font": {"size": 0, "color": "#ffffff"}, "title": "Migration", "community": 10, "community_name": "django_db_models_deletion", "source_file": "admin_panel/migrations/0012_visitqueue_user.py", "file_type": "code", "degree": 1}, {"id": "admin_panel_migrations_0016_factor_user_migration", "label": "Migration", "color": {"background": "#4E79A7", "border": "#4E79A7", "highlight": {"background": "#ffffff", "border": "#4E79A7"}}, "size": 10.3, "font": {"size": 0, "color": "#ffffff"}, "title": "Migration", "community": 10, "community_name": "django_db_models_deletion", "source_file": "admin_panel/migrations/0016_factor_user.py", "file_type": "code", "degree": 1}, {"id": "admin_panel_migrations_0018_alter_factoriteam_product_migration", "label": "Migration", "color": {"background": "#4E79A7", "border": "#4E79A7", "highlight": {"background": "#ffffff", "border": "#4E79A7"}}, "size": 10.3, "font": {"size": 0, "color": "#ffffff"}, "title": "Migration", "community": 10, "community_name": "django_db_models_deletion", "source_file": "admin_panel/migrations/0018_alter_factoriteam_product.py", "file_type": "code", "degree": 1}, {"id": "admin_panel_migrations_0019_article_articletype_migration", "label": "Migration", "color": {"background": "#4E79A7", "border": "#4E79A7", "highlight": {"background": "#ffffff", "border": "#4E79A7"}}, "size": 10.3, "font": {"size": 0, "color": "#ffffff"}, "title": "Migration", "community": 10, "community_name": "django_db_models_deletion", "source_file": "admin_panel/migrations/0019_article_articletype.py", "file_type": "code", "degree": 1}, {"id": "admin_panel_migrations_0024_action_migration", "label": "Migration", "color": {"background": "#4E79A7", "border": "#4E79A7", "highlight": {"background": "#ffffff", "border": "#4E79A7"}}, "size": 10.3, "font": {"size": 0, "color": "#ffffff"}, "title": "Migration", "community": 10, "community_name": "django_db_models_deletion", "source_file": "admin_panel/migrations/0024_action.py", "file_type": "code", "degree": 1}, {"id": "admin_panel_migrations_0036_sarresid_migration", "label": "Migration", "color": {"background": "#4E79A7", "border": "#4E79A7", "highlight": {"background": "#ffffff", "border": "#4E79A7"}}, "size": 10.3, "font": {"size": 0, "color": "#ffffff"}, "title": "Migration", "community": 10, "community_name": "django_db_models_deletion", "source_file": "admin_panel/migrations/0036_sarresid.py", "file_type": "code", "degree": 1}, {"id": "admin_panel_migrations_0038_auto_20240213_1735_migration", "label": "Migration", "color": {"background": "#4E79A7", "border": "#4E79A7", "highlight": {"backgrounLine truncated
const RAW_EDGES = [{"from": "tests_test_sms", "to": "tests_test_sms_autosendtests", "label": "contains", "title": "contains [EXTRACTED]", "dashes": false, "width": 2, "color": {"opacity": 0.7}, "confidence": "EXTRACTED"}, {"from": "tests_test_sms_autosendtests", "to": "tests_test_sms_paneltestcase", "label": "inherits", "title": "inherits [EXTRACTED]", "dashes": false, "width": 2, "color": {"opacity": 0.7}, "confidence": "EXTRACTED"}, {"from": "tests_test_sms_autosendtests", "to": "tests_test_sms_autosendtests_overdue_visit_customer", "label": "method", "title": "method [EXTRACTED]", "dashes": false, "width": 2, "color": {"opacity": 0.7}, "confidence": "EXTRACTED"}, {"from": "tests_test_sms_autosendtests", "to": "tests_test_sms_autosendtests_test_default_is_pending_so_manager_must_approve", "label": "method", "title": "method [EXTRACTED]", "dashes": false, "width": 2, "color": {"opacity": 0.7}, "confidence": "EXTRACTED"}, {"from": "tests_test_sms_autosendtests", "to": "tests_test_sms_autosendtests_test_auto_send_enqueues_approved_without_manager", "label": "method", "title": "method [EXTRACTED]", "dashes": false, "width": 2, "color": {"opacity": 0.7}, "confidence": "EXTRACTED"}, {"from": "tests_test_sms_autosendtests", "to": "tests_test_sms_autosendtests_test_auto_send_does_not_bypass_opt_out_or_disabled", "label": "method", "title": "method [EXTRACTED]", "dashes": false, "width": 2, "color": {"opacity": 0.7}, "confidence": "EXTRACTED"}, {"from": "tests_test_sms_autosendtests", "to": "tests_test_sms_autosendtests_test_welcome_auto_send_skips_approval_on_customer_creation", "label": "method", "title": "method [EXTRACTED]", "dashes": false, "width": 2, "color": {"opacity": 0.7}, "confidence": "EXTRACTED"}, {"from": "tests_test_sms_autosendtests", "to": "tests_test_sms_autosendtests_test_template_edit_persists_auto_send", "label": "method", "title": "method [EXTRACTED]", "dashes": false, "width": 2, "color": {"opacity": 0.7}, "confidence": "EXTRACTED"}, {"from": "tests_test_sms_autosendtests", "to": "tests_test_sms_autosendtests_test_templates_page_shows_auto_send_switch", "label": "method", "title": "method [EXTRACTED]", "dashes": false, "width": 2, "color": {"opacity": 0.7}, "confidence": "EXTRACTED"}, {"from": "tests_test_sms_rationale_1183", "to": "tests_test_sms_autosendtests", "label": "rationale_for", "title": "rationale_for [EXTRACTED]", "dashes": false, "width": 2, "color": {"opacity": 0.7}, "confidence": "EXTRACTED"}, {"from": "tests_test_sms_autosendtests", "to": "admin_models_phonenumber", "label": "uses", "title": "uses [INFERRED]", "dashes": true, "width": 1, "color": {"opacity": 0.35}, "confidence": "INFERRED"}, {"from": "tests_test_sms_autosendtests", "to": "admin_models_customer", "label": "uses", "title": "uses [INFERRED]", "dashes": true, "width": 1, "color": {"opacity": 0.35}, "confidence": "INFERRED"}, {"from": "admin_panel_migrations_0001_initial", "to": "admin_panel_migrations_0001_initial_migration", "label": "contains", "title": "contains [EXTRACTED]", "dashes": false, "width": 2, "color": {"opacity": 0.7}, "confidence": "EXTRACTED"}, {"from": "admin_panel_migrations_0007_productinstore_factor", "to": "admin_panel_migrations_0007_productinstore_factor_migration", "label": "contains", "title": "contains [EXTRACTED]", "dashes": false, "width": 2, "color": {"opacity": 0.7}, "confidence": "EXTRACTED"}, {"from": "admin_panel_migrations_0008_storeroom_user", "to": "admin_panel_migrations_0008_storeroom_user_migration", "label": "contains", "title": "contains [EXTRACTED]", "dashes": false, "width": 2, "color": {"opacity": 0.7}, "confidence": "EXTRACTED"}, {"from": "admin_panel_migrations_0011_storeroom_user", "to": "admin_panel_migrations_0011_storeroom_user_migration", "label": "contains", "title": "contains [EXTRACTED]", "dashes": false, "width": 2, "color": {"opacity": 0.7}, "confidence": "EXTRACTED"}, {"from": "admin_panel_migrations_0012_visitqueue_user", "to": "admin_panel_migrations_0012_visitqueue_user_migration", "label": "contains", "title": "contains [EXTRACTED]", "dashes": false, "width": 2, "color": {"opacity": 0.7}, "confidence": "EXTRACTED"}, {"from": "admin_panel_migrations_0016_factor_user", "to": "admin_panel_migrations_0016_factor_user_migration", "label": "contains", "title": "contains [EXTRACTED]", "dashes": false, "width": 2, "color": {"opacity": 0.7}, "confidence": "EXTRACTED"}, {"from": "admin_panel_migrations_0018_alter_factoriteam_product", "to": "admin_panel_migrations_0018_alter_factoriteam_product_migration", "label": "contains", "title": "contains [EXTRACTED]", "dashes": false, "width": 2, "color": {"opacity": 0.7}, "confidence": "EXTRACTED"}, {"from": "admin_panel_migrations_0019_article_articletype", "to": "admin_panel_migrations_0019_article_articletype_migration", "label": "contains", "title": "contains [EXTRACTED]", "dashes": false, "width": 2, "color": {"opacity": 0.7}, "confidence": "EXTRACTED"}, {"from": "admin_panel_migrations_0024_action", "to": "adminLine truncated
const LEGEND = [{"cid": 0, "color": "#4E79A7", "label": "views/sms.py", "count": 36}, {"cid": 1, "color": "#F28E2B", "label": "run_all", "count": 15}, {"cid": 2, "color": "#E15759", "label": "django_db", "count": 41}, {"cid": 3, "color": "#76B7B2", "label": "views/store.py", "count": 73}, {"cid": 4, "color": "#59A14F", "label": "ModelsPackageContractTests", "count": 8}, {"cid": 5, "color": "#EDC948", "label": "PhoneCleanupTests", "count": 31}, {"cid": 6, "color": "#B07AA1", "label": ".load", "count": 13}, {"cid": 7, "color": "#FF9DA7", "label": "models/sms.py", "count": 32}, {"cid": 8, "color": "#9C755F", "label": "django_db_models", "count": 27}, {"cid": 9, "color": "#BAB0AC", "label": "views/customers.py", "count": 32}, {"cid": 10, "color": "#4E79A7", "label": "django_db_models_deletion", "count": 39}, {"cid": 11, "color": "#F28E2B", "label": "PanelTestCase", "count": 16}, {"cid": 12, "color": "#E15759", "label": "make_customer", "count": 37}, {"cid": 13, "color": "#76B7B2", "label": "EventTriggerTests", "count": 22}, {"cid": 14, "color": "#59A14F", "label": "views/finance.py", "count": 24}, {"cid": 15, "color": "#EDC948", "label": "django_contrib", "count": 7}, {"cid": 16, "color": "#B07AA1", "label": "sms0098ws.py", "count": 23}, {"cid": 17, "color": "#FF9DA7", "label": "Admin Panel Customization", "count": 22}, {"cid": 18, "color": "#9C755F", "label": "User", "count": 49}, {"cid": 19, "color": "#BAB0AC", "label": "apply_timing", "count": 22}, {"cid": 20, "color": "#4E79A7", "label": "InactiveCustomerHiddenTests", "count": 16}, {"cid": 21, "color": "#F28E2B", "label": "normalize_mobile", "count": 31}, {"cid": 22, "color": "#E15759", "label": "Login Template Forms", "count": 19}, {"cid": 23, "color": "#76B7B2", "label": "cheque_inquiry.py", "count": 20}, {"cid": 24, "color": "#59A14F", "label": "factories.py", "count": 13}, {"cid": 25, "color": "#EDC948", "label": "Rip&#x27;n Fast. Fewer Tokens. Better Code.", "count": 18}, {"cid": 26, "color": "#B07AA1", "label": "views/factors.py", "count": 29}, {"cid": 27, "color": "#FF9DA7", "label": "sms0098.py", "count": 14}, {"cid": 28, "color": "#9C755F", "label": "make_user", "count": 23}, {"cid": 29, "color": "#BAB0AC", "label": "QueueReviewTests", "count": 9}, {"cid": 30, "color": "#4E79A7", "label": "NoticeMechanismTests", "count": 16}, {"cid": 31, "color": "#F28E2B", "label": "name_fix.py", "count": 24}, {"cid": 32, "color": "#E15759", "label": "enqueue_sms", "count": 18}, {"cid": 33, "color": "#76B7B2", "label": "ZeroZeroNinetyEightWebServiceTests", "count": 35}, {"cid": 34, "color": "#59A14F", "label": "ScannerTests", "count": 23}, {"cid": 35, "color": "#EDC948", "label": "admin_panel/templates/admin_panel/baseHesabdari.html", "count": 15}, {"cid": 36, "color": "#B07AA1", "label": "Reference guide", "count": 14}, {"cid": 37, "color": "#FF9DA7", "label": "Factor Creation UI", "count": 14}, {"cid": 38, "color": "#9C755F", "label": "hesabdariResidNashode.html", "count": 14}, {"cid": 39, "color": "#BAB0AC", "label": "requests", "count": 11}, {"cid": 40, "color": "#4E79A7", "label": "CustomerSmsHistoryTests", "count": 24}, {"cid": 41, "color": "#F28E2B", "label": "TemplatesPageUITests", "count": 14}, {"cid": 42, "color": "#E15759", "label": "SMS Integration Docs", "count": 13}, {"cid": 43, "color": "#76B7B2", "label": "enqueue.py", "count": 20}, {"cid": 44, "color": "#59A14F", "label": "django_shortcuts", "count": 15}, {"cid": 45, "color": "#EDC948", "label": "test_inventory.py", "count": 30}, {"cid": 46, "color": "#B07AA1", "label": "admin_panel/templates/admin_panel/customerAction.html", "count": 13}, {"cid": 47, "color": "#FF9DA7", "label": "SmsReportTests", "count": 39}, {"cid": 48, "color": "#9C755F", "label": "write_tree", "count": 14}, {"cid": 49, "color": "#BAB0AC", "label": "CustomerInfoCardUITests", "count": 13}, {"cid": 50, "color": "#4E79A7", "label": "TestCase", "count": 10}, {"cid": 51, "color": "#F28E2B", "label": "Phone Cleanup Guide", "count": 12}, {"cid": 52, "color": "#E15759", "label": "Customer", "count": 47}, {"cid": 53, "color": "#76B7B2", "label": "project_tree.py", "count": 13}, {"cid": 54, "color": "#59A14F", "label": "Agent Operating Guidelines", "count": 11}, {"cid": 55, "color": "#EDC948", "label": "Command", "count": 28}, {"cid": 56, "color": "#B07AA1", "label": "BehinSMSProvider", "count": 8}, {"cid": 57, "color": "#FF9DA7", "label": "activate_sms", "count": 15}, {"cid": 58, "color": "#9C755F", "label": "Financial Data Objects", "count": 10}, {"cid": 59, "color": "#BAB0AC", "label": "Hybrid Migration Command", "count": 10}, {"cid": 60, "color": "#4E79A7", "label": ".run_cmd", "count": 37}, {"cid": 61, "color": "#F28E2B", "label": "Resource Cleanup Handler", "count": 10}, {"cid": 62, "color": "#E15759", "label": "SMS Timing Migration", "count": 10}, {"cid": 63, "color": "#76B7B2", "label": "admin_panel/templates/admin_panel/bedehi.html", "count": 10}, {"cid": 64, "color": "#59A14F", "label": "customerIDFactorBackup.html", "count":Line truncated
// HTML-escape helper — prevents XSS when injecting graph data into innerHTML
function esc(s) {
+7981 -7064
View File
File diff suppressed because it is too large. Load diff
+50 -26
View File
@@ -960,9 +960,9 @@
"semantic_hash": "364f4190f934261a082c189d8f44d29c"
},
"admin_panel/views/customers.py": {
"mtime": 1791711853.5219991,
"seen": 1791714098.633808,
"ast_hash": "43945c8ffdbde356447635b1203b9566",
"mtime": 1792159253.0096061,
"seen": 1792493391.7541184,
"ast_hash": "a4371b66153ff8afa68452aa35026d84",
"semantic_hash": ""
},
"admin_panel/views/factors.py": {
@@ -1098,9 +1098,9 @@
"semantic_hash": "be04ce55506d075f0afe266cadeda08e"
},
"admin_panel/models/sms.py": {
"mtime": 1791719506.0225618,
"seen": 1791720362.5829337,
"ast_hash": "8e4bb3e917888a16e675f3f139207b1b",
"mtime": 1792159401.822662,
"seen": 1792493391.7461972,
"ast_hash": "61c0b5b802c2bb76777d999b07092ed9",
"semantic_hash": ""
},
"admin_panel/signals.py": {
@@ -1116,10 +1116,10 @@
"semantic_hash": "22f7602ea6f852c9a1cf8a71a40c7f5c"
},
"admin_panel/sms/enqueue.py": {
"mtime": 1791626998.7211082,
"seen": 1791702801.3150773,
"ast_hash": "7d00e585643244a5e694427047972c74",
"semantic_hash": "7d00e585643244a5e694427047972c74"
"mtime": 1792159171.2898316,
"seen": 1792493391.7481978,
"ast_hash": "65e5b86d2fe970cb81658198f0f66d44",
"semantic_hash": ""
},
"admin_panel/sms/phones.py": {
"mtime": 1791567752.8740702,
@@ -1134,10 +1134,10 @@
"semantic_hash": "19bd0d12c9932809424ddcc58d9d5ccd"
},
"admin_panel/sms/render.py": {
"mtime": 1791564403.1354272,
"seen": 1791702801.3150773,
"ast_hash": "9b82ac87329178259ffa6929b1f0bd94",
"semantic_hash": "9b82ac87329178259ffa6929b1f0bd94"
"mtime": 1792159186.946252,
"seen": 1792493391.749565,
"ast_hash": "a70f4e1d403843e2b6f0f1f08e03d22b",
"semantic_hash": ""
},
"admin_panel/sms/rules.py": {
"mtime": 1791708897.995327,
@@ -1146,21 +1146,21 @@
"semantic_hash": ""
},
"admin_panel/sms/scanner.py": {
"mtime": 1791708785.8918655,
"seen": 1791710591.5772536,
"ast_hash": "e9d675f9673e33de929cd9c06fe7de41",
"mtime": 1792157807.5642593,
"seen": 1792493391.7505708,
"ast_hash": "f03e830b2c7a3344fb2ed18acfeff919",
"semantic_hash": ""
},
"admin_panel/sms/timing.py": {
"mtime": 1791708745.8135014,
"seen": 1791710591.5772536,
"ast_hash": "4819c52781b07b14c5c19f66a89bea9c",
"mtime": 1792156624.858224,
"seen": 1792493391.7505708,
"ast_hash": "03ffaeaf60bd13c964f61f1c2429a4ca",
"semantic_hash": ""
},
"admin_panel/views/sms.py": {
"mtime": 1791720558.729439,
"seen": 1791721256.78409,
"ast_hash": "c0041541c95e34dd74b4d5439a701f73",
"mtime": 1792159340.4347801,
"seen": 1792493391.7541184,
"ast_hash": "9aa47348420d891e99ca67401c677898",
"semantic_hash": ""
},
"integrations/__init__.py": {
@@ -1260,9 +1260,9 @@
"semantic_hash": "d181c7e91c9fc824e45ee36afe852884"
},
"tests/test_sms.py": {
"mtime": 1791720635.36276,
"seen": 1791721256.78409,
"ast_hash": "797136590c528a8a52e6884b355122d2",
"mtime": 1792159900.5047233,
"seen": 1792493391.763808,
"ast_hash": "e1b1259ac6fd3522b73f30f4b8af04f8",
"semantic_hash": ""
},
"admin_panel/templates/admin_panel/_customer_sms.html": {
@@ -1516,5 +1516,29 @@
"seen": 1792154233.652624,
"ast_hash": "315d8f435e2837683f3c4b797f7928b9",
"semantic_hash": ""
},
"admin_panel/migrations/0075_cheque_funding_before_due.py": {
"mtime": 1792156733.9907367,
"seen": 1792493391.7441702,
"ast_hash": "662e63fec04ca3cbfb417730e6b3e08f",
"semantic_hash": ""
},
"admin_panel/migrations/0076_alter_smstemplate_text_help.py": {
"mtime": 1792157913.085637,
"seen": 1792493391.7441702,
"ast_hash": "c755f747ccb441234920652de8f30c34",
"semantic_hash": ""
},
"admin_panel/migrations/0077_alter_smstemplate_text_balance_help.py": {
"mtime": 1792159653.211569,
"seen": 1792493391.7441702,
"ast_hash": "04743cd90961374dffadd8345b0ba9c6",
"semantic_hash": ""
},
"admin_panel/sms/balance.py": {
"mtime": 1792160598.9153934,
"seen": 1792493391.7481978,
"ast_hash": "60c36802c8ec0752be3effd8cbc63e68",
"semantic_hash": ""
}
}
+83 -14
View File
@@ -1,4 +1,5 @@
"""تست‌های قابلیت پیامک: نرمال‌سازی، idempotency، opt-out، worker و تریگرها."""
import datetime
import json
import re
from datetime import timedelta
@@ -16,9 +17,10 @@ from admin_panel.sms.enqueue import enqueue_sms
from admin_panel.sms.phones import customer_phone_entries
from admin_panel.sms.recipients import resolve_recipients
from admin_panel.sms.rules import is_sms_enabled
from admin_panel.sms.scanner import run_all
from admin_panel.sms.scanner import run_all, scan_visit
from admin_panel.sms.timing import apply_timing, current_timing_value, timing_for
from admin_panel.sms.triggers import notify_welcome
from admin_panel.visit_policy import visit_period
from integrations.sms.base import SMSProvider, SMSResult
from integrations.sms.normalize import normalize_mobile, to_local
@@ -427,7 +429,11 @@ class ScannerTests(TestCase):
self.assertEqual(SMSQueue.objects.filter(customer=old).count(), 0)
def test_deleted_visit_reminder_rearms_next_period_only(self):
"""حذفِ یادآوری بازدید فقط همان دوره را کنسل می‌کند؛ دورهٔ بعد دوباره ساخته می‌شود."""
"""حذفِ یادآوری بازدید فقط همان دوره را کنسل می‌کند؛ دورهٔ بعد دوباره ساخته می‌شود.
نکته: اسکنر با ``now`` تزریقی اجرا می‌شود تا این تست به ساعت واقعی سیستم وابسته نباشد
(قبلاً با ``run_all`` تمام اسکن‌ها اجرا می‌شدند و ماتریس دوره به تاریخ واقعی گره می‌خورد).
"""
customer = make_customer('مشتری بازدید دوره‌ای')
PhoneNumber.objects.create(customer=customer, phone_number='09120000007')
Customer.objects.filter(pk=customer.pk).update(
@@ -436,31 +442,88 @@ class ScannerTests(TestCase):
template = SMSTemplate.objects.get(sms_type=SMSType.VISIT)
apply_timing(template, 9)
template.save()
period = timedelta(weeks=int(9 * 4.3))
days_to_next = period.days - (300 % period.days) # تا شروع دورهٔ بعد
self.assertEqual(visit_period().months, 9)
period = visit_period()
install = timezone.localdate() - timedelta(days=300)
today = timezone.localdate()
# روز ورود به چرخهٔ بعد = موعد تقویمی بعدی (install + 9m، +18m، ...)
from dateutil.relativedelta import relativedelta
from admin_panel.visit_policy import visit_cycle_index
next_due = install + period
while next_due <= today:
next_due = next_due + period
days_to_next = (next_due - today).days
self.assertGreater(
visit_cycle_index(install, next_due), visit_cycle_index(install, today),
msg='شمارهٔ چرخه باید در موعد تقویمی بعدی افزایش یابد',
)
def keys():
return list(
SMSQueue.objects.filter(customer=customer).values_list('dedup_key', flat=True)
)
run_all(today)
def scan(day):
"""اسکن در «روز» داده‌شده؛ مبنای زمانی هم همان روز است (بدون وابستگی به ساعت واقعی)."""
moment = timezone.make_aware(
datetime.datetime.combine(day, datetime.time(12, 0)),
timezone.get_current_timezone(),
)
return scan_visit(day, now=moment)
# کنترل جریان: پنجرهٔ یادآوری با «امروز» واقعی (زمان پنجرهٔ اسکن) سنجیده می‌شود،
# پس خط شروع را به همان اندازه عقب می‌بریم تا این تست به تاریخ اجرا وابسته نباشد.
config = SMSProviderConfig.load()
config.scan_since = timezone.now() - timedelta(days=600)
config.allow_backlog = True
config.backlog_max = 50
config.save()
# پیش‌بردن «اکنون» به روزهای آینده به تغییر موقت ساعت ارجاعی نیاز دارد
# (بازه‌های ماهانه تقویمی‌اند و با جابه‌جایی خط شروع شبیه‌سازی نمی‌شوند).
import admin_panel.sms.scanner as scanner_module
real_timezone_now = scanner_module.timezone.now
def scan(day):
"""اسکن در «روز» داده‌شده؛ مبنای زمانی هم همان روز است (بدون وابستگی به ساعت واقعی)."""
moment = timezone.make_aware(
datetime.datetime.combine(day, datetime.time(12, 0)),
timezone.get_current_timezone(),
)
scanner_module.timezone.now = lambda: moment
try:
return scan_visit(day, now=moment)
finally:
scanner_module.timezone.now = real_timezone_now
scan(today)
first = keys()
self.assertEqual(len(first), 1)
# مدیر مورد را حذف می‌کند ⇒ در همان دوره دوباره ساخته نمی‌شود.
SMSQueue.objects.filter(customer=customer).delete()
run_all(today)
scan(today)
self.assertEqual(keys(), [])
# حتی نزدیک پایان دوره هم برنمی‌گردد.
run_all(today + timedelta(days=days_to_next - 1))
scan(today + timedelta(days=days_to_next - 1))
self.assertEqual(keys(), [])
# در دورهٔ بعد کلیدِ جدید ساخته می‌شود (دوباره ارسال می‌شود، نه خاموشِ دائمی).
run_all(today + timedelta(days=days_to_next))
self.assertEqual(len(keys()), 1)
self.assertNotEqual(keys(), first)
# در دورهٔ بعد کلیدِ تازه ساخته می‌شود (یادآوری برای همیشه خاموش نمی‌ماند).
# نکته: شماره‌گذاری چرخه از «لنگر ثابت» (تاریخ نصب) محاسبه می‌شود، پس اولین
# چرخهٔ کاملِ بعد از حذف، چرخهٔ شمارهٔ ۲ است؛ حلقه تا رسیدن به کلید تازه
# ادامه می‌دهد تا تست به محاسبهٔ دقیق تقویمی گره نخورد.
second = None
for step in range(1, 5):
scan(today + timedelta(days=days_to_next * step))
current = keys()
if current:
second = current
break
self.assertIsNotNone(second, msg='یادآوری دورهٔ بعد باید دوباره ساخته شود')
self.assertNotEqual(second, first)
def test_disabled_template_is_skipped(self):
today = timezone.localdate()
@@ -2190,13 +2253,19 @@ class EventTriggerTests(PanelTestCase):
# ---------- ثبت اقدام از نوع بازدید ----------
def _post_action(self, customer, action_type):
def _post_action(self, customer, action_type, *, is_done=True):
url = reverse('admin_panel:customerNewAction', kwargs={'c_id': customer.id})
return self.client.post(url, {
payload = {
'actionType': action_type,
'dateTimeAnjam': '1700000000000',
'desc': 'توضیح', 'userID': self.user.id,
})
}
if is_done:
# اعتبارسنجی HTTP: بازدید انجام‌شده باید تاریخ اتمام داشته باشد؛
# فیلد خالی در سرور با زمان ثبت پر می‌شود.
payload['is_done'] = 'on'
payload['FinishDateTime'] = '1700000000000'
return self.client.post(url, payload)
def test_bazdid_action_enqueues_visit_created(self):
customer = self._customer_with_phone()
+816
View File
@@ -0,0 +1,816 @@
"""تست‌های «آخرین بازدید» (visit_policy) و دستور ترمیم.
این ماژول قواعد طلایی زیر را قفل می‌کند:
* فقط اقدام ``bazdid + finish`` در محاسبهٔ آخرین بازدید دیده می‌شود؛
«هماهنگی» هرگز.
* تاریخ مؤثر = ``actionDateTime`` اگر از حالا جلوتر نباشد، وگرنه ``finishDateTime``.
* بازمحاسبه و ترمیم، هیچ رکورد اقدامی را تغییر نمی‌دهند.
* بازدید انجام‌شده با تاریخ بیش از ۳۰ روز آینده در لایهٔ HTTP رد می‌شود.
"""
import datetime
import io
import os
from datetime import timedelta
from pathlib import Path
from django.core.exceptions import ValidationError
from django.core.management import CommandError, call_command
from django.db import connection
from django.test import TestCase
from django.utils import timezone
from admin_panel.models import Action, Customer
from admin_panel.visit_policy import (active_deferral, as_aware, last_visit_dt,
last_visit_map, refresh_last_bazdid,
validate_action_payload,
visit_cycle_index, visit_due_date)
from .factories import make_customer, make_user
_BASE_NOW = timezone.now()
def at(days=0, hours=0):
"""لحظهٔ aware نسبت به «حالا» (ثابت در طول یک ماژول، بدون drift میکروثانیه)."""
return _BASE_NOW + timedelta(days=days, hours=hours)
def make_visit(customer, *, action_dt=None, finish_dt=None, status='finish',
action_type='bazdid'):
return Action.objects.create(
customer=customer, actionType=action_type, status=status,
actionDateTime=action_dt, finishDateTime=finish_dt,
desc='تست', result='نتیجه',
)
def action_snapshot(action):
"""همهٔ فیلدهای یک اقدام، برای اثبات دست‌نخورده ماندن آن."""
action.refresh_from_db()
return {field.name: getattr(action, field.name)
for field in action._meta.concrete_fields}
class EffectiveVisitDateTests(TestCase):
"""قاعدهٔ «تاریخ مؤثر» و انتخاب بزرگ‌ترین مقدار."""
def setUp(self):
self.customer = make_customer('مشتری تاریخ مؤثر')
def test_finish_before_action_uses_action_when_action_is_past(self):
# هر دو در گذشته‌اند ⇒ قاعدهٔ جدید تاریخ واقعی بازدید (action) را می‌گیرد.
make_visit(self.customer, action_dt=at(-30), finish_dt=at(-31))
self.assertEqual(last_visit_dt(self.customer), as_aware(at(-30)))
def test_future_action_falls_back_to_finish(self):
make_visit(self.customer, action_dt=at(+10), finish_dt=at(-2))
self.assertLessEqual(last_visit_dt(self.customer), timezone.now())
def test_future_action_and_future_finish_yields_nothing(self):
make_visit(self.customer, action_dt=at(+10), finish_dt=at(+11))
self.assertIsNone(last_visit_dt(self.customer))
def test_only_action(self):
make_visit(self.customer, action_dt=at(-5))
self.assertEqual(last_visit_dt(self.customer), as_aware(at(-5)))
def test_only_finish(self):
make_visit(self.customer, finish_dt=at(-4))
self.assertEqual(last_visit_dt(self.customer), as_aware(at(-4)))
def test_no_dates(self):
make_visit(self.customer)
self.assertIsNone(last_visit_dt(self.customer))
def test_latest_finish_wins_over_future_planned_visit(self):
make_visit(self.customer, action_dt=at(-90), finish_dt=at(-90))
make_visit(self.customer, action_dt=at(+20), finish_dt=at(-3))
self.assertEqual(last_visit_dt(self.customer), as_aware(at(-3)))
def test_pending_visit_is_ignored(self):
make_visit(self.customer, action_dt=at(-200), finish_dt=at(-200))
make_visit(self.customer, action_dt=at(-1), status='waite')
self.assertEqual(last_visit_dt(self.customer), as_aware(at(-200)))
class HamahangiIsolationTests(TestCase):
"""هماهنگی هرگز نباید در «آخرین بازدید» دیده شود."""
def setUp(self):
self.customer = make_customer('مشتری هماهنگی')
def test_only_hamahangi_gives_no_visit(self):
make_visit(self.customer, action_dt=at(-1), finish_dt=at(-1),
action_type='hamahangi')
self.assertIsNone(last_visit_dt(self.customer))
def test_newer_hamahangi_does_not_override_visit(self):
make_visit(self.customer, action_dt=at(-60), finish_dt=at(-60))
make_visit(self.customer, action_dt=at(-2), finish_dt=at(-2),
action_type='hamahangi')
self.assertEqual(last_visit_dt(self.customer), as_aware(at(-60)))
def test_refresh_does_not_clear_value_without_visit(self):
Customer.objects.filter(pk=self.customer.pk).update(last_bazdid_dt=at(-10))
self.customer.refresh_from_db()
make_visit(self.customer, action_dt=at(-1), action_type='hamahangi')
changed = refresh_last_bazdid(self.customer)
self.customer.refresh_from_db()
self.assertFalse(changed)
self.assertEqual(as_aware(self.customer.last_bazdid_dt), as_aware(at(-10)))
def test_waiting_hamahangi_is_not_a_visit_but_is_a_deferral(self):
make_visit(self.customer, action_dt=at(+5), status='waite',
action_type='hamahangi')
self.assertIsNone(last_visit_dt(self.customer))
self.assertIsNotNone(active_deferral(self.customer))
def test_refresh_keeps_action_untouched(self):
action = make_visit(self.customer, action_dt=at(-30), finish_dt=at(-31))
before = action_snapshot(action)
refresh_last_bazdid(self.customer)
self.assertEqual(action_snapshot(action), before)
class RefreshLastBazdidTests(TestCase):
"""نوشتن مقدار مشتری: فقط وقتی تغییر کند، و فقط همان ستون."""
def setUp(self):
self.customer = make_customer('مشتری بازمحاسبه')
def test_writes_action_date_not_finish_date(self):
make_visit(self.customer, action_dt=at(-20), finish_dt=at(-1))
self.assertTrue(refresh_last_bazdid(self.customer))
self.customer.refresh_from_db()
self.assertLessEqual(as_aware(self.customer.last_bazdid_dt), as_aware(at(-20)))
def test_is_idempotent(self):
make_visit(self.customer, action_dt=at(-20), finish_dt=at(-20))
self.assertTrue(refresh_last_bazdid(self.customer))
self.assertFalse(refresh_last_bazdid(self.customer))
def test_other_customer_fields_untouched(self):
make_visit(self.customer, action_dt=at(-20), finish_dt=at(-20))
before = Customer.objects.values().get(pk=self.customer.pk)
refresh_last_bazdid(self.customer)
after = Customer.objects.values().get(pk=self.customer.pk)
changed = {key for key in before if before[key] != after[key]}
self.assertEqual(changed, {'last_bazdid_dt'})
class ValidationTests(TestCase):
"""اعتبارسنجی لایهٔ HTTP (قاعدهٔ ۳۰ روز فقط برای بازدید انجام‌شده)."""
def payload(self, **overrides):
base = {'actionType': 'bazdid', 'status': 'finish', 'actionDateTime': at(-1)}
base.update(overrides)
return base
def test_finished_visit_far_in_future_is_rejected(self):
with self.assertRaises(ValidationError):
validate_action_payload(self.payload(actionDateTime=at(+60)))
def test_finished_visit_today_is_accepted(self):
self.assertTrue(validate_action_payload(self.payload(actionDateTime=at(0))))
def test_finished_visit_in_past_is_accepted(self):
self.assertTrue(validate_action_payload(self.payload(actionDateTime=at(-90))))
def test_hamahangi_in_future_is_accepted(self):
self.assertTrue(validate_action_payload(
self.payload(actionType='hamahangi', status='waite', actionDateTime=at(+40))
))
def test_invalid_action_type_is_rejected(self):
with self.assertRaises(ValidationError):
validate_action_payload(self.payload(actionType='انتخاب کنید'))
def test_invalid_status_is_rejected(self):
with self.assertRaises(ValidationError):
validate_action_payload(self.payload(status='done'))
def test_end_of_day_tehran_is_accepted(self):
# ۲۳:۵۹ امروز به وقت تهران (= ۲۰:۲۹ UTC) نباید رد شود.
tz = timezone.get_current_timezone()
today = timezone.localdate()
moment = datetime.datetime.combine(
today, datetime.time(23, 59), tzinfo=tz
)
self.assertTrue(validate_action_payload(self.payload(actionDateTime=moment)))
def test_start_of_day_tehran_is_accepted(self):
# ۰۰:۳۰ امروز تهران در UTC روز قبل است؛ نباید به‌اشتباه رد یا آینده حساب شود.
tz = timezone.get_current_timezone()
today = timezone.localdate()
moment = datetime.datetime.combine(today, datetime.time(0, 30), tzinfo=tz)
self.assertTrue(validate_action_payload(self.payload(actionDateTime=moment)))
def test_naive_payload_is_made_aware(self):
# ورودی naive (بقایای کد قدیمی) باید با تایم‌زون پروژه aware شود.
naive = datetime.datetime.now() - timedelta(days=1)
self.assertTrue(validate_action_payload(self.payload(actionDateTime=naive)))
self.assertFalse(timezone.is_naive(as_aware(naive)))
def run_command(*args):
out = io.StringIO()
call_command('recalc_last_bazdid', *args, stdout=out)
return out.getvalue()
def plan_command(*args):
"""برنامهٔ تغییرات را از همان مسیر ``handle`` می‌گیرد، بدون نوشتن/بکاپ."""
from django.core.management.base import CommandParser
from admin_panel.management.commands.recalc_last_bazdid import Command
command = Command()
parser = CommandParser(add_help=False)
command.add_arguments(parser)
options = parser.parse_args(list(args))
defaults = {'no_color': True, 'force_color': False, 'skip_checks': False,
'traceback': False, 'verbosity': 0, 'pythonpath': None,
'settings': None, 'stdout': io.StringIO(), 'stderr': io.StringIO()}
defaults.update(vars(options))
return command._plan(
defaults['exact'], defaults['clear_orphans'], defaults['batch_size'],
)
class RecalcCommandTests(TestCase):
"""دستور ترمیم: فقط Customer را می‌نویسد، هماهنگی را دست نمی‌زند."""
def setUp(self):
self.forward = make_customer('مشتری رو به جلو')
self.backward = make_customer('مشتری رو به عقب')
self.ham = make_customer('مشتری هماهنگی')
# رو به جلو: مقدار فعلی قدیمی‌تر از واقعیت است.
make_visit(self.forward, action_dt=at(-30), finish_dt=at(-30))
Customer.objects.filter(pk=self.forward.pk).update(last_bazdid_dt=at(-400))
# رو به عقب: مقدار فعلی جلوتر از واقعیت است (drift ویرایش).
make_visit(self.backward, action_dt=at(-100), finish_dt=at(-100))
Customer.objects.filter(pk=self.backward.pk).update(last_bazdid_dt=at(-2))
# هماهنگی: مقدارش دقیقاً از تاریخ هماهنگی است.
self.ham_action = make_visit(
self.ham, action_dt=at(-5), finish_dt=at(-5), action_type='hamahangi',
)
Customer.objects.filter(pk=self.ham.pk).update(last_bazdid_dt=at(-5))
def _values(self):
return dict(Customer.objects.values_list('id', 'last_bazdid_dt'))
def test_dry_run_writes_nothing(self):
before = self._values()
run_command('--exact', '--dry-run')
self.assertEqual(self._values(), before)
def test_exact_requires_confirm(self):
with self.assertRaises(CommandError):
run_command('--exact')
def test_plan_default_mode_ignores_backward_drift(self):
stats, _, pending, _ = plan_command()
self.assertEqual(stats['forward'], 1)
self.assertEqual(stats['backward'], 0)
self.assertNotIn(self.backward.pk, [customer_id for customer_id, _ in pending])
def test_plan_exact_mode_fixes_both_directions(self):
stats, _, pending, _ = plan_command('--exact')
targets = dict(pending)
self.assertEqual(stats['forward'], 1)
self.assertEqual(stats['backward'], 1)
self.assertEqual(as_aware(targets[self.forward.pk]), as_aware(at(-30)))
self.assertEqual(as_aware(targets[self.backward.pk]), as_aware(at(-100)))
def test_apply_writes_only_customer_column(self):
from admin_panel.management.commands.recalc_last_bazdid import Command as Cmd
_, _, pending, _ = plan_command('--exact')
before = action_snapshot(self.ham_action)
Cmd()._apply(pending)
values = self._values()
self.assertEqual(as_aware(values[self.forward.pk]), as_aware(at(-30)))
self.assertEqual(as_aware(values[self.backward.pk]), as_aware(at(-100)))
self.assertEqual(action_snapshot(self.ham_action), before)
def test_plan_is_idempotent_after_apply(self):
from admin_panel.management.commands.recalc_last_bazdid import Command as Cmd
_, _, pending, _ = plan_command('--exact')
Cmd()._apply(pending)
stats, _, pending_again, _ = plan_command('--exact')
self.assertEqual(pending_again, [])
self.assertEqual(stats['no_change'], stats['scanned'])
def test_hamahangi_value_is_untouched_without_clear_orphans(self):
plan_command('--exact')
self.assertEqual(as_aware(self._values()[self.ham.pk]), as_aware(at(-5)))
def test_hamahangi_value_is_cleared_with_clear_orphans(self):
_, _, pending, _ = plan_command('--exact', '--clear-orphans')
self.assertIn((self.ham.pk, None), pending)
def test_hamahangi_suspect_heuristic(self):
_, _, _, suspects = plan_command('--exact')
self.assertEqual(suspects, 1) # فقط مشتری هماهنگی
class EntezarScannerConsistencyTests(TestCase):
"""صفحهٔ «انتظار» و اسکنر پیامک باید یک جواب بدهند."""
def setUp(self):
self.user = make_user('entezarAdmin', superuser=True)
def _entezar_ids(self):
self.client.force_login(self.user)
response = self.client.get('/panel/entezar/')
self.assertEqual(response.status_code, 200)
return {c.id for c in response.context['customers']}
def test_same_customer_state_on_both_sides(self):
from admin_panel.sms.scanner import scan_visit
from admin_panel.sms.timing import DEFAULT_VISIT_MONTHS
months = DEFAULT_VISIT_MONTHS
stale = make_customer('مشتری معوق مشترک')
fresh = make_customer('مشتری تازه بازدیدشده')
Customer.objects.filter(pk=stale.pk).update(
installDate=timezone.localdate() - timedelta(days=400)
)
Customer.objects.filter(pk=fresh.pk).update(
installDate=timezone.localdate() - timedelta(days=400)
)
make_visit(fresh, action_dt=timezone.now() - timedelta(days=5),
finish_dt=timezone.now() - timedelta(days=5))
ids = self._entezar_ids()
self.assertIn(stale.pk, ids)
self.assertNotIn(fresh.pk, ids)
# اسکنر هم باید فقط مشتری معوق را عقب‌افتاده بداند.
from admin_panel.visit_policy import active_deferrals, last_visit_map, visit_period
period = visit_period(months)
now = timezone.now()
visits = last_visit_map(now=now)
deferrals = active_deferrals(now=now)
def is_stale(customer):
anchor = visits.get(customer.pk) or as_aware(customer.installDate)
return (customer.pk not in deferrals) and (anchor + period <= now)
self.assertTrue(is_stale(Customer.objects.get(pk=stale.pk)))
self.assertFalse(is_stale(Customer.objects.get(pk=fresh.pk)))
def test_active_deferral_hides_customer(self):
deferred = make_customer('مشتری تعویق‌دار')
Customer.objects.filter(pk=deferred.pk).update(
installDate=timezone.localdate() - timedelta(days=400)
)
make_visit(deferred, action_dt=at(+3), status='waite',
action_type='hamahangi')
ids = self._entezar_ids()
self.assertNotIn(deferred.pk, ids)
class VisitCadenceTests(TestCase):
"""قاعدهٔ «هر ۹ ماه یک‌بار»: موعد و چرخه از یک تقویم می‌آیند."""
MONTHS = 9
def test_due_date_is_calendar_nine_months(self):
anchor = as_aware(datetime.date(2024, 3, 4))
self.assertEqual(visit_due_date(anchor, months=self.MONTHS).date(),
datetime.date(2024, 12, 4))
def test_cycle_increments_exactly_on_each_due_date(self):
anchor = as_aware(datetime.date(2024, 3, 4))
self.assertEqual(visit_cycle_index(anchor, anchor, months=self.MONTHS), 0)
for k in range(1, 6):
due = visit_due_date(anchor, months=self.MONTHS * k)
# در روز موعدِ k-ام، «دورهٔ جاری» همان k است.
self.assertEqual(visit_cycle_index(anchor, due, months=self.MONTHS), k,
msg=f'چرخه در موعد {k} باید {k} باشد')
def test_cycle_does_not_advance_before_due_date(self):
anchor = as_aware(datetime.date(2024, 3, 4))
due = visit_due_date(anchor, months=self.MONTHS)
before = due - timedelta(days=1)
self.assertEqual(visit_cycle_index(anchor, before, months=self.MONTHS), 0)
self.assertEqual(visit_cycle_index(anchor, due, months=self.MONTHS), 1)
def test_each_period_has_a_distinct_key(self):
"""بازگشت باگ اصلی: کلید هر دوره باید یکتا باشد (نه تکرار، نه پرش)."""
anchor = as_aware(datetime.date(2024, 1, 1))
keys = []
for k in range(1, 7):
due = visit_due_date(anchor, months=self.MONTHS * k)
keys.append((visit_cycle_index(anchor, due, months=self.MONTHS),
due.strftime('%Y-%m')))
self.assertEqual(len({key for _cycle, key in keys}), len(keys))
self.assertEqual([c for c, _k in keys], [1, 2, 3, 4, 5, 6])
def test_customer_without_any_visit_gets_every_period(self):
"""مشتری که هرگز بازدید نشده، برای هر ۹ ماه یک کلید تازه می‌گیرد."""
from admin_panel.models import PhoneNumber, SMSProviderConfig, SMSQueue
from admin_panel.sms.scanner import scan_visit
customer = make_customer('مشتری بدون بازدید')
PhoneNumber.objects.create(customer=customer, phone_number='09120000123')
Customer.objects.filter(pk=customer.pk).update(
installDate=datetime.date(2023, 1, 1)
)
customer.refresh_from_db()
config = SMSProviderConfig.load()
# «اکنون» این تست در سال ۲۰۲۳–۲۰۲۵ است، پس پنجرهٔ یادآوری هم همان‌جا باز می‌شود.
config.allow_backlog = True
config.backlog_max = 100
config.scan_since = timezone.make_aware(
datetime.datetime(2023, 1, 2, 0, 0), timezone.get_current_timezone(),
)
config.save()
seen_keys = set()
cursor = datetime.date(2023, 1, 2)
while cursor <= datetime.date(2026, 1, 1):
moment = timezone.make_aware(
datetime.datetime.combine(cursor, datetime.time(12, 0)),
timezone.get_current_timezone(),
)
scan_visit(cursor, now=moment)
seen_keys.update(
SMSQueue.objects.filter(customer=customer)
.values_list('dedup_key', flat=True)
)
cursor += timedelta(days=14)
# ۹ ماه اول (۲۰۲۳-۱۰) ، ۱۸ ماه (۲۰۲۴-۰۷) و ۲۷ ماه (۲۰۲۵-۰۴) ⇒ سه دورهٔ یکتا.
# نکته: اسکنر همیشه «تازه‌ترین موعد رسیده» را هدف می‌گیرد؛ پس اگر بین دو اجرا
# بیش از یک دوره گذشته باشد، فقط آخرین دوره صف می‌شود (نه ارسال انبوه).
due_months = {key.rsplit(':', 1)[1] for key in seen_keys}
self.assertEqual(
due_months, {'2023-10', '2024-07', '2025-04'},
msg=f'کلیدهای ساخته‌شده: {sorted(seen_keys)}',
)
class VisitDecisionTests(TestCase):
"""تصمیم‌های محصولی: یک پیامک برای جدیدترین دوره، بازگشت پس از حذف، ریست با نصب."""
MONTHS = 9
def _scan_at(self, day):
from admin_panel.sms.scanner import scan_visit
moment = timezone.make_aware(
datetime.datetime.combine(day, datetime.time(12, 0)),
timezone.get_current_timezone(),
)
return scan_visit(day, now=moment)
def _prep(self, install_date):
from admin_panel.models import PhoneNumber, SMSProviderConfig, SMSQueue
customer = make_customer('مشتری تصمیم')
PhoneNumber.objects.create(customer=customer, phone_number='09120000777')
# ثبت مستقیم: `installDate` با auto_now_add پر می‌شود، پس با update() بازنویسی می‌کنیم.
Customer.objects.filter(pk=customer.pk).update(installDate=install_date)
customer.refresh_from_db()
self.assertEqual(customer.installDate, install_date)
config = SMSProviderConfig.load()
config.allow_backlog = True
config.backlog_max = 100
config.scan_since = timezone.make_aware(
datetime.datetime(2020, 1, 1, 0, 0), timezone.get_current_timezone(),
)
config.save()
return customer, SMSQueue
# --- تصمیم ۱: فقط یک پیامک برای جدیدترین دوره ---
def test_absent_25_months_gets_exactly_one_reminder(self):
customer, SMSQueue = self._prep(datetime.date(2023, 1, 1))
self._scan_at(datetime.date(2025, 2, 10)) # ۲۵ ماه بعد
keys = list(SMSQueue.objects.filter(customer=customer).values_list('dedup_key', flat=True))
self.assertEqual(len(keys), 1, msg=f'کلیدها: {keys}')
# جدیدترین دورهٔ رسیده: ۱۸ ماهگی (۲۰۲۴-۰۷) — نه ۹ ماهگی.
self.assertEqual(keys[0].rsplit(':', 1)[1], '2024-07')
# --- تصمیم ۲: حذف یادآوری = کنسل همان دوره تا ۹ ماه ---
def test_deleted_reminder_stays_cancelled_until_next_period(self):
customer, SMSQueue = self._prep(datetime.date(2023, 1, 1))
self._scan_at(datetime.date(2024, 1, 10)) # دورهٔ ۹ ماهگی
self.assertEqual(SMSQueue.objects.filter(customer=customer).count(), 1)
# مدیر از پنل صف، پیامک را حذف می‌کند (مسیر واقعی «کنسل دوره»).
from django.urls import reverse
from tests.factories import make_user
manager = make_user('smsQueueManager', superuser=True)
self.client.force_login(manager)
item = SMSQueue.objects.get(customer=customer)
response = self.client.post(
reverse('admin_panel:sms_queue_delete', kwargs={'q_id': item.id}),
)
self.assertEqual(response.status_code, 302)
self.assertFalse(SMSQueue.objects.filter(customer=customer).exists())
# تا پایان دورهٔ اول (موعد بعدی = ۲۰۲۴-۰۷-۰۱) هیچ یادآوری تازه‌ای ساخته نمی‌شود.
for day in (datetime.date(2024, 2, 10), datetime.date(2024, 6, 10)):
self._scan_at(day)
self.assertEqual(SMSQueue.objects.filter(customer=customer).count(), 0,
msg='حذف باید همان دوره را کنسل نگه دارد')
# دورهٔ دوم (۱۸ ماهگی) ⇒ یادآوری تازه با کلید دیگر.
self._scan_at(datetime.date(2024, 7, 1))
self.assertEqual(SMSQueue.objects.filter(customer=customer).count(), 1)
self.assertEqual(
SMSQueue.objects.get(customer=customer).dedup_key.rsplit(':', 1)[1], '2024-07',
)
# --- تصمیم ۳: نصب چرخه را ریست می‌کند ---
def test_nasb_action_resets_the_cycle(self):
customer, SMSQueue = self._prep(datetime.date(2023, 1, 1))
from admin_panel.models import Action
Action.objects.create(
customer=customer, actionType='nasb', status='finish',
actionDateTime=timezone.make_aware(
datetime.datetime(2024, 1, 1, 10, 0), timezone.get_current_timezone(),
),
)
# ۸ ماه بعد از نصب ⇒ هنوز موعد نرسیده
self._scan_at(datetime.date(2024, 9, 1))
self.assertEqual(SMSQueue.objects.filter(customer=customer).count(), 0)
# ۹ ماه کامل بعد از نصب ⇒ یادآوری
self._scan_at(datetime.date(2024, 10, 5))
self.assertEqual(SMSQueue.objects.filter(customer=customer).count(), 1)
def test_phone_call_does_not_reset_the_cycle(self):
customer, SMSQueue = self._prep(datetime.date(2023, 1, 1))
from admin_panel.models import Action
Action.objects.create(
customer=customer, actionType='outCall', status='finish',
actionDateTime=timezone.make_aware(
datetime.datetime(2024, 5, 1, 10, 0), timezone.get_current_timezone(),
),
)
# ۹ ماه از نصب گذشته ⇒ باید معوق بماند، چون تماس تلفنی ریست نمی‌کند
self._scan_at(datetime.date(2024, 10, 5))
self.assertEqual(SMSQueue.objects.filter(customer=customer).count(), 1)
# --- تصمیم ۴: پایان تعویق ⇒ اگر بازدید قدیمی است، بلافاصله معوق ---
def test_after_deferral_ends_old_visit_makes_customer_due_immediately(self):
customer, SMSQueue = self._prep(datetime.date(2023, 1, 1))
from admin_panel.models import Action
# آخرین بازدید واقعی: ۱۰ ماه پیش از پایان تعویق (۲۰۲۵-۰۳-۱۵)
Action.objects.create(
customer=customer, actionType='bazdid', status='finish',
actionDateTime=timezone.make_aware(
datetime.datetime(2025, 3, 15, 10, 0), timezone.get_current_timezone(),
),
)
# تعویق فعال تا ۲۰۲۶-۰۱-۱۵ ⇒ در این بازه یادآوری نمی‌گیرد
Action.objects.create(
customer=customer, actionType='hamahangi', status='waite',
actionDateTime=timezone.make_aware(
datetime.datetime(2026, 1, 15, 10, 0), timezone.get_current_timezone(),
),
)
self._scan_at(datetime.date(2026, 1, 10))
self.assertEqual(SMSQueue.objects.filter(customer=customer).count(), 0)
# تعویق تمام شد؛ ۱۰ ماه از بازدید گذشته ⇒ همان روز یادآوری می‌گیرد
self._scan_at(datetime.date(2026, 1, 20))
self.assertEqual(SMSQueue.objects.filter(customer=customer).count(), 1)
class NextAppointmentTests(TestCase):
"""تصمیم: هماهنگیِ «انجام‌شده» و بازدیدِ برنامه‌ریزی‌شده هم مشتری را از فهرست خارج می‌کنند."""
def setUp(self):
from tests.factories import make_user
self.user = make_user('apptExpert', superuser=True)
self.client.force_login(self.user)
def _customer(self, name, install_days=400):
customer = make_customer(name)
Customer.objects.filter(pk=customer.pk).update(
installDate=timezone.localdate() - timedelta(days=install_days)
)
customer.refresh_from_db()
return customer
def _entezar_ids(self):
response = self.client.get('/panel/entezar/')
self.assertEqual(response.status_code, 200)
return {c.id for c in response.context['customers']}
def _add(self, customer, action_type, status, when, **extra):
return Action.objects.create(
customer=customer, actionType=action_type, status=status,
actionDateTime=when, **extra,
)
# --- خواستهٔ ۱: هماهنگیِ finish ---
def test_finished_hamahangi_hides_customer_until_appointment(self):
customer = self._customer('هماهنگی انجام‌شده')
# همین حالا تماس گرفتیم و برای هفتهٔ بعد توافق شد؛ هماهنگی «انجام شد».
self._add(customer, 'hamahangi', 'finish', at(+7), finishDateTime=at(0))
self.assertNotIn(customer.pk, self._entezar_ids())
def test_finished_hamahangi_with_past_date_does_not_hide(self):
customer = self._customer('هماهنگی گذشته')
self._add(customer, 'hamahangi', 'finish', at(-2), finishDateTime=at(-2))
self.assertIn(customer.pk, self._entezar_ids())
def test_finished_hamahangi_falls_back_to_finish_datetime(self):
customer = self._customer('هماهنگی بدون تاریخ اقدام')
self._add(customer, 'hamahangi', 'finish', None, finishDateTime=at(+5))
self.assertNotIn(customer.pk, self._entezar_ids())
# --- خواستهٔ ۲: بازدید waite با تاریخ آینده ---
def test_scheduled_visit_hides_customer(self):
customer = self._customer('بازدید برنامه‌ریزی‌شده')
self._add(customer, 'bazdid', 'waite', at(+5))
self.assertNotIn(customer.pk, self._entezar_ids())
def test_scheduled_visit_in_the_past_does_not_hide(self):
customer = self._customer('بازدید عقب‌افتاده')
self._add(customer, 'bazdid', 'waite', at(-5))
self.assertIn(customer.pk, self._entezar_ids())
# --- پس از تاریخ قرار، مشتری برمی‌گردد ---
def test_customer_returns_after_appointment_date(self):
from admin_panel.visit_policy import next_appointment_map
customer = self._customer('قرار منقضی')
self._add(customer, 'bazdid', 'waite', at(+3))
self.assertIn(customer.pk, next_appointment_map(now=at(0)))
self.assertNotIn(customer.pk, next_appointment_map(now=at(+4)))
# --- هماهنگی بازدید نیست ---
def test_hamahangi_never_changes_last_visit(self):
customer = self._customer('هماهنگی بر بازدید بی‌اثر')
self._add(customer, 'hamahangi', 'finish', at(+7), finishDateTime=at(0))
self.assertIsNone(last_visit_dt(customer))
# --- هم‌ترازی صفحه و اسکنر ---
def test_scanner_also_skips_customer_with_appointment(self):
from admin_panel.models import PhoneNumber, SMSProviderConfig, SMSQueue
from admin_panel.sms.scanner import scan_visit
customer = self._customer('قرار + اسکنر')
PhoneNumber.objects.create(customer=customer, phone_number='09120000999')
config = SMSProviderConfig.load()
config.allow_backlog = True
config.backlog_max = 50
config.scan_since = at(-2000)
config.save()
self._add(customer, 'bazdid', 'waite', at(+5))
now = at(0)
scan_visit(timezone.localdate(), now=now)
self.assertFalse(SMSQueue.objects.filter(customer=customer).exists())
class LastVisitMapEquivalenceTests(TestCase):
"""نسخهٔ SQL ``last_visit_map`` باید دقیقاً همان خروجی نسخهٔ پایتونی را بدهد."""
NOW = datetime.datetime(2026, 6, 15, 12, 0, tzinfo=datetime.timezone.utc)
def setUp(self):
# رکوردهای مرزی که قاعده را به چالش می‌کشند.
cases = [
# (action, finish) هر دو در گذشته ⇒ action ملاک است.
(self.NOW - timedelta(days=30), self.NOW - timedelta(days=31)),
# action در آینده ⇒ finish ملاک است.
(self.NOW + timedelta(days=10), self.NOW - timedelta(days=2)),
# هر دو در آینده ⇒ هیچ تاریخ معتبری ندارد.
(self.NOW + timedelta(days=10), self.NOW + timedelta(days=11)),
# فقط action.
(self.NOW - timedelta(days=5), None),
# فقط finish.
(None, self.NOW - timedelta(days=4)),
# هیچ‌کدام.
(None, None),
# دقیقاً برابر «حالا».
(self.NOW, self.NOW),
]
for index, (action_dt, finish_dt) in enumerate(cases):
customer = make_customer(f'همسانی {index}')
Action.objects.create(
customer=customer, actionType='bazdid', status='finish',
actionDateTime=action_dt, finishDateTime=finish_dt,
)
def test_sql_matches_python_reference(self):
from admin_panel.visit_policy import _last_visit_map_python
py = _last_visit_map_python(now=self.NOW)
sql = last_visit_map(now=self.NOW)
self.assertEqual(py, sql)
def test_sql_matches_reference_for_other_clocks(self):
from admin_panel.visit_policy import _last_visit_map_python
for delta in (timedelta(days=-365), timedelta(days=1), timedelta(days=400)):
now = self.NOW + delta
self.assertEqual(
_last_visit_map_python(now=now), last_visit_map(now=now),
msg=f'clock shift {delta}',
)
def test_timezone_cutoff_is_utc_not_local(self):
"""مرز زمانی نباید با منطقهٔ محلی جابه‌جا شود (باگ ۳:۳۰ ساعتهٔ تهران)."""
customer = make_customer('مرز زمانی')
# رکوردی که ۵ دقیقه بعد از «حالا» است ⇒ نباید حساب شود.
Action.objects.create(
customer=customer, actionType='bazdid', status='finish',
actionDateTime=self.NOW + timedelta(minutes=5),
finishDateTime=self.NOW + timedelta(minutes=5),
)
self.assertNotIn(customer.pk, last_visit_map(now=self.NOW))
class NextAppointmentEquivalenceTests(TestCase):
"""نسخهٔ SQL ``next_appointment_map`` باید همان نگاشت مرجع باشد."""
NOW = datetime.datetime(2026, 6, 15, 12, 0, tzinfo=datetime.timezone.utc)
def test_matches_reference(self):
from admin_panel.visit_policy import next_appointment_map
customer_a = make_customer('قرار الف')
customer_b = make_customer('قرار ب')
customer_c = make_customer('قرار ج')
Action.objects.create(customer=customer_a, actionType='bazdid', status='waite',
actionDateTime=self.NOW + timedelta(days=5))
Action.objects.create(customer=customer_a, actionType='bazdid', status='waite',
actionDateTime=self.NOW + timedelta(days=9))
Action.objects.create(customer=customer_b, actionType='hamahangi', status='finish',
actionDateTime=self.NOW + timedelta(days=3),
finishDateTime=self.NOW)
Action.objects.create(customer=customer_c, actionType='bazdid', status='waite',
actionDateTime=self.NOW - timedelta(days=5))
result = next_appointment_map(now=self.NOW)
self.assertEqual(result[customer_a.pk], self.NOW + timedelta(days=5))
self.assertEqual(result[customer_b.pk], self.NOW + timedelta(days=3))
self.assertNotIn(customer_c.pk, result)
class VisitDedupCleanupTests(TestCase):
"""پاک‌سازی کلیدهای آلودهٔ دفتر یکتاسازی (بدون لمس دادهٔ مشتری)."""
def _keys(self):
from admin_panel.models import SMSDedup
return set(SMSDedup.objects.values_list('key', flat=True))
def test_removes_only_absurd_keys_by_default(self):
from admin_panel.models import SMSDedup
SMSDedup.objects.bulk_create([
SMSDedup(key='visit:10:24663'), # عدد بی‌معنا (باگ تقسیم قدیمی)
SMSDedup(key='visit:11:1'), # قدیمی ولی منطقی
SMSDedup(key='visit:12:2026-03'), # قالب جدید
SMSDedup(key='welcome:5'), # نوع دیگر
])
call_command('cleanup_visit_dedup', stdout=io.StringIO())
self.assertEqual(
self._keys(),
{'visit:11:1', 'visit:12:2026-03', 'welcome:5'},
)
def test_all_legacy_flag_removes_numeric_keys_too(self):
from admin_panel.models import SMSDedup
SMSDedup.objects.bulk_create([
SMSDedup(key='visit:11:1'),
SMSDedup(key='visit:12:2026-03'),
])
call_command('cleanup_visit_dedup', '--all-legacy', stdout=io.StringIO())
self.assertEqual(self._keys(), {'visit:12:2026-03'})
def test_dry_run_deletes_nothing(self):
from admin_panel.models import SMSDedup
SMSDedup.objects.create(key='visit:10:24663')
call_command('cleanup_visit_dedup', '--dry-run', stdout=io.StringIO())
self.assertEqual(self._keys(), {'visit:10:24663'})
def test_customer_and_action_rows_untouched(self):
from admin_panel.models import SMSDedup
customer = make_customer('مشتری پاک‌سازی کلید')
action = make_visit(customer, action_dt=at(-30), finish_dt=at(-30))
before_action = action_snapshot(action)
before_customer = Customer.objects.values().get(pk=customer.pk)
SMSDedup.objects.create(key='visit:1:24663')
call_command('cleanup_visit_dedup', stdout=io.StringIO())
self.assertEqual(action_snapshot(action), before_action)
self.assertEqual(Customer.objects.values().get(pk=customer.pk), before_customer)
self.assertEqual(self._keys(), set())