diff --git a/PROJECT_TREE.txt b/PROJECT_TREE.txt index 05f7bb5..ea2be3f 100644 --- a/PROJECT_TREE.txt +++ b/PROJECT_TREE.txt @@ -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 diff --git a/admin_panel/management/commands/cleanup_visit_dedup.py b/admin_panel/management/commands/cleanup_visit_dedup.py new file mode 100644 index 0000000..e0e4bed --- /dev/null +++ b/admin_panel/management/commands/cleanup_visit_dedup.py @@ -0,0 +1,81 @@ +"""پاک‌سازی کلیدهای «آلوده» دفتر یکتاسازی یادآوری بازدید. + +چرا لازم است؟ کلید یکتاسازیِ یادآوری بازدید، در نسخهٔ قدیمی ``visit::<شمارهٔ چرخه>`` +بود که شمارهٔ چرخه با تقسیم روز بر یک عدد ثابت محاسبه می‌شد. وقتی آن عدد صفر می‌شد، +عددهای بی‌معنا (مثل ``visit:1601:24663``) ساخته و برای همیشه در ``SMSDedup`` ثبت +می‌شدند؛ نتیجه‌اش قفل دائمی یادآوری برای همان مشتری بود. + +قاعدهٔ جدید کلید را با «موعد تقویمی» می‌سازد: ``visit::``. کلیدهای قدیمی +هرگز با کلید جدید برخورد نمی‌کنند، بنابراین بی‌ضرر ولی بازدارنده‌اند. + +این دستور **فقط** کلیدهای بی‌معنا/قدیمیِ دفتر را حذف می‌کند و هیچ مشتری، اقدام یا +پیامکی را تغییر نمی‌دهد. + + 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::<عدد>) حذف شوند، " + "نه فقط عددهای بی‌معنا.") + + 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:,} کلید حذف شد (فقط از دفتر یکتاسازی)." + )) diff --git a/admin_panel/management/commands/recalc_last_bazdid.py b/admin_panel/management/commands/recalc_last_bazdid.py index bcbb719..3ff043c 100644 --- a/admin_panel/management/commands/recalc_last_bazdid.py +++ b/admin_panel/management/commands/recalc_last_bazdid.py @@ -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}") diff --git a/admin_panel/migrations/0078_action_visit_covering_index.py b/admin_panel/migrations/0078_action_visit_covering_index.py new file mode 100644 index 0000000..817a5ec --- /dev/null +++ b/admin_panel/migrations/0078_action_visit_covering_index.py @@ -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', + ), + ), + ], + ), + ] diff --git a/admin_panel/models/actions.py b/admin_panel/models/actions.py index 5dc48bb..e85eeba 100644 --- a/admin_panel/models/actions.py +++ b/admin_panel/models/actions.py @@ -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', + ), + ] diff --git a/admin_panel/sms/scanner.py b/admin_panel/sms/scanner.py index 1df386e..048f237 100644 --- a/admin_panel/sms/scanner.py +++ b/admin_panel/sms/scanner.py @@ -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) diff --git a/admin_panel/views/actions.py b/admin_panel/views/actions.py index 9a74408..99274e9 100644 --- a/admin_panel/views/actions.py +++ b/admin_panel/views/actions.py @@ -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() diff --git a/admin_panel/views/customers.py b/admin_panel/views/customers.py index 58eaca1..5f8f23d 100644 --- a/admin_panel/views/customers.py +++ b/admin_panel/views/customers.py @@ -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. diff --git a/admin_panel/views/sms.py b/admin_panel/views/sms.py index 6b98ac2..364bc73 100644 --- a/admin_panel/views/sms.py +++ b/admin_panel/views/sms.py @@ -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) diff --git a/admin_panel/visit_policy.py b/admin_panel/visit_policy.py new file mode 100644 index 0000000..1a0edfe --- /dev/null +++ b/admin_panel/visit_policy.py @@ -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) diff --git a/graphify-out/.graphify_labels.json b/graphify-out/.graphify_labels.json index cc819f2..83457a5 100644 --- a/graphify-out/.graphify_labels.json +++ b/graphify-out/.graphify_labels.json @@ -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" } diff --git a/graphify-out/.graphify_labels.json.sig b/graphify-out/.graphify_labels.json.sig index 5b0d59e..12fd07c 100644 --- a/graphify-out/.graphify_labels.json.sig +++ b/graphify-out/.graphify_labels.json.sig @@ -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"} \ No newline at end of file +{"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": "9f8c26fb5fbe89a4", "190": "edb619bf5ac0fc49", "191": "d5c0f5f6714c9991", "192": "9948fb8b548535ed", "193": "c02bc9595eaedd36", "194": "7714d721fe893ef5", "195": "ef8221c5fdeee7f8", "196": "8d20e5c1bce8916d", "197": "bf60a8731033e467", "198": "7ca812ef30c0752b", "199": "ef1669fc78d119b0", "200": "f0242773793fa7bf", "201": "84208a7b8507da66", "202": "9144923ab31b52a3", "203": "1e2795525cbc6fa2", "204": "e298c218ccbada05", "205": "79d00401c99b2005", "206": "55d209d7b64c2954"} \ No newline at end of file diff --git a/graphify-out/GRAPH_REPORT.md b/graphify-out/GRAPH_REPORT.md index da816a2..dc1d52c 100644 --- a/graphify-out/GRAPH_REPORT.md +++ b/graphify-out/GRAPH_REPORT.md @@ -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._ diff --git a/graphify-out/graph.html b/graphify-out/graph.html index 5ae0f26..ed62b1b 100644 --- a/graphify-out/graph.html +++ b/graphify-out/graph.html @@ -63,12 +63,12 @@
-
1960 nodes · 3584 edges · 184 communities
+
1991 nodes · 3632 edges · 207 communities