Compare commits

..
4 Commits
Author SHA1 Message Date
javad 2e5d0bcdd3 some improve 2026-10-27 11:49:09 +03:30
javad fa73a734a0 improve bazdid 6 2026-10-22 14:01:08 +03:30
javad 99c25a8216 improve bazdid 5 2026-10-21 16:34:38 +03:30
javad 825f2a5973 improve bazdid 4 2026-10-21 16:34:13 +03:30
38 changed files with 34822 additions and 8191 deletions

No files matched your search

+50 -9
View File
@@ -1,11 +1,11 @@
Project Root: khatibi
Generated At: 2026-10-21T10:54:58+00:00
Python Files: 188
Generated At: 2026-10-27T07:48:20+00:00
Python Files: 194
Django Files: 12
JavaScript/TypeScript Files: 14
HTML Files: 58
Total Files: 754
Total Directories: 79
HTML Files: 59
Total Files: 794
Total Directories: 81
khatibi
├── .cursor
@@ -130,6 +130,8 @@ khatibi
│ │ ├── 0077_alter_smstemplate_text_balance_help.py
│ │ ├── 0078_action_visit_covering_index.py
│ │ ├── 0079_customer_visit_hold.py
│ │ ├── 0080_action_soft_delete.py
│ │ ├── 0081_sms_type_install_created.py
│ │ └── __init__.py
│ ├── models
│ │ ├── __init__.py
@@ -242,7 +244,6 @@ khatibi
│ │ │ └── nazar.html
│ │ ├── _customer_info.html
│ │ ├── _customer_phones.html
│ │ ├── _customer_sms.html
│ │ ├── ActionList.html
│ │ ├── base.html
│ │ ├── baseHesabdari.html
@@ -325,16 +326,21 @@ khatibi
├── data9
│ ├── backup-20261020-180934.sqlite3
│ ├── backup-20261020-213406.sqlite3
│ ├── backup-20261020-213406.sqlite3-shm
│ ├── backup-20261020-213406.sqlite3-wal
│ ├── db2 (2).sqlite32
│ ├── db2.sqlite32
│ ├── db222.sqlite3
│ ├── khatibi92669266_backups_db_backup_20261020_223011.sqlite3
│ ├── khatibi92669266_backups_db_backup_20261020_223011.sqlite3-shm
│ ├── khatibi92669266_backups_db_backup_20261020_223011.sqlite3-wal
│ ├── kt.sqlite3
│ ├── kt.sqlite3-shm
│ └── kt.sqlite3-wal
│ ├── khatibi92669266_backups_db_backup_20261021_223009.sqlite3
│ ├── khatibi92669266_backups_db_backup_20261021_223009.sqlite3-shm
│ ├── khatibi92669266_backups_db_backup_20261021_223009.sqlite3-wal
│ └── kt.sqlite3
├── docs
│ ├── entezar_action_fields_report.html
│ ├── entezar_action_fields_report.md
│ └── visit_hold_entezar.md
├── graphify-out
│ ├── 2026-10-09
@@ -372,12 +378,21 @@ khatibi
│ │ ├── graph.json
│ │ ├── GRAPH_REPORT.md
│ │ └── manifest.json
│ ├── 2026-10-22
│ │ ├── .graphify_analysis.json
│ │ ├── .graphify_labels.json
│ │ ├── .graphify_semantic_marker
│ │ ├── graph.json
│ │ ├── GRAPH_REPORT.md
│ │ └── manifest.json
│ ├── cache
│ │ ├── ast
│ │ │ └── v0.9.82-s7
│ │ │ ├── 0075bdcd95747f7c750eba8757614aab71d4d8c77bd6764e4c5a98fe18dc8997.json
│ │ │ ├── 0175e34128b5ac5119a5da4cc53d889dce334d8f20357631d54627c53cae9b8e.json
│ │ │ ├── 01850b7fad89dcb9e521fceed2c41e64e9909a0f4c4a54d818d346ccf63bbc5d.json
│ │ │ ├── 01b87542be8c5f2850a08a631287f94a89184e186d5cfca4d585800f41ee0f5d.json
│ │ │ ├── 0317da4b3f4f0b0b5f6cf487567ec7e1b6f3bed2c6ad5cb19bd526a020521c10.json
│ │ │ ├── 0348dd4662ebd96572421864ce77a266334a327f8bcbdc237d54c313d116bafb.json
│ │ │ ├── 03c6fb41770a9293658a5db5af36f7e4afe64ae04b3ce8cc6c5874320706fd22.json
│ │ │ ├── 0400cc0eadd706ff70ef627ffe39f401fc72a591dcbcd5463fca07fc271f080d.json
@@ -401,8 +416,11 @@ khatibi
│ │ │ ├── 14f64f9b156b6b520e7af189b303a2844728f3ab8bbe1e65fed49160b1431cb2.json
│ │ │ ├── 15be5091a5bfbf8f48c3f3ae344a980be5452eab00f054c001958aae96557514.json
│ │ │ ├── 174e406c2649f2384921581cbf912945a95d54aab73707b9aa45752aaa7c54d7.json
│ │ │ ├── 17850e40dfa0d02ec3b0d4b2e1f9b931fe6a3a90c6cd874dabeb9466bed5f1d1.json
│ │ │ ├── 183467d5c1ee7daac78dfb428bd335c17e17272bd4510358f38fc5924140c816.json
│ │ │ ├── 1893253c5245ae073f9de9168d537384ab764b79d7b1631c785cd7335aab791b.json
│ │ │ ├── 1b244c373e08c304261f7f760687c7ebe0ba9c79e7ab7ea16c3fa4768da959f5.json
│ │ │ ├── 1ceebfc94adbc38aa951c83044b67943bc4032309ba3a61d9335dd833d49bbef.json
│ │ │ ├── 1d5a29a9dabb365c079039c788988636b66c3a4bd2dbe66b249df001c2b5396f.json
│ │ │ ├── 1d7a28f73bb52bbc018674a0c01405b7f395bcc5f340ecabe332e2fe440bcae8.json
│ │ │ ├── 1d9f15f04104af07c60349f21bcd9f6f2c0e65c915e3e9c69040a10197b0fa6f.json
@@ -412,11 +430,13 @@ khatibi
│ │ │ ├── 21af53aa7322868a25b47458e59f339a8f6e58b7914fbe8e3389ab86e0b0b643.json
│ │ │ ├── 22310d4d49f2beee611c83915933ea06e80ec562fd92c170345c556cd3772561.json
│ │ │ ├── 2356cb9f067d43739312459bad2cb6d9ecbacac703e7e40a282d5b89d64332b3.json
│ │ │ ├── 23c92d4714bc2e410ae9267e6ea7c139d5b16215da917f9a89c44a29724c69d7.json
│ │ │ ├── 23da13b308596ede7678e92e57041bcecdd8e9baff04b759ddf9a69803dd790a.json
│ │ │ ├── 23f4e3a62e03a38743659d94dd1350d17775da8be9175846e4ff3f38fa87c515.json
│ │ │ ├── 24508e7a9b6fa93cdd28b9436bdb55fa24ad8f324baa95adb3afd37e301af9ac.json
│ │ │ ├── 2501a9885d6fa5292276ee8cc7db63a9a3c08605f38d199d425d33f650dac35e.json
│ │ │ ├── 25dbf169bbd8ae50f7cc568b4dbb7d259eb993ccddc9dbb20db96018ed093f5a.json
│ │ │ ├── 28b192ecdcd9b33d8e4980d5043b6b2bc59095df392e22bf44d2166f7fa6d5ba.json
│ │ │ ├── 28df8bbd275806fd77c086629211231bd1329fe8156f521b19b5c5979f0d3b51.json
│ │ │ ├── 294e2d390f56acc0445564d428d90c641d76496cbc457bcfb09876382e9d87ab.json
│ │ │ ├── 2976c530804df2075019fedaf2b908eeeff37b2703310c7e031b653d1fec71e5.json
@@ -429,7 +449,9 @@ khatibi
│ │ │ ├── 33b32e27be802f61375110f7f4ac9419fd4eb633523e01e31774b874448849c1.json
│ │ │ ├── 3614ae46baf3e1b6efa17011c37407a5d2931aba1886d8f8bdf8e4e154ab6c3f.json
│ │ │ ├── 370c10e9d6f01aa67799b1f2191d5749a69ce4541aa55cbf4a28d45732bd641b.json
│ │ │ ├── 373e16b6c6996757d88a6ac751cd44028efa64701d746f8536819889417125ff.json
│ │ │ ├── 38980449465279974120694e1c667f229049f75d656fe518196d9cadc2429155.json
│ │ │ ├── 3a341a98aef632503bf2dc67ad87833e604c9aabe7ff69f5bcff7b54d6fe6407.json
│ │ │ ├── 3ba0d4c75ab8bfa5bc2a60dccb74197cb94bd161e526e54770a9b0a2aa757806.json
│ │ │ ├── 3c9f8987dc7426b442fe2a90c7de5d909fc85988e172e7722c11f24a2c331481.json
│ │ │ ├── 3d94db4e528b0e06bb975a12c71733767fe57307bb6e3de7c6ce65721e097541.json
@@ -444,6 +466,7 @@ khatibi
│ │ │ ├── 45f7e59ef3f291171180b6763e8da408d8b7579bb7cb96dd4ca859e48aa5ce14.json
│ │ │ ├── 46616763804b2b938d73c75f7651158e94803e0b86b3a759ecdd59375759eea3.json
│ │ │ ├── 46bde576f6f1e69457e4ad33238fdbd602d2a7f027644a84cb81068215fbd5e1.json
│ │ │ ├── 4723c76012231305253c473fb5f2b81fa35a49f9f4aa38f54e9af3285aa51a02.json
│ │ │ ├── 477b0c06bcadb94314490ac76899f499d45a942aadef1e65623d303342157463.json
│ │ │ ├── 492a9871203480b590199773e90727872f60f06013713a4a2936d87986cdbbe5.json
│ │ │ ├── 4da7df08a0ef803551d404438fe24d6c8cc11d9a2c3cc8d85e37d5228ba3a166.json
@@ -451,6 +474,7 @@ khatibi
│ │ │ ├── 4e604dd03a70463ae25f6615ea3ce4fe8e98fab15d6b11ddafd51d601062e84d.json
│ │ │ ├── 506e815cbeaaf3aa6e448dcbf70c10b4d118dff41e05a292fb8ec6d76dd391c4.json
│ │ │ ├── 507e42a0bbe7a582ce1fcb77651e1cfde643907ca0429fedfe11afe7ae3b11c4.json
│ │ │ ├── 50b38b9cbcb9d5aad3a94b28cb983b3694926bc20e0381419b82008efd042d65.json
│ │ │ ├── 52608b276c6f0a2f190186e5bac578715ad888ff5ba3c10ff30879c3baf9d9ce.json
│ │ │ ├── 527de91bd8bbc5a7ab79951457a9f192909faa9bbf02aaec72bb3d26e2298b57.json
│ │ │ ├── 5316515ee7fc3e1038e676b361d9028edc735993c3d34791a77a14503deb064d.json
@@ -474,8 +498,10 @@ khatibi
│ │ │ ├── 5e19aa3aba5509cd48a9a93b39db071cfc2c2728ccae46b34caa0e214cc6be01.json
│ │ │ ├── 5f050da3769e151e52138ee1ea5dcbcb6160cb57952c75665e607628d0e08d67.json
│ │ │ ├── 5f858ab6d9f90e0f41617273b6f132af3f76f520deafb8d612adce373875e768.json
│ │ │ ├── 5fe315e6417b8eb8b26079139d8bbe46ddaaf7cd67b078d927ea79d3815be453.json
│ │ │ ├── 60002a01d6fc22147e2994d1a1a447d5e577c02aed2443789af20571ec2d2a19.json
│ │ │ ├── 60e00560038df059b1696e54dcf055cd658677d6e1201570a8500531addc0ac6.json
│ │ │ ├── 611c158815bdf3dbec9f5a5c01bce751fcd07db536e9bbaba2b2d232f82f5ae6.json
│ │ │ ├── 62d88411a61ee9d3b60dda6c4febf33539b8b9e8226b5d2b9205f8e2b0c4478a.json
│ │ │ ├── 63c38f2a8abe98c0d1e9cb5b7d9fd0451e1e46c2bcbf1d2c3b241b263ac0a9cd.json
│ │ │ ├── 6448d65fdcfe330b5d4278c8d1540e7b075238672b8101f9d2e1c4052f7c9c49.json
@@ -491,7 +517,10 @@ khatibi
│ │ │ ├── 6aca679f90689aa4c275389c8b4e2462a9456775e8b124071070af2c988ca0fc.json
│ │ │ ├── 6afa6136d9df8317bdd8d49f4109412776deb21ba123fdd2a97331b400a0230d.json
│ │ │ ├── 6bfbb9eed6842bf331dec78724d7e740919c76345b54be844964f44640482a87.json
│ │ │ ├── 6d026e901d3b2ff0cb9550c750966905afbbc681ba68db88e1de608fb2964495.json
│ │ │ ├── 6d4d7d7f829f13e46e4898aceb760d66d2255dc838eab0933f9a92d720f420a7.json
│ │ │ ├── 6d556671b5632e29972dc09e345d68d522a5b3bcd2852b803d56668890fa46d7.json
│ │ │ ├── 6dcafcac3a0b169a08580cc4cb9196423dacfa51329841311665c363a97bc29e.json
│ │ │ ├── 6df9c1e88224e95c55dee4238fa91358360231ee47362294e884bc6e24d73612.json
│ │ │ ├── 6e262456570c174e207055c7c964fe85431addb0f59298b1e4d912500dfb23a3.json
│ │ │ ├── 6e2ed39911a83c75e432ffd0dab22fd095ce8c5bb186d6ab22d594375f3b32bc.json
@@ -509,6 +538,7 @@ khatibi
│ │ │ ├── 77a1520156879de714ee872c29b1387967bcc5ce470b47d37087ea2a110f3875.json
│ │ │ ├── 780977bca042ffdde1cca2811a3a46c36bd3fcff2723c94ac527428113131c64.json
│ │ │ ├── 784dd791c21e8837a3e925331cf234b2138ded3d2012968739604d5535a18e09.json
│ │ │ ├── 78d30d0a40b28969d64653d2508fd53e53709b95a45f3e380ebd6c7aff04ace9.json
│ │ │ ├── 79ffc45a7139464dd6c7b1fc6f1f0f9c44347fbd7dcee92c6d4b63525424c442.json
│ │ │ ├── 7be7f428e6acfe4c99370707cc7de9253f801feff1564088f2584d5dc8c42a89.json
│ │ │ ├── 7bf404104bb32fc04568c2d287891fa69955ac3f46718c99dcca64a4bfff6fe3.json
@@ -542,6 +572,7 @@ khatibi
│ │ │ ├── 9766508e5a013c3d3cbb2602719391199e90af5720c9782a77ca71565aa346b1.json
│ │ │ ├── 9880af8b991c19abe5ed019ad7b7291b8b281ed9dcb894a00c3fd8259a59b4a4.json
│ │ │ ├── 996411678d23c3cf8a0e6c1c5076701e8bf5f3dad21ca907ec4116d15366a668.json
│ │ │ ├── 9b2fd03f2769231e4c207b2f0ee915e3b5eb050232f2fe6c59ddb01904976650.json
│ │ │ ├── 9c2c2f6f8d4d4c3a9a60da3ed38a26dc789e8cfaff5d46d140d2f208e5fef3c3.json
│ │ │ ├── 9cfb6e4f7b08a863675af032e7823c0523170f5201e686c5ef72b39c04b07a12.json
│ │ │ ├── 9eabdb0cacba38c8fb6b3e777932e06bd82172cdc0bcccf2fc0bfc23b5bd4974.json
@@ -566,6 +597,7 @@ khatibi
│ │ │ ├── b59999c2fd322c241c7bd37e6ed0a95c9124cbb79f8424f1fa7cb2e92fe63817.json
│ │ │ ├── b82d5f6840b4930eb62fdb1c7b3a58124fff805e529ede7684516a65322fcae0.json
│ │ │ ├── b87a932e418a18531d61dc15776db652401e7cf1682dead02abdf50e2cd00530.json
│ │ │ ├── b9b878bef7c0ae7d407a42c797a4697990237971be65a5d01a6c7b9b444a2a9a.json
│ │ │ ├── b9f8ea31056fe0c0fc40a84640c58f29895312c65bf694567487204d8f1170e9.json
│ │ │ ├── bb140cfe12cfb3e32dc9815393035fc33c7649c2faeeccb82cdee56a930ea635.json
│ │ │ ├── bb74cda4073a6da7a4b60fecfb3bb40796a51ceafcbdd128d60e5ce23fa4f319.json
@@ -611,9 +643,11 @@ khatibi
│ │ │ ├── d9590fb13a20db20da78d33674f56397a18e3356d08dab6f7c0739130caee096.json
│ │ │ ├── db68b8db5217e2295eb1195760d7352960627da044a4c4f8f6b89c67ad2f8f57.json
│ │ │ ├── db794bbcafe219af962673ef26efb1ee1eacf3923e13113352f3eedfd3192d23.json
│ │ │ ├── db81ec42bfd68175829c568dac66c2844afba128b94afa2accdf24ed67aeccaf.json
│ │ │ ├── dbbdad9d992922aff62ad3262a531716cfc32e2f15f600773b9a0cf10545058d.json
│ │ │ ├── dc0344515d1b3ffe8643560516b88418be0b306fba56101b8a630ed73d48db46.json
│ │ │ ├── dcad55d51bb4494a642af555d5bfe78c5b8ee3d11de1b3d8f4286d608980854b.json
│ │ │ ├── ddd79719e349936349e9ec7684d3eec146d3b73f95a419ad142ae603ab199acd.json
│ │ │ ├── e08c4784f0f82403ff24a1676e3d75e11a208aadf8fe94a723461c9658afd1e0.json
│ │ │ ├── e0eb3c543474df255f97ae0679df48999b86b75dc42943b9d67f4cfcf1acae34.json
│ │ │ ├── e16f78807ff202fa2c81867b321acfacede2d24cc785856eef0633d8d352796b.json
@@ -628,6 +662,7 @@ khatibi
│ │ │ ├── e9e14d581ed59799b2d83c67402e8555b5b59e7d49425950ec490422bd0307d9.json
│ │ │ ├── eaceb29a907e3e67bed5255cc1f8b8b3eab9d9946f4d4df2cc99070151285029.json
│ │ │ ├── ec07e022005d3513838eee7e6618ff060e25d914cab7745aa6172f22d4089497.json
│ │ │ ├── ec3570ad9673cc5dfc9a0d12f208b7f4d897d28d1ae2974d6a3fa63ad27598d3.json
│ │ │ ├── eccad1f007b52643ce8d81576efb7d1969ecbff2e9db288989fd9a9c21fd7288.json
│ │ │ ├── ecd9c1dbfa0a72e253adb680ae7088eff24c26360e53a65862b274ea1281ceae.json
│ │ │ ├── ee450e6251e62d8972efb53bc35145c4a65050a7b6db2954b84bda42fca7b704.json
@@ -643,6 +678,7 @@ khatibi
│ │ │ ├── f72f6f3fe9d226e4717e158dedd318fff0a1d52ece54f965311c2b4f862dcfee.json
│ │ │ ├── f76726d2898f2a3ba7168d3fb77c21087432eb9c200631b664ecb3b1837aaf17.json
│ │ │ ├── f92b94479afedf47039d8821a77facb55d9a862338e85876b80cac3b41c844f6.json
│ │ │ ├── fa4a8a35e96c919372da27c7af11abea0c89fff941c0b20dbc4e48c15f32597c.json
│ │ │ ├── fb6825c4e9ffba13d17a2da18a1e07aef792089a7cd24b31e7f4f32ee39287e8.json
│ │ │ ├── fbfc351c35c5272de5612b4740e380b06a24c37f8074b977e35f62ad85a6dcc0.json
│ │ │ ├── fc6ae63ae2639372c4ae4fbe9718feca814261dda099ba58bb780d14c69086cd.json
@@ -788,6 +824,10 @@ khatibi
│ ├── settings.py
│ ├── urls.py
│ └── wsgi.py
├── scripts
│ ├── coordination_hold_order_probe.py
│ ├── entezar_action_audit.py
│ └── hold_write_verification.py
├── static
│ └── .gitkeep
├── tests
@@ -795,6 +835,7 @@ khatibi
│ ├── factories.py
│ ├── test_customer_name_fix.py
│ ├── test_customers.py
│ ├── test_entezar_action_simulation.py
│ ├── test_factors.py
│ ├── test_inventory.py
│ ├── test_models_contract.py
@@ -97,7 +97,8 @@ class Command(BaseCommand):
# آمار نظرسنجی‌ها
nazar_stats = Action.objects.filter(
nazarDT__range=(today_start, today_end),
grade__gt=0
grade__gt=0,
deleted_at__isnull=True,
).aggregate(
avg_grade=Avg('grade'),
total_count=Count('id')
@@ -0,0 +1,26 @@
# Generated by Django 3.2.25 on 2026-10-21 12:31
from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
('admin_panel', '0079_customer_visit_hold'),
]
operations = [
migrations.AddField(
model_name='action',
name='deleted_at',
field=models.DateTimeField(blank=True, default=None, help_text='اگر پر باشد، این اقدام توسط کاربر حذف شده است (status=cancell).', null=True, verbose_name='زمان حذف'),
),
migrations.AddField(
model_name='action',
name='deleted_by',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='deleted_actions', to=settings.AUTH_USER_MODEL, verbose_name='حذف\u200cکننده'),
),
]
@@ -0,0 +1,113 @@
"""افزودن نوع پیامک ``install_created`` («ثبت نصب — اقدام از نوع نصب»).
**این مهاجرت هیچ دادهٔ موجودی را تغییر نمی‌دهد.** سه کار انجام می‌شود:
۱. ``choices`` سه فیلد ``sms_type`` (قالب، تنظیم مشتری، صف) به ۸ نوع گسترش
می‌یابد تا مقدار جدید در پنل/اعتبارسنجی معتبر باشد. ``AlterField`` فقط
متادیتای ستون را عوض می‌کند و به ارزش ردیف‌ها دست نمی‌زند.
۲. **فقط** یک قالب تازه برای نوع جدید ساخته می‌شود (``get_or_create``): اگر از
قبل وجود داشته باشد، هیچ فیلدی از آن بازنویسی نمی‌شود.
* ``is_enabled=True`` ⇒ از پنل قالب‌ها دیده/ویرایش شود.
* ``auto_send=False`` (صریح) ⇒ در صف «در انتظار تأیید» می‌ماند تا مدیر خودش
تأیید/ارسال کند؛ هیچ پیامکی ناخواسته ارسال نمی‌شود.
۳. قالب‌های هفت نوع قبلی حتی لمس هم نمی‌شوند: در این فایل هیچ ``update``/``save``
روی آن‌ها وجود ندارد.
متغیرهای متنی همان ``{name}`` و ``{date}`` هستند؛ نیازی به تغییر ساختار نیست.
"""
from django.db import migrations, models
SMS_TYPE_CHOICES = [
("welcome", "خوش‌آمدگویی (ایجاد مشتری)"),
("survey", "نظرسنجی پس از ثبت فاکتور"),
("cheque_registration", "یادآوری تأمین موجودی چک"),
("visit", "یادآوری بازدید خدمات دستگاه"),
("visit_created", "ثبت بازدید (اقدام از نوع بازدید)"),
("install_created", "ثبت نصب (اقدام از نوع نصب)"),
("payment_thanks", "تشکر از پرداخت"),
("nesiye_due", "یادآوری سررسید نسیه"),
]
NEW_SMS_TYPE = "install_created"
# قالب پیش‌فرض نوع جدید. متن مشابه «ثبت بازدید» است تا لحن پیام‌ها یکدست بماند.
INSTALL_TEMPLATE = {
"title": "ثبت نصب",
"text": "{name} عزیز، نصب شما برای تاریخ {date} ثبت شد. "
"از همکاری شما سپاسگزاریم.",
"is_enabled": True,
# خاموش: پیامک در «در انتظار تأیید» می‌ماند (رفتار پیش‌فرض امن پروژه).
"auto_send": False,
"offset_days": 0,
"repeat_interval_days": None,
"repeat_interval_months": None,
}
def seed_install_template(apps, schema_editor):
"""ایجاد قالب «ثبت نصب» اگر وجود ندارد (اجرای دوباره بی‌خطر است).
``get_or_create`` عمداً بدون هیچ ``update`` است: قالب‌های موجود (هر هفت نوع
قبلی) و حتی خودِ ``install_created`` اگر از قبل باشد، دست‌نخورده می‌مانند.
"""
SMSTemplate = apps.get_model("admin_panel", "SMSTemplate")
SMSTemplate.objects.get_or_create(
sms_type=NEW_SMS_TYPE,
defaults={
"title": INSTALL_TEMPLATE["title"],
"text": INSTALL_TEMPLATE["text"],
"is_enabled": INSTALL_TEMPLATE["is_enabled"],
"auto_send": INSTALL_TEMPLATE["auto_send"],
"offset_days": INSTALL_TEMPLATE["offset_days"],
"repeat_interval_days": INSTALL_TEMPLATE["repeat_interval_days"],
"repeat_interval_months": INSTALL_TEMPLATE["repeat_interval_months"],
},
)
def drop_install_template(apps, schema_editor):
"""بازگشت: فقط ردیف‌های همان نوع جدید حذف می‌شوند.
سایر قالب‌ها و تنظیم‌های مشتری دست نمی‌خورند.
"""
apps.get_model("admin_panel", "SMSTemplate").objects.filter(
sms_type=NEW_SMS_TYPE,
).delete()
apps.get_model("admin_panel", "CustomerSMSSetting").objects.filter(
sms_type=NEW_SMS_TYPE,
).delete()
class Migration(migrations.Migration):
dependencies = [
("admin_panel", "0080_action_soft_delete"),
]
operations = [
migrations.AlterField(
model_name="smstemplate",
name="sms_type",
field=models.CharField(
choices=SMS_TYPE_CHOICES, max_length=40, unique=True,
verbose_name="نوع پیامک",
),
),
migrations.AlterField(
model_name="customersmssetting",
name="sms_type",
field=models.CharField(
choices=SMS_TYPE_CHOICES, max_length=40, verbose_name="نوع پیامک",
),
),
migrations.AlterField(
model_name="smsqueue",
name="sms_type",
field=models.CharField(
blank=True, choices=SMS_TYPE_CHOICES, default="", max_length=40,
verbose_name="نوع",
),
),
migrations.RunPython(seed_install_template, drop_install_template),
]
+19
View File
@@ -67,6 +67,25 @@ class Action(models.Model):
nazarDT = models.DateTimeField(null=True, blank=True, default=None)
grade = models.IntegerField(null=True, blank=True, default=None)
# --- حسابرسی «حذف» (soft-delete از طریق status='cancell') ---
# چرا ستون جدا؟ چون ``cancell`` از قبل معنای «لغو برنامه» را دارد و در گزارش‌ها
# هم استفاده می‌شود. با این دو ستون می‌توانیم «حذف توسط کاربر» را از «لغو
# عادی» تفکیک کنیم، بدون آنکه منطق محاسبات (که فقط waite/finish را می‌بیند)
# دست بخورد.
deleted_at = models.DateTimeField(
null=True, blank=True, default=None, verbose_name="زمان حذف",
help_text="اگر پر باشد، این اقدام توسط کاربر حذف شده است (status=cancell).",
)
deleted_by = models.ForeignKey(
User, on_delete=models.SET_NULL, null=True, blank=True,
related_name="deleted_actions", verbose_name="حذف‌کننده",
)
@property
def is_deleted(self):
"""آیا این اقدام با «حذف» کنار گذاشته شده است؟ (نه لغو عادی)"""
return self.deleted_at is not None
class Meta:
# ایندکس **پوشا (covering)** برای محاسبهٔ «آخرین بازدید»: هر سه ستونِ لازم
# برای فیلتر و تجمیع داخل خودِ ایندکس است، پس دیتابیس به جدول اصلی مراجعه
+2 -1
View File
@@ -11,7 +11,7 @@ from django.db import models
class SMSType(models.TextChoices):
"""انواع پیامک‌های سیستم (دقیقاً ۷ نوع، به ترتیب چرخهٔ کاری).
"""انواع پیامک‌های سیستم (دقیقاً ۸ نوع، به ترتیب چرخهٔ کاری).
ترتیب تعریف = ترتیب نمایش در پنل. هر نوع یا از یک رویداد (view) یا از یک
اسکنر دوره‌ای در صف قرار می‌گیرد؛ نگاشت آن در ``admin_panel/sms/triggers.py``
@@ -23,6 +23,7 @@ class SMSType(models.TextChoices):
CHEQUE_REGISTRATION = "cheque_registration", "یادآوری تأمین موجودی چک"
VISIT = "visit", "یادآوری بازدید خدمات دستگاه"
VISIT_CREATED = "visit_created", "ثبت بازدید (اقدام از نوع بازدید)"
INSTALL_CREATED = "install_created", "ثبت نصب (اقدام از نوع نصب)"
PAYMENT_THANKS = "payment_thanks", "تشکر از پرداخت"
NESIYE_DUE = "nesiye_due", "یادآوری سررسید نسیه"
+3 -2
View File
@@ -261,8 +261,9 @@ def scan_visit(today, *, now=None):
return count
# فقط همان ۷ نوع مجاز؛ انواع رویدادی (خوش‌آمدگویی، نظرسنجی، ثبت بازدید، تشکر از
# پرداخت) در ``admin_panel/sms/triggers.py`` توسط ویوها صف می‌شوند، نه این‌جا.
# فقط انواعی که «موعد» دارند و دوره‌ای اسکن می‌شوند؛ انواع رویدادی (خوش‌آمدگویی،
# نظرسنجی، ثبت بازدید، ثبت نصب، تشکر از پرداخت) در ``admin_panel/sms/triggers.py``
# توسط ویوها صف می‌شوند، نه این‌جا.
SCANNERS = [
("cheque_registration", scan_cheque_funding),
("nesiye_due", scan_nesiye_due),
+5
View File
@@ -46,6 +46,11 @@ TIMING = {
"field": None,
"note": "به‌محض ثبت «اقدام از نوع بازدید» ارسال می‌شود؛ تنظیم زمانی ندارد.",
},
SMSType.INSTALL_CREATED: {
"kind": NONE,
"field": None,
"note": "به‌محض ثبت «اقدام از نوع نصب» ارسال می‌شود؛ تنظیم زمانی ندارد.",
},
SMSType.PAYMENT_THANKS: {
"kind": NONE,
"field": None,
+18
View File
@@ -7,6 +7,7 @@
* :func:`notify_welcome` — ایجاد مشتری.
* :func:`notify_factor_survey` — ثبت فاکتور (نظرسنجی).
* :func:`notify_visit_scheduled` — ثبت «اقدام از نوع بازدید».
* :func:`notify_install_scheduled` — ثبت «اقدام از نوع نصب».
* :func:`notify_payment_thanks` — ثبت پرداخت (سند بستانکار / وصول).
قرارداد پروژه: استفاده از signal ممنوع است؛ همه‌چیز صریح از دلِ ویو صدا زده می‌شود.
@@ -61,6 +62,23 @@ def notify_visit_scheduled(customer, action, *, created_by=None):
)
def notify_install_scheduled(customer, action, *, created_by=None):
"""پیامک «نصب شما برای تاریخ … ثبت شد» پس از ایجاد اقدام نصب.
قرینهٔ :func:`notify_visit_scheduled` است و همان قرارداد را دارد: فقط برای
اقدام ``nasb`` صدا زده می‌شود، متن از قالب ``install_created`` می‌آید و
یکتاسازی با شناسهٔ همان رکورد اقدام انجام می‌شود (ویرایش دوباره = بدون تکرار).
"""
if customer is None or action is None or action.pk is None:
return 0
return enqueue_sms(
customer=customer, sms_type=SMSType.INSTALL_CREATED,
context={"name": customer.name_family, "date": action.actionDateTime},
dedup_key=f"install_created:{action.pk}",
created_by=created_by,
)
def notify_payment_thanks(customer, amount, *, article_id=None, method=None,
created_by=None):
"""پیامک تشکر از پرداخت؛ پس از ثبت سند پرداخت (بستانکار/وصول).
@@ -1,40 +0,0 @@
{% if perms.admin_panel.ManageSMS %}
<div class="box" dir="rtl">
<h2 class="title is-5">پیامک</h2>
<div class="columns is-multiline">
<div class="column is-12">
<h3 class="subtitle is-6">فعال/غیرفعال پیامک‌ها برای این مشتری</h3>
{% for item in sms_toggles %}
<form method="post" action="{% url 'admin_panel:sms_customer_toggle' customer.id %}" class="field has-addons">
{% csrf_token %}
<input type="hidden" name="sms_type" value="{{ item.code }}">
<input type="hidden" name="is_enabled" value="{% if item.enabled %}false{% else %}true{% endif %}">
<div class="control is-expanded">
<input class="input" type="text" value="{{ item.label }}" disabled>
</div>
<div class="control">
<button class="button {% if item.enabled %}is-success{% else %}is-danger{% endif %}" type="submit">
{% if item.enabled %}فعال{% else %}غیرفعال{% endif %}
</button>
</div>
</form>
{% endfor %}
</div>
<div class="column is-12">
<h3 class="subtitle is-6">ارسال پیامک سفارشی</h3>
<form method="post" action="{% url 'admin_panel:sms_send_custom' customer.id %}">
{% csrf_token %}
<div class="field">
<div class="control">
<textarea class="textarea" name="text" rows="2" placeholder="متن پیامک..."></textarea>
</div>
</div>
<p class="help">می‌توانید از متغیرهایی مثل {name} (نام مشتری) استفاده کنید.</p>
<button class="button is-info" type="submit">ارسال به شماره‌های اصلی</button>
</form>
</div>
</div>
</div>
{% endif %}
@@ -4,7 +4,7 @@
{% block BodyCustomer1 %}
{% include 'admin_panel/customerActionInc.html' %}
{% include 'admin_panel/customerActionInc.html' with show_delete=True %}
<!--
<div id="action" class="column is-12-mobile is-12-desktop ">
@@ -66,7 +66,8 @@
customer_hold='{% if action.customer.visit_hold_until %}{{ action.customer.visit_hold_until|date:"c" }}{% endif %}'
class="columns is-mobile is-multiline is-gapless actionBox {{ bgBox }}">
<div class="column is-12-mobile is-12-desktop actionBoxDesc actionBoxOpenB">
<div class="pr-4 pl-4 pb-4 pt-4">
<div class="pr-4 pl-4 pb-4 pt-4 ax-cardrow">
<span class="ax-cardrow-main">
{% ifequal action.status 'finish' %}
<img class="" style="width: 20px; height: 20px;" src="{% static 'admin_panel/icons/check.png' %}">
{% endifequal %}
@@ -81,6 +82,27 @@
<span class="is-bazdid">{% ifequal action.actionType 'nasb' %}نصب{% endifequal %}{% ifequal action.actionType 'bazdid' %}بازدید{% endifequal %}{% ifequal action.actionType 'vosol' %}وصول مطالبات{% endifequal %}{% ifequal action.actionType 'hamahangi' %}هماهنگی{% endifequal %}{% ifequal action.actionType 'inCall' %}تماس ورودی{% endifequal %}{% ifequal action.actionType 'outCall' %}تماس خروجی{% endifequal %}</span>
<span class="pr-3">{{ action.desc }}</span>
</span>
{% if show_delete %}
{% if action.nazarDT %}
<span class="ax-del is-locked" title="این اقدام دارای نظرسنجی ثبت‌شده است و قابل حذف نیست">
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M12 1a5 5 0 0 0-5 5v3H6a2 2 0 0 0-2 2v9a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2v-9a2 2 0 0 0-2-2h-1V6a5 5 0 0 0-5-5zm-3 8V6a3 3 0 0 1 6 0v3H9z"/></svg>
<span class="ax-del-text">نظرسنجی</span>
</span>
{% else %}
<form method="post" class="ax-del-form"
action="{% url 'admin_panel:customerActionDelete' action.customer.id action.id %}">
{% csrf_token %}
<button type="submit" class="ax-del is-danger"
title="حذف این اقدام"
data-type="{% ifequal action.actionType 'nasb' %}نصب{% endifequal %}{% ifequal action.actionType 'bazdid' %}بازدید{% endifequal %}{% ifequal action.actionType 'vosol' %}وصول مطالبات{% endifequal %}{% ifequal action.actionType 'hamahangi' %}هماهنگی{% endifequal %}{% ifequal action.actionType 'inCall' %}تماس ورودی{% endifequal %}{% ifequal action.actionType 'outCall' %}تماس خروجی{% endifequal %}"
onclick="return confirm('این «' + (this.dataset.type || 'اقدام') + '» حذف شود؟\n\nاقدام از فهرست انتظار/وظایف خارج می‌شود و پیامک ارسال‌نشدهٔ مرتبط لغو می‌گردد. سابقه برای حسابرسی باقی می‌ماند.');">
<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M9 3h6l1 2h4v2H4V5h4l1-2zm-3 6h12l-1 12a2 2 0 0 1-2 2H9a2 2 0 0 1-2-2L6 9zm4 3v8h2v-8h-2zm4 0v8h2v-8h-2z"/></svg>
<span class="ax-del-text">حذف</span>
</button>
</form>
{% endif %}
{% endif %}
</div>
</div>
<div class="column is-12-mobile is-12-desktop actionBoxOpenB">
@@ -166,6 +188,42 @@
</div>
<style>
/* ===== دکمهٔ «حذف» روی کارت اقدام (mobile first) ===== */
.ax-cardrow {
display: flex; align-items: flex-start; justify-content: space-between;
gap: .5rem; flex-wrap: nowrap;
}
.ax-cardrow-main { flex: 1 1 auto; min-width: 0; }
.ax-del-form { margin: 0; flex: 0 0 auto; }
.ax-del {
display: inline-flex; align-items: center; gap: .25rem;
padding: .2rem .45rem; border-radius: .375rem; border: 1px solid #e5e7eb;
background: #fff; color: #6b7280; font-size: .68rem; font-weight: 700;
cursor: pointer; line-height: 1.5; white-space: nowrap;
transition: background-color .15s ease, border-color .15s ease,
color .15s ease, box-shadow .15s ease;
}
.ax-del svg { width: 13px; height: 13px; fill: currentColor; flex: 0 0 auto; }
.ax-del:hover { background: #fef2f2; border-color: #fecaca; color: #b91c1c; }
.ax-del:focus-visible {
outline: 2px solid #fca5a5; outline-offset: 1px;
}
.ax-del.is-danger { border-color: #fee2e2; color: #dc2626; background: #fff; }
.ax-del.is-danger:hover {
background: #dc2626; border-color: #dc2626; color: #fff;
box-shadow: 0 1px 6px rgba(220, 38, 38, .25);
}
/* «نظرسنجی دارد» ⇒ دکمه غیرفعال (قاعدهٔ محصولی) */
.ax-del.is-locked {
cursor: not-allowed; color: #9ca3af; background: #f9fafb;
border-color: #e5e7eb;
}
.ax-del.is-locked:hover { background: #f9fafb; border-color: #e5e7eb; color: #9ca3af; }
@media (max-width: 480px) {
.ax-del-text { display: none; } /* در موبایل فقط آیکون، برای صرفه‌جویی فضا */
.ax-del { padding: .25rem; }
}
/* ===== مودال «ایجاد/ویرایش وظیفه» — mobile first =====
همهٔ کلاس‌ها با پیشوند ax- هستند تا با سایر بخش‌ها تداخل نکنند. */
#ActionBox .ax-card {
@@ -260,6 +318,29 @@
#ActionBox .ax-warn.is-confirming .ax-warn-actions { display: flex; }
#ActionBox .ax-warn:focus { outline: 2px solid #f59e0b; outline-offset: 2px; }
/* اعلان «تاریخ قرار عقب‌تر» — کلاس مستقل تا به CSS پنل «انجام شد» وابسته نباشد */
#ActionBox .ax-holdback {
margin-top: .5rem; padding: .6rem .7rem;
border: 1px solid #fcd34d; border-radius: .5rem; background: #fffbeb;
color: #92400e; font-size: .78rem; line-height: 1.75;
}
#ActionBox .ax-holdback.is-blocking {
border-color: #f59e0b; box-shadow: 0 0 0 3px #fde68a66; background: #fef3c7;
}
#ActionBox .ax-holdback .ax-hb-title { font-weight: 800; color: #78350f; margin: 0 0 .15rem; }
#ActionBox .ax-holdback .ax-hb-current { margin: 0 0 .4rem; }
#ActionBox .ax-holdback .ax-hb-current b { font-weight: 800; }
#ActionBox .ax-holdback .ax-hb-alt { margin: 0; }
#ActionBox .ax-holdback .ax-hb-check {
display: flex; align-items: flex-start; gap: .5rem; margin: 0;
padding: .5rem .6rem; border-radius: .45rem; background: #fff;
border: 1px solid #fbbf24; cursor: pointer; font-weight: 700; color: #7c2d12;
}
#ActionBox .ax-holdback .ax-hb-check input {
width: 1.05rem; height: 1.05rem; margin-top: .15rem; flex: 0 0 auto;
}
#ActionBox .ax-holdback:focus { outline: 2px solid #f59e0b; outline-offset: 2px; }
#ActionBox .ax-help-toggle {
display: flex; align-items: center; gap: .45rem; width: 100%;
padding: .55rem .7rem; border: 1px solid #bae6fd; border-radius: .5rem;
@@ -294,12 +375,15 @@
</style>
{% if customer %}
<form id="ActionBox" class="modal" method="post" action="{% url 'admin_panel:customerNewAction' customer.id %}">
<form id="ActionBox" class="modal" method="post" action="{% url 'admin_panel:customerNewAction' customer.id %}"
data-customer-hold="{% if customer.visit_hold_until %}{{ customer.visit_hold_until|date:'c' }}{% endif %}">
{% else %}
<form id="ActionBox" class="modal" method="post" action="">
{% endif %}
{% csrf_token %}
{# تأیید صریح کاربر برای عقب‌بردن تاریخ قرار؛ فقط با زدن دکمهٔ تأیید پر می‌شود. #}
<input type="hidden" name="force_hold" id="forceHold" value="">
<div class="modal-background closeActionBox"></div>
<div class="modal-card ax-card">
<header class="modal-card-head ax-head">
@@ -369,6 +453,20 @@
</div>
</div>
{# اعلان «تاریخ عقب‌تر» — عرض کامل، بیرون از شبکهٔ دوستونه #}
<div class="ax-holdback is-hidden" id="holdBackwardNotice" tabindex="-1"
role="alert" aria-live="polite">
<p class="ax-hb-title">این تاریخ از قرار فعلی عقب‌تر است</p>
<p class="ax-hb-current">قرار فعلی: <b id="hbCurrent">—</b></p>
<label class="ax-hb-check is-hidden" id="hbForceRow" for="forceHoldCheck">
<input type="checkbox" id="forceHoldCheck">
<span>تاریخ قرار را عقب ببر</span>
</label>
<p class="ax-hb-alt is-hidden" id="hbCreateHint">
این تاریخ ذخیره نمی‌شود. برای تغییر قرار، وظیفهٔ هماهنگی فعلی را ویرایش کنید.
</p>
</div>
{# راهنمای بازشونده — فقط برای هماهنگی #}
<div class="ax-help-wrap showWhenCoordination is-hidden" style="margin-top: .6rem;">
<button type="button" class="ax-help-toggle" id="holdHelpToggle"
@@ -393,11 +491,13 @@
<h4>نکات مهم</h4>
<ul>
<li><b>خالی + تیک «انجام شد»</b> ← سیستم خودش ۱۴ روز می‌گذارد.</li>
<li><b>خالی + تیک</b> ← سیستم خودش ۱۴ روز می‌گذارد.</li>
<li><b>خالی + بدون تیک</b> ← هیچ تعویقی ثبت نمی‌شود؛ مشتری در فهرست می‌ماند.</li>
<li><b>فقط تمدید می‌کند، کوتاه نمی‌کند:</b> اگر قرار ۲۲ آذر باشد و همکار دیگری ۳۰ آذر بگذارد،
قرار به ۳۰ آذر می‌رود؛ اما ۱۵ آذر نادیده گرفته می‌شود. برای کوتاه‌کردن،
هماهنگی را حذف و دوباره ثبت کنید.</li>
قرار به ۳۰ آذر می‌رود؛ تاریخ‌های عقب‌تر نادیده گرفته می‌شوند تا قرارِ دورتر از بین نرود.
اگر خودتان می‌خواهید قرار را عقب ببرید، وظیفه را <b>ویرایش</b> کنید و
گزینهٔ <b>«تاریخ قرار را عقب ببر»</b> را تیک بزنید
(در «ایجاد وظیفهٔ تازه» این گزینه وجود ندارد).</li>
<li><b>حذف یا لغو هماهنگی</b> ← قرار بی‌اثر می‌شود و مشتری بلافاصله برمی‌گردد.</li>
<li><b>سقف ۶ ماه</b> است؛ بیشتر از آن با پیام خطا رد می‌شود.</li>
<li>این فیلد <b>تاریخ بازدید پرسنل نیست</b> و <b>بازدید حساب نمی‌شود</b>؛
@@ -473,6 +573,16 @@
var holdAutoChecked = false;
var holdConfirmed = false;
// لایهٔ دوم محافظت: فرم «حذف» داخل کارتِ قابل‌کلیک است؛ رویداد را همان‌جا
// متوقف می‌کنیم تا به هندلرِ بازکردن مودالِ ویرایش نرسد.
// بایند «مستقیم» (نه delegated) چون این بلوک با هر ردیف تکرار می‌شود؛
// نام‌فضا (axdel) + off مانع از انباشته‌شدن هندلرها روی ردیف‌های تکراری می‌شود.
$('.ax-del-form, .ax-del')
.off('.axdel')
.on('click.axdel mousedown.axdel', function (e) {
e.stopPropagation();
});
// پیکر «تا تاریخ پیگیری نشود» را ریست می‌کند.
// نکته: pDatepicker یک Model برمی‌گرداند؛ اگر سلکتور هیچ المانی پیدا نکند
// مقدار undefined می‌شود. بدون این محافظ، یک ناهم‌خوانی نام کلاس باعث
@@ -528,6 +638,7 @@
dateTimePickerAnjam.setDate();
dateTimePickerFinish.setDate();
resetHoldPicker();
syncHoldBackward();
$(".showWhenCoordination").addClass("is-hidden");
closeHoldHelp();
$("#sabtBtn05").val("ثبت");
@@ -581,8 +692,109 @@
$("#checkIsDone").trigger('focus');
});
// ══════════ عقب‌بردن تاریخ قرار ══════════
// قاعدهٔ سیستم: تاریخ «تا تاریخ پیگیری نشود» فقط رو به جلو می‌رود تا قرارِ
// دورتری که ثبت شده از بین نرود. در «ویرایش» اگر کاربر خودش بخواهد عقب ببرد،
// قرار فعلی را می‌بیند و با تیک زدن اجازهٔ صریح می‌دهد. در «ایجادِ» وظیفهٔ تازه
// این اجازه داده نمی‌شود؛ چون کاربر قصد اصلاح قرارِ موجود را ندارد و ممکن است
// ناخواسته قولِ ثبت‌شدهٔ قبلی را کوتاه کند.
function holdCurrentMs() {
var iso = ($("#ActionBox").attr('data-customer-hold') || '').trim();
if (!iso) { return null; }
var ms = new Date(iso).getTime();
return isNaN(ms) ? null : ms;
}
// مقدار پیکر در فیلد مخفی به‌صورت timestamp میلی‌ثانیه‌ای است؛ اگر پیکر
// تاریخ شمسی متنی گذاشت، همان را هم می‌پذیریم.
function holdTargetMs() {
var raw = ($("#visitHoldUntil").val() || '').trim();
if (!raw) { return null; }
if (/^\d+$/.test(raw)) {
var n = parseInt(raw, 10);
return isNaN(n) ? null : n;
}
var parsed = Date.parse(raw.replace(/\//g, '-'));
return isNaN(parsed) ? null : parsed;
}
function sameLocalDay(a, b) {
var da = new Date(a), db = new Date(b);
return da.getFullYear() === db.getFullYear()
&& da.getMonth() === db.getMonth()
&& da.getDate() === db.getDate();
}
function jalaliDay(ms) {
if (ms === null) { return '—'; }
try {
if (typeof persianDate !== 'undefined') {
return new persianDate(ms).format('YYYY/MM/DD');
}
} catch (err) { /* کتابخانه در دسترس نیست ⇒ تاریخ میلادی */ }
var d = new Date(ms);
return d.getFullYear() + '/' + (d.getMonth() + 1) + '/' + d.getDate();
}
// آیا تاریخ انتخابی از قرار فعلی عقب‌تر است؟ (مستقل از تیک کاربر)
function holdIsBackward() {
if ($("#actionType").val() !== 'hamahangi') { return false; }
var current = holdCurrentMs(), target = holdTargetMs();
if (current === null || target === null) { return false; }
if (sameLocalDay(current, target)) { return false; }
return target < current;
}
// به‌روزرسانی اعلان (و اجازهٔ عقب‌بردن فقط در ویرایش) — با هر تغییر تاریخ/نوع.
function syncHoldBackward() {
var notice = $("#holdBackwardNotice");
var needed = holdIsBackward();
var canForce = needed && isEditMode;
// تیک فقط وقتی معنا دارد که عقب‌بردن مجاز باشد؛ در غیر این صورت پاک می‌شود.
$("#hbForceRow").toggleClass("is-hidden", !canForce);
$("#hbCreateHint").toggleClass("is-hidden", !needed || canForce);
if (!canForce) {
$("#forceHoldCheck").prop('checked', false);
$("#forceHold").val('');
}
if (!needed) {
notice.addClass("is-hidden").removeClass("is-blocking");
return;
}
$("#hbCurrent").text(jalaliDay(holdCurrentMs()));
notice.removeClass("is-hidden");
}
// تیک «عقب ببر» ⇒ فیلد مخفی force_hold برای سرور.
$("#forceHoldCheck").on('change', function() {
var checked = $(this).prop('checked');
$("#forceHold").val(checked ? '1' : '');
$("#holdBackwardNotice").toggleClass("is-blocking", !checked);
});
// هر تغییر تاریخ/نوع اقدام ⇒ وضعیت دوباره محاسبه می‌شود.
$("#ActionBox").on('change input', "#visitHoldUntil, #actionType",
function() { syncHoldBackward(); });
// پیکر تاریخ هم بعد از انتخاب، فیلد مخفی را پر می‌کند.
$(document).on('change', ".dateTimePickerHoldUntil, .dateTimePickerHoldUntil-alt",
function() { syncHoldBackward(); });
// گرفتن تأیید پیش از ثبتِ هماهنگیِ «انجام‌شده» (فقط در حالت ایجاد، نه ویرایش).
$("#ActionBox").on('submit', function(event) {
// (۱) در ویرایش: تاریخ عقب‌تر از قرار فعلی ⇒ تا تیک تأیید نزده، ارسال نمی‌شود.
// در «ایجاد»، وظیفه ذخیره می‌شود و فقط تاریخ قرار عقب نمی‌رود؛ سرور
// همین را با پیام روشن به کاربر می‌گوید، پس ارسال بلاک نمی‌شود.
if (isEditMode && holdIsBackward() && !$("#forceHoldCheck").prop('checked')) {
event.preventDefault();
var notice = $("#holdBackwardNotice").removeClass("is-hidden").addClass("is-blocking");
if (notice[0].scrollIntoView) {
notice[0].scrollIntoView({ block: 'center', behavior: 'smooth' });
}
notice.trigger('focus');
return;
}
// (۲) هماهنگیِ «انجام‌شده»: تأیید یک‌بارهٔ انجام کار.
if (isEditMode || holdConfirmed) {
return; // ویرایش، یا تأیید قبلی ⇒ ارسال آزاد
}
@@ -596,7 +808,12 @@
showHoldConfirm();
});
$(".actionBoxOpenB").click(function(){
$(".actionBoxOpenB").click(function(event){
// کلیک روی کنترل‌های «حذف» نباید مودال ویرایش را باز کند.
// (دکمه داخل کارت است و رویداد به والد بالا می‌رفت.)
if ($(event.target).closest('.ax-del-form, .ax-del').length) {
return;
}
isEditMode = true;
$("#sabtBtn05").val("ویرایش");
var actionEditUrl = "{% url 'admin_panel:customerEditAction' 1111 2222 %}";
@@ -631,6 +848,7 @@
// مقدار ذخیره‌شدهٔ «قرار بعدی» را در همان فیلد نشان بده (بعد از toggle،
// چون toggle برای انواع غیرهماهنگی فیلد را پاک می‌کند).
seedHoldPicker(customerHold);
syncHoldBackward();
$("#desc").val(ActionParseData[0]['fields']['desc']);
$("#userID").val(ActionParseData[0]['fields']['forUser']);
if(ActionParseData[0]['fields']['status'] == 'finish'){
@@ -679,6 +897,11 @@
enabled: false,
}
},
// بعد از انتخاب تاریخ، وضعیت «عقب‌تر بودن» فوراً بازمحاسبه شود
// (پیکر مقدار altField را برنامه‌نویسی‌شده می‌نویسد و ممکن است change رخ ندهد).
onSelect: function () {
setTimeout(function () { syncHoldBackward(); }, 0);
},
});
// اگر پیکر ساخته نشد (مثلاً نام کلاس در قالب عوض شده باشد) هشدار بده؛
@@ -7,7 +7,7 @@
<div class="columns m-0">
<div class="column is-12 px-2 pt-3 pb-0">
{% for message in messages %}
<div class="notification {% if message.tags == 'error' %}is-danger{% elif message.tags == 'success' %}is-success{% else %}is-info{% endif %} is-light mb-2">
<div class="notification {% if message.tags == 'error' %}is-danger{% elif message.tags == 'warning' %}is-warning{% elif message.tags == 'success' %}is-success{% else %}is-info{% endif %} is-light mb-2">
<button class="delete close-notification" type="button"></button>
<strong>{{ message }}</strong>
</div>
@@ -180,9 +180,12 @@
</form>
</div>
{% include "./_customer_sms.html" %}
</div>
{% comment %}
باکس «پیامک» از این صفحه حذف شد: تنظیمات پیامک به تب «سوابق پیامک» مشتری
(/panel/customer/<id>/sms/) منتقل شده تا فرم فاکتور تمام‌عرض بماند و سوابق و
تنظیمات هر دو در یک صفحهٔ اختصاصی دیده شوند.
{% endcomment %}
{% endblock %}
@@ -4,30 +4,223 @@
{% block BodyCustomer1 %}
{% comment %}
صفحهٔ «پیامک مشتری» = سوابق + تنظیمات در یک صفحه.
قاعدهٔ طراحی: تنظیمات هرگز روی سوابق نمی‌نشیند؛ فقط آمار + دکمه‌ها بالاست و
تنظیمات/ارسال سفارشی/شماره‌های اصلی داخل مودال باز می‌شوند.
همهٔ کلاس‌ها با پیشوند csmsx و محدود به #customerSmsHistory هستند تا به
صفحات دیگر نشت نکنند.
{% endcomment %}
<style>
/* جدول سوابق پیامک مشتری */
.smshist-msg {
white-space: pre-wrap;
overflow-wrap: anywhere;
max-width: 34rem;
line-height: 1.9;
{% verbatim %}
#customerSmsHistory, #customerSmsHistory *, #customerSmsHistory *::before,
#customerSmsHistory *::after { box-sizing: border-box; }
/* متغیرها روی خودِ صفحه و روی ریشهٔ مودال‌ها تعریف می‌شوند؛ مودال‌ها در بلوک
جدا (BodyCustomer2) رندر می‌شوند و خارج از #customerSmsHistory هستند. */
#customerSmsHistory, .csmsx-modal { --cs-line: #e8ecf3; --cs-ink: #363636;
--cs-muted: #7a7a7a; --cs-brand: #3273dc; --cs-ok: #48c774; }
/* ---------- نوار بالای صفحه ---------- */
.csmsx-head { display: flex; flex-wrap: wrap; gap: 1rem; align-items: flex-start;
justify-content: space-between; padding: 1.1rem 1.2rem; margin-bottom: 1rem;
border: 1px solid var(--cs-line); border-radius: 14px;
background: linear-gradient(180deg, #fbfdff 0%, #f5f8fd 100%); }
.csmsx-head__main { min-width: 0; }
.csmsx-title { font-size: 1.15rem; font-weight: 700; color: var(--cs-ink);
margin: 0 0 .25rem; overflow-wrap: anywhere; }
.csmsx-sub { margin: 0; color: var(--cs-muted); font-size: .82rem; line-height: 1.8; }
.csmsx-sub b { color: var(--cs-ink); }
.csmsx-stats { display: flex; flex-wrap: wrap; gap: .45rem; margin-top: .7rem; }
.csmsx-stat { display: inline-flex; align-items: center; gap: .35rem;
font-size: .78rem; padding: .25rem .65rem; border-radius: 999px;
background: #fff; border: 1px solid var(--cs-line); color: #4a4a4a; white-space: nowrap; }
.csmsx-stat b { font-variant-numeric: tabular-nums; }
.csmsx-stat.is-sent { border-color: #c8ebd5; background: #f2fbf6; }
.csmsx-stat.is-pending { border-color: #ffeaa7; background: #fffdf3; }
.csmsx-stat.is-failed { border-color: #ffd0d9; background: #fff5f7; }
.csmsx-head__actions { display: flex; flex-wrap: wrap; gap: .5rem; }
{% endverbatim %}
</style>
<style>
{% verbatim %}
/* ---------- نوار فیلترها ---------- */
.csmsx-filters { display: flex; flex-wrap: wrap; gap: .6rem; align-items: flex-end;
padding: .85rem 1rem; margin-bottom: 1rem; background: #fff;
border: 1px solid var(--cs-line); border-radius: 12px; }
.csmsx-filters .control { margin: 0; }
.csmsx-filters label { display: block; font-size: .75rem; color: var(--cs-muted);
margin-bottom: .25rem; }
.csmsx-filters select { min-width: 10.5rem; }
.csmsx-filters__note { margin-inline-start: auto; font-size: .78rem;
color: var(--cs-muted); }
/* ---------- فهرست سوابق ---------- */
.csmsx-history { background: #fff; border: 1px solid var(--cs-line);
border-radius: 14px; overflow: hidden; }
.csmsx-history__scroll { overflow-x: auto; }
.csmsx-history table { margin: 0 !important; background: transparent; }
.csmsx-history thead th { position: sticky; top: 0; z-index: 1; background: #f7f9fc;
color: #4a4a4a; font-size: .78rem; font-weight: 700;
border-bottom: 1px solid var(--cs-line) !important; }
.csmsx-history tbody tr:hover { background: #fafcff; }
.csmsx-msg { white-space: pre-wrap; overflow-wrap: anywhere; max-width: 34rem;
line-height: 1.95; font-size: .84rem; }
/* پیامک‌های بلند (متن قالب‌ها چندخطی است) کل جدول را بی‌اندازه بلند می‌کردند؛
متن در ۴ خط خلاصه می‌شود و با «نمایش کامل» باز می‌شود. */
.csmsx-msg.is-clamped { display: -webkit-box; -webkit-line-clamp: 4;
-webkit-box-orient: vertical; overflow: hidden; }
.csmsx-expand { margin-top: .25rem; font-size: .72rem; padding: .1rem .45rem;
border-radius: 6px; border: 1px solid var(--cs-line); background: #fff;
color: var(--cs-brand); cursor: pointer; }
.csmsx-expand:hover { background: #f4f8ff; }
.csmsx-meta { font-size: .76rem; line-height: 1.7; color: var(--cs-muted); }
.csmsx-badge { white-space: nowrap; }
.csmsx-type-chip { display: inline-block; font-size: .75rem; padding: .1rem .5rem;
border-radius: 999px; background: #f1f5fb; color: #33507d; white-space: nowrap; }
{% endverbatim %}
</style>
<style>
{% verbatim %}
/* ---------- مودال‌ها ---------- */
.csmsx-modal .modal-content { max-width: 46rem; width: calc(100% - 1.5rem); }
.csmsx-modal .box { padding: 0; overflow: hidden; border-radius: 14px; }
.csmsx-modal__head { display: flex; align-items: flex-start; justify-content: space-between;
gap: .75rem; padding: 1rem 1.15rem; border-bottom: 1px solid var(--cs-line);
background: linear-gradient(180deg, #fbfdff 0%, #f4f8fd 100%); }
.csmsx-modal__title { margin: 0; font-size: 1.02rem; font-weight: 700; color: var(--cs-ink); }
.csmsx-modal__sub { margin: .2rem 0 0; font-size: .78rem; color: var(--cs-muted); line-height: 1.8; }
.csmsx-modal__body { padding: 1rem 1.15rem; max-height: min(68vh, 34rem); overflow-y: auto; }
.csmsx-modal__foot { display: flex; flex-wrap: wrap; gap: .5rem; justify-content: flex-end;
padding: .85rem 1.15rem; border-top: 1px solid var(--cs-line); background: #fcfcfd; }
.csmsx-section-title { display: flex; align-items: center; gap: .45rem; margin: 0 0 .6rem;
font-size: .82rem; font-weight: 700; color: #4a4a4a; }
.csmsx-section + .csmsx-section { margin-top: 1.15rem; padding-top: 1.15rem;
border-top: 1px dashed var(--cs-line); }
/* ردیف هر نوع پیامک داخل مودال تنظیمات */
.csmsx-type { display: grid; grid-template-columns: auto minmax(0, 1fr) auto;
gap: .6rem .75rem; align-items: center; padding: .6rem .7rem; border-radius: 10px; }
.csmsx-type + .csmsx-type { margin-top: .3rem; }
.csmsx-type:hover { background: #fafcff; }
.csmsx-type.is-off { background: #fcfcfc; }
.csmsx-type.is-off .csmsx-type__label { color: #9a9a9a; }
.csmsx-type__icon { font-size: 1.15rem; line-height: 1; }
.csmsx-type__main { min-width: 0; }
.csmsx-type__label { display: block; font-size: .85rem; font-weight: 600;
color: var(--cs-ink); overflow-wrap: anywhere; }
.csmsx-type__meta { display: flex; flex-wrap: wrap; gap: .3rem; margin-top: .2rem; }
.csmsx-tag { font-size: .7rem; padding: .1rem .45rem; border-radius: 999px;
border: 1px solid var(--cs-line); background: #fff; color: var(--cs-muted); white-space: nowrap; }
.csmsx-tag.is-auto { border-color: #cfe0ff; background: #f4f8ff; color: #2f5fb3; }
/* کلید فعال/غیرفعال (دکمهٔ submit داخل فرم) */
.csmsx-switch { display: inline-flex; align-items: center; gap: .45rem;
background: none; border: 0; padding: .25rem; cursor: pointer; min-height: 2.4rem; }
.csmsx-switch__track { position: relative; flex: 0 0 auto; width: 44px; height: 25px;
border-radius: 13px; background: #dbdbdb; transition: background .18s; }
.csmsx-switch__track::after { content: ""; position: absolute; top: 3px;
inset-inline-start: 3px; width: 19px; height: 19px; border-radius: 50%;
background: #fff; box-shadow: 0 1px 3px rgba(0,0,0,.3); transition: transform .18s; }
.csmsx-switch.is-on .csmsx-switch__track { background: var(--cs-ok); }
.csmsx-switch.is-on .csmsx-switch__track::after { transform: translateX(-19px); }
.csmsx-switch:focus-visible .csmsx-switch__track { outline: 2px solid var(--cs-brand); outline-offset: 2px; }
.csmsx-switch__text { font-size: .76rem; color: #4a4a4a; white-space: nowrap; min-width: 3.2rem; text-align: start; }
/* شماره‌های اصلی داخل مودال */
.csmsx-phones { display: flex; flex-wrap: wrap; gap: .5rem; }
.csmsx-phone { display: inline-flex; align-items: center; gap: .4rem; padding: .3rem .4rem .3rem .65rem;
border: 1px solid var(--cs-line); border-radius: 999px; background: #fff; }
.csmsx-phone__num { direction: ltr; font-size: .82rem; color: var(--cs-ink); }
.csmsx-phone .sms-primary-toggle { border-radius: 999px; font-size: .72rem; height: 1.9rem; padding-inline: .6rem; }
.csmsx-empty { font-size: .8rem; color: var(--cs-muted); margin: 0; }
/* ارسال سفارشی */
.csmsx-chips { display: flex; flex-wrap: wrap; gap: .35rem; margin-bottom: .5rem; }
.csmsx-chip { font-size: .72rem; padding: .2rem .55rem; border-radius: 999px;
border: 1px solid var(--cs-line); background: #fff; color: #4a4a4a; cursor: pointer;
direction: ltr; font-family: ui-monospace, Menlo, Consolas, monospace; }
.csmsx-chip:hover { border-color: var(--cs-brand); color: var(--cs-brand); }
.csmsx-counter { display: flex; justify-content: space-between; gap: .5rem;
font-size: .74rem; color: var(--cs-muted); margin-top: .4rem; }
/* ---------- واکنش‌گرا ---------- */
@media (max-width: 768px) {
.csmsx-head { padding: .9rem; }
.csmsx-head__actions { width: 100%; }
.csmsx-head__actions .button { flex: 1 1 auto; }
.csmsx-filters { padding: .75rem; }
.csmsx-filters select { min-width: 0; width: 100%; }
.csmsx-filters .control { flex: 1 1 45%; }
.csmsx-filters__note { margin-inline-start: 0; width: 100%; }
.csmsx-modal__body { max-height: 72vh; }
/* موبایل: جدول به کارت تبدیل می‌شود. اسکرول افقی روی موبایل، متن پیامک را
می‌بُرد و خواندن سوابق را عملاً غیرممکن می‌کرد؛ در این حالت هر پیامک یک
کارت کامل با برچسب هر خانه است و هیچ اسکرول افقی لازم نیست. */
.csmsx-history__scroll { overflow: visible; }
.csmsx-history table, .csmsx-history tbody, .csmsx-history tr,
.csmsx-history td { display: block; width: auto !important; }
.csmsx-history thead { display: none; }
.csmsx-history tbody tr { padding: .7rem .85rem; border-bottom: 1px solid var(--cs-line); }
.csmsx-history tbody tr:last-child { border-bottom: 0; }
.csmsx-history tbody td { display: flex; gap: .5rem; align-items: flex-start;
padding: .18rem 0 !important; border: 0 !important; }
.csmsx-history tbody td::before { content: attr(data-label); flex: 0 0 5.5rem;
font-size: .72rem; color: var(--cs-muted); line-height: 1.9; }
.csmsx-history tbody td[data-label=""]::before { content: none; }
/* ستون متن: برچسب بالای متن (نه کنارش) تا عرض خواندن کم نشود. */
.csmsx-history tbody td[data-label="متن"] { display: block; }
.csmsx-history tbody td[data-label="متن"]::before { display: block;
flex: none; margin-bottom: .15rem; }
.csmsx-history tbody td > * { min-width: 0; }
.csmsx-expand { white-space: nowrap; }
.csmsx-msg { max-width: none; }
.csmsx-col-id, .csmsx-col-sent, .csmsx-col-delivery-check { display: none !important; }
}
.smshist-meta { font-size: .78rem; line-height: 1.7; }
.smshist-badge { white-space: nowrap; }
{% endverbatim %}
</style>
<div id="customerSmsHistory" class="column is-12-mobile is-12-desktop" dir="rtl">
<h1 class="has-text-centered has-text-weight-bold mb-1">سوابق پیامک مشتری</h1>
<p class="has-text-centered has-text-grey smshist-meta mb-3">
همهٔ پیامک‌های ارسال‌شده برای شماره‌های این مشتری — جمعاً
<strong>{{ total_count }}</strong> پیامک.
</p>
<form method="get" class="field is-grouped is-grouped-multiline mb-3">
{# ---------- ۱) نوار بالای صفحه: آمار + دکمه‌های تنظیمات ---------- #}
<div class="csmsx-head">
<div class="csmsx-head__main">
<h1 class="csmsx-title">پیامک‌های {{ customer.name_family }}</h1>
<p class="csmsx-sub">
جمعاً <b>{{ total_count }}</b> پیامک در سابقه ·
<b>{{ sms_enabled_count }}</b> از <b>{{ sms_type_total }}</b> نوع پیامک برای این مشتری فعال است.
</p>
<div class="csmsx-stats">
<span class="csmsx-stat is-sent">✅ ارسال‌شده <b>{{ stat_sent }}</b></span>
<span class="csmsx-stat is-pending">⏳ در صف <b>{{ stat_pending }}</b></span>
<span class="csmsx-stat is-failed">⚠️ ناموفق <b>{{ stat_failed }}</b></span>
</div>
</div>
<div class="csmsx-head__actions">
{% if perms.admin_panel.ManageSMS %}
<button type="button" class="button is-link is-light js-csmsx-open" data-target="smsSettingsModal"
aria-haspopup="dialog">
⚙️ تنظیمات پیامک
</button>
<button type="button" class="button is-info is-light js-csmsx-open" data-target="smsCustomModal"
aria-haspopup="dialog">
✉️ ارسال پیامک
</button>
{% endif %}
{% if perms.admin_panel.ManageSMSSettings %}
<a class="button is-white" href="{% url 'admin_panel:sms_templates' %}"
title="ویرایش متن قالب‌های پیامک">🗂️ متن قالب‌ها</a>
{% endif %}
</div>
</div>
{# ---------- ۲) فیلترها ---------- #}
<form method="get" class="csmsx-filters">
<div class="control">
<label for="csmsx-status">وضعیت ارسال</label>
<div class="select">
<select name="status">
<option value="">همهٔ وضعیت‌های ارسال</option>
<select id="csmsx-status" name="status">
<option value="">همهٔ وضعیت‌ها</option>
{% for value, label in status_choices %}
<option value="{{ value }}" {% if status == value %}selected{% endif %}>{{ label }}</option>
{% endfor %}
@@ -35,88 +228,113 @@
</div>
</div>
<div class="control">
<label for="csmsx-delivery">وضعیت تحویل</label>
<div class="select">
<select name="delivery">
<option value="">همهٔ وضعیت‌های تحویل</option>
<select id="csmsx-delivery" name="delivery">
<option value="">همهٔ وضعیت‌ها</option>
{% for value, label in delivery_choices %}
<option value="{{ value }}" {% if delivery == value %}selected{% endif %}>{{ label }}</option>
{% endfor %}
</select>
</div>
</div>
<div class="control"><button class="button is-link" type="submit">فیلتر</button></div>
<div class="control">
<button class="button is-link" type="submit">اعمال فیلتر</button>
</div>
{% if status or delivery %}
<div class="control">
<a class="button is-light" href="{% url 'admin_panel:customerSmsHistory' customer.id %}">حذف فیلتر</a>
</div>
{% endif %}
<span class="csmsx-filters__note">
{% if status or delivery %}
نمایش <b>{{ filtered_count }}</b> از <b>{{ total_count }}</b> پیامک
{% else %}
نمایش <b>{{ total_count }}</b> پیامک، جدیدترین‌ها ابتدا
{% endif %}
</span>
</form>
{% if status or delivery %}
<p class="mb-2 smshist-meta">نتایج فیلترشده: <strong>{{ filtered_count }}</strong> مورد</p>
{% endif %}
<div class="table-container">
<table class="table is-fullwidth is-narrow is-hoverable is-striped is-bordered">
<thead>
<tr>
<th>شناسه</th>
<th style="min-width: 7rem;">نوع پیامک</th>
<th style="min-width: 8rem;">شماره گیرنده</th>
<th style="min-width: 18rem;">متن پیامک</th>
<th>وضعیت ارسال</th>
<th style="min-width: 8rem;">وضعیت تحویل</th>
<th>تاریخ ایجاد</th>
<th>تاریخ ارسال</th>
</tr>
</thead>
<tbody>
{% for item in items %}
<tr>
<td>{{ item.id }}</td>
<td>{{ item.get_sms_type_display|default:'—' }}</td>
<td dir="ltr">{{ item.phone_number }}</td>
<td class="smshist-msg">{{ item.message }}</td>
<td>
<span class="tag smshist-badge
{% if item.status == 'sent' %}is-success
{% elif item.status == 'failed' %}is-danger
{% elif item.status == 'pending' %}is-warning
{% elif item.status == 'approved' %}is-info
{% else %}is-light{% endif %}">
{{ item.get_status_display }}
</span>
{% if item.last_error %}
<div class="has-text-danger smshist-meta" title="{{ item.last_error }}">
خطا: {{ item.last_error|truncatechars:80 }}
</div>
{% endif %}
</td>
<td>
<span class="tag smshist-badge
{% if item.delivery_status == 'delivered' %}is-success
{% elif item.delivery_status == 'not_delivered' %}is-danger
{% elif item.delivery_status == 'expired' %}is-danger
{% elif item.delivery_status == 'pending' %}is-warning
{% else %}is-light{% endif %}">
{{ item.get_delivery_status_display|default:'بدون پیگیری' }}
</span>
{% if item.delivery_checked_at %}
<div class="has-text-grey smshist-meta">استعلام: {{ item.delivery_checked_at|toJalaliDT }}</div>
{% endif %}
</td>
<td class="smshist-meta">{{ item.created_at|toJalaliDT }}</td>
<td class="smshist-meta">{{ item.sent_at|toJalaliDT|default:'—' }}</td>
</tr>
{% empty %}
<tr><td colspan="8" class="has-text-centered">پیامکی برای این مشتری ثبت نشده است.</td></tr>
{% endfor %}
</tbody>
</table>
{# ---------- ۳) سوابق ---------- #}
<div class="csmsx-history">
<div class="csmsx-history__scroll">
<table class="table is-fullwidth is-narrow is-hoverable is-striped">
<thead>
<tr>
<th class="csmsx-col-id">شناسه</th>
<th style="min-width: 8.5rem;">نوع پیامک</th>
<th style="min-width: 8rem;">گیرنده</th>
<th style="min-width: 16rem;">متن پیامک</th>
<th>وضعیت ارسال</th>
<th style="min-width: 8rem;">وضعیت تحویل</th>
<th style="min-width: 7rem;">تاریخ ایجاد</th>
<th class="csmsx-col-sent" style="min-width: 7rem;">تاریخ ارسال</th>
</tr>
</thead>
<tbody>
{% for item in items %}
<tr>
<td class="csmsx-col-id csmsx-meta" data-label="شناسه">{{ item.id }}</td>
<td data-label="نوع">
{% if item.sms_type %}
<span class="csmsx-type-chip">{{ item.get_sms_type_display }}</span>
{% else %}
<span class="csmsx-type-chip">سفارشی</span>
{% endif %}
{% if item.attempts %}
<div class="csmsx-meta">تلاش: {{ item.attempts }}</div>
{% endif %}
</td>
<td class="csmsx-meta" dir="ltr" data-label="گیرنده">{{ item.phone_number }}</td>
<td data-label="متن">
<div class="csmsx-msg {% if item.message|length > 170 %}is-clamped{% endif %}">{{ item.message }}</div>
{% if item.message|length > 170 %}
<button type="button" class="csmsx-expand js-csmsx-expand" aria-expanded="false">نمایش کامل</button>
{% endif %}
</td>
<td data-label="ارسال">
<span class="tag csmsx-badge
{% if item.status == 'sent' %}is-success
{% elif item.status == 'failed' %}is-danger
{% elif item.status == 'pending' %}is-warning
{% elif item.status == 'approved' %}is-info
{% else %}is-light{% endif %}">
{{ item.get_status_display }}
</span>
{% if item.last_error %}
<div class="has-text-danger csmsx-meta" title="{{ item.last_error }}">
خطا: {{ item.last_error|truncatechars:80 }}
</div>
{% endif %}
</td>
<td data-label="تحویل">
<span class="tag csmsx-badge
{% if item.delivery_status == 'delivered' %}is-success
{% elif item.delivery_status == 'not_delivered' %}is-danger
{% elif item.delivery_status == 'expired' %}is-danger
{% elif item.delivery_status == 'pending' %}is-warning
{% else %}is-light{% endif %}">
{{ item.get_delivery_status_display|default:'بدون پیگیری' }}
</span>
{% if item.delivery_checked_at %}
<div class="csmsx-meta csmsx-col-delivery-check">استعلام: {{ item.delivery_checked_at|toJalaliDT }}</div>
{% endif %}
</td>
<td class="csmsx-meta" data-label="تاریخ">{{ item.created_at|toJalaliDT }}</td>
<td class="csmsx-meta csmsx-col-sent" data-label="ارسال‌شده">{{ item.sent_at|toJalaliDT|default:'—' }}</td>
</tr>
{% empty %}
<tr>
<td colspan="8" class="has-text-centered py-5" data-label="">پیامکی برای این مشتری ثبت نشده است.</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
{% if items.has_other_pages %}
<nav class="pagination is-centered" role="navigation" aria-label="pagination">
<nav class="pagination is-centered mt-4" role="navigation" aria-label="pagination">
{% if items.has_previous %}
<a class="pagination-previous" href="?page={{ items.previous_page_number }}{% if status %}&status={{ status }}{% endif %}{% if delivery %}&delivery={{ delivery }}{% endif %}">قبلی</a>
{% else %}
@@ -135,3 +353,235 @@
</div>
{% endblock %}
{% block BodyCustomer2 %}
{% if perms.admin_panel.ManageSMS %}
{# ---------- مودال ۱: تنظیمات پیامک (نوع‌ها + شماره‌های اصلی) ---------- #}
<div class="modal csmsx-modal" id="smsSettingsModal" role="dialog" aria-modal="true"
aria-labelledby="smsSettingsTitle">
<div class="modal-background js-csmsx-close"></div>
<div class="modal-content">
<div class="box" dir="rtl">
<div class="csmsx-modal__head">
<div>
<h2 class="csmsx-modal__title" id="smsSettingsTitle">⚙️ تنظیمات پیامک این مشتری</h2>
<p class="csmsx-modal__sub">
هر تغییر بلافاصله ذخیره می‌شود. خاموش‌کردن یک نوع، فقط برای همین مشتری است؛
متن پیامک‌ها از <b>قالب‌های پیامک</b> می‌آید.
</p>
</div>
<button type="button" class="delete js-csmsx-close" aria-label="بستن"></button>
</div>
<div class="csmsx-modal__body">
{% if notice_text and request.GET.open == 'settings' %}
<div class="notification {{ notice_kind }} is-light py-2 px-3 mb-3">
<strong>{{ notice_text }}</strong>
</div>
{% endif %}
{# --- بخش الف: شماره‌های اصلی پیامک --- #}
<section class="csmsx-section">
<h3 class="csmsx-section-title">📱 شماره‌های اصلی پیامک</h3>
{% if phone_entries %}
<div class="csmsx-phones">
{% for entry in phone_entries %}
{% if entry.is_mobile %}
<span class="csmsx-phone">
<span class="csmsx-phone__num">{{ entry.display }}</span>
<button type="button"
class="button sms-primary-toggle {% if entry.is_primary %}is-warning{% else %}is-light{% endif %}"
data-url="{% url 'admin_panel:sms_primary_toggle' customer.id %}"
data-phone="{{ entry.raw }}"
data-primary="{% if entry.is_primary %}true{% else %}false{% endif %}"
title="{% if entry.is_primary %}برداشتن از شماره‌های اصلی پیامک{% else %}تعیین به‌عنوان شمارهٔ اصلی پیامک{% endif %}">
{% if entry.is_primary %}★ اصلی{% else %}☆ اصلی{% endif %}
</button>
</span>
{% endif %}
{% endfor %}
</div>
<p class="csmsx-empty mt-2">
پیامک‌ها به شماره‌های «★ اصلی» می‌روند؛ اگر هیچ شماره‌ای اصلی نباشد، شمارهٔ اول مشتری.
</p>
{% else %}
<p class="csmsx-empty">شماره‌ای برای این مشتری ثبت نشده است.</p>
{% endif %}
</section>
{# --- بخش ب: فعال/غیرفعال کردن هر نوع پیامک --- #}
<section class="csmsx-section">
<h3 class="csmsx-section-title">
🔔 انواع پیامک
<span class="csmsx-tag">{{ sms_enabled_count }} از {{ sms_type_total }} فعال</span>
</h3>
{% for item in sms_toggles %}
<form method="post" action="{% url 'admin_panel:sms_customer_toggle' customer.id %}"
class="csmsx-type {% if not item.enabled %}is-off{% endif %}">
{% csrf_token %}
<input type="hidden" name="sms_type" value="{{ item.code }}">
<input type="hidden" name="is_enabled" value="{% if item.enabled %}false{% else %}true{% endif %}">
<span class="csmsx-type__icon" aria-hidden="true">{{ item.icon }}</span>
<div class="csmsx-type__main">
<span class="csmsx-type__label">{{ item.label }}</span>
<span class="csmsx-type__meta">
{% if item.when %}<span class="csmsx-tag">{{ item.when }}</span>{% endif %}
<span class="csmsx-tag {% if item.auto_send %}is-auto{% endif %}">
{% if item.auto_send %}ارسال خودکار{% else %}نیازمند تأیید مدیر{% endif %}
</span>
</span>
</div>
<button type="submit" class="csmsx-switch {% if item.enabled %}is-on{% endif %}"
role="switch" aria-checked="{% if item.enabled %}true{% else %}false{% endif %}"
title="{% if item.enabled %}خاموش کردن{% else %}روشن کردن{% endif %} این نوع پیامک">
<span class="csmsx-switch__track" aria-hidden="true"></span>
<span class="csmsx-switch__text">{% if item.enabled %}فعال{% else %}غیرفعال{% endif %}</span>
</button>
</form>
{% endfor %}
</section>
</div>
<div class="csmsx-modal__foot">
<button type="button" class="button is-light js-csmsx-close">بستن</button>
</div>
</div>
</div>
</div>
{# ---------- مودال ۲: ارسال پیامک سفارشی ---------- #}
<div class="modal csmsx-modal" id="smsCustomModal" role="dialog" aria-modal="true"
aria-labelledby="smsCustomTitle">
<div class="modal-background js-csmsx-close"></div>
<div class="modal-content">
<form class="box" dir="rtl" method="post"
action="{% url 'admin_panel:sms_send_custom' customer.id %}">
{% csrf_token %}
<div class="csmsx-modal__head">
<div>
<h2 class="csmsx-modal__title" id="smsCustomTitle">✉️ ارسال پیامک سفارشی</h2>
<p class="csmsx-modal__sub">
به شماره‌های «★ اصلی» این مشتری ارسال می‌شود و بی‌درنگ در صف قرار می‌گیرد.
</p>
</div>
<button type="button" class="delete js-csmsx-close" aria-label="بستن"></button>
</div>
<div class="csmsx-modal__body">
{% if notice_text and request.GET.open == 'custom' %}
<div class="notification {{ notice_kind }} is-light py-2 px-3 mb-3">
<strong>{{ notice_text }}</strong>
</div>
{% endif %}
<div class="csmsx-chips">
<button type="button" class="csmsx-chip js-csmsx-token" data-token="{name}">{name}</button>
<button type="button" class="csmsx-chip js-csmsx-token" data-token="{balance_sep}">{balance_sep}</button>
</div>
<textarea id="csmsxCustomText" class="textarea" name="text" rows="5"
placeholder="متن پیامک…" required
data-counter="#csmsxCustomCounter"></textarea>
<div class="csmsx-counter">
<span>{name} با نام مشتری، {balance_sep} با مانده حساب جایگزین می‌شود.</span>
<span id="csmsxCustomCounter">۰ نویسه</span>
</div>
</div>
<div class="csmsx-modal__foot">
<button type="button" class="button is-light js-csmsx-close">انصراف</button>
<button type="submit" class="button is-info">ارسال پیامک</button>
</div>
</form>
</div>
</div>
<script>
// باز/بسته کردن مودال‌های این صفحه + درج متغیر در متن + شمارندهٔ نویسه.
// بدون کتابخانهٔ جدید؛ کلید Esc و کلیک روی پس‌زمینه هم مودال را می‌بندد.
(function () {
if (window.__csmsxModalInit) { return; }
window.__csmsxModalInit = true;
function openModal(id) {
var modal = document.getElementById(id);
if (!modal) { return; }
modal.classList.add('is-active');
var field = modal.querySelector('textarea');
if (field) { field.focus(); }
}
function closeModal(modal) {
if (modal) { modal.classList.remove('is-active'); }
}
document.addEventListener('click', function (event) {
if (!event.target.closest) { return; }
var opener = event.target.closest('.js-csmsx-open');
if (opener) {
event.preventDefault();
openModal(opener.getAttribute('data-target'));
return;
}
var closer = event.target.closest('.js-csmsx-close');
if (closer) {
event.preventDefault();
closeModal(closer.closest('.modal'));
return;
}
var expand = event.target.closest('.js-csmsx-expand');
if (expand) {
event.preventDefault();
var box = expand.parentNode.querySelector('.csmsx-msg');
if (box) {
var opened = box.classList.toggle('is-clamped') === false;
expand.setAttribute('aria-expanded', opened ? 'true' : 'false');
expand.textContent = opened ? 'خلاصه' : 'نمایش کامل';
}
return;
}
var chip = event.target.closest('.js-csmsx-token');
if (chip) {
event.preventDefault();
var field = document.getElementById('csmsxCustomText');
if (!field) { return; }
var token = chip.getAttribute('data-token') || '';
var start = field.selectionStart == null ? field.value.length : field.selectionStart;
var end = field.selectionEnd == null ? field.value.length : field.selectionEnd;
field.value = field.value.slice(0, start) + token + field.value.slice(end);
field.selectionStart = field.selectionEnd = start + token.length;
field.dispatchEvent(new Event('input', { bubbles: true }));
field.focus();
}
});
document.addEventListener('keydown', function (event) {
if (event.key !== 'Escape' && event.key !== 'Esc') { return; }
var active = document.querySelector('#smsSettingsModal.is-active, #smsCustomModal.is-active');
closeModal(active);
});
document.addEventListener('input', function (event) {
var field = event.target;
if (!field || field.id !== 'csmsxCustomText') { return; }
var target = document.querySelector(field.getAttribute('data-counter'));
if (!target) { return; }
// ارقام فارسی، هم‌خوان با بقیهٔ متن صفحه.
var count = String(field.value.length)
.replace(/[0-9]/g, function (d) { return '۰۱۲۳۴۵۶۷۸۹'.charAt(Number(d)); });
target.textContent = count + ' نویسه';
});
// باز شدن خودکار مودال بر اساس ?open= (بعد از ذخیرهٔ تنظیمات یا خطای ارسال).
var wanted = new URLSearchParams(window.location.search).get('open');
if (wanted === 'settings') { openModal('smsSettingsModal'); }
if (wanted === 'custom') { openModal('smsCustomModal'); }
})();
</script>
{% endif %}
{% endblock %}
+1
View File
@@ -37,6 +37,7 @@ urlpatterns = [
path('customer/<int:c_id>/nazar/', views.customerNazar, name='customerNazar'),
path('customer/<int:c_id>/action/new', views.customerNewAction, name='customerNewAction'),
path('customer/<int:c_id>/action/<int:action_id>/edit', views.customerEditAction, name='customerEditAction'),
path('customer/<int:c_id>/action/<int:action_id>/delete', views.customerActionDelete, name='customerActionDelete'),
path('customer/<int:c_id>/del', views.customerDel, name='customerDel'),
path('customer/<int:c_id>/factors/', views.customerFactors, name='customerFactors'),
path('customer/<int:c_id>/factor/<int:f_id>', views.customerFactor, name='customerFactor'),
+216 -36
View File
@@ -8,13 +8,16 @@ 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 persiantools.jdatetime import JalaliDate
from ..models import Action, Customer
from ..sms.triggers import notify_visit_scheduled
from ..sms.triggers import notify_install_scheduled, notify_visit_scheduled
from ..visit_policy import (REASON_CANCEL, REASON_CREATE, REASON_EDIT,
as_aware, clear_visit_hold, default_hold_until,
normalize_finish_datetime, normalize_hold_until,
refresh_last_bazdid, set_visit_hold,
ActionDeleteError, as_aware,
cancel_pending_sms_for_action, clear_visit_hold,
default_hold_until, normalize_finish_datetime,
normalize_hold_until, refresh_last_bazdid,
set_visit_hold, soft_delete_action,
validate_action_payload)
@@ -27,8 +30,12 @@ def customerAction(request, c_id):
filter_type = request.GET.get('actionType', 'all')
filter_status = request.GET.get('status', 'all')
# کوئری پایه برای اقدامات این مشتری
# کوئری پایه برای اقدامات این مشتری.
# اقدام‌های حذف‌شده (soft-delete) به‌طور پیش‌فرض نمایش داده نمی‌شوند؛ با
# فیلتر صریح `status=cancell` می‌توان آن‌ها را دید (برای حسابرسی).
actions = Action.objects.filter(customer=customer)
if filter_status != 'cancell':
actions = actions.filter(deleted_at__isnull=True)
# اعمال فیلتر بر اساس نوع اقدام (بازدید، نصب و ...)
if filter_type != 'all':
@@ -57,7 +64,9 @@ def customerAction(request, c_id):
@login_required(login_url='/panel/p_login/')
def customerNazar(request ,c_id):
customer = Customer.objects.get(id=c_id)
actions = Action.objects.filter(customer=customer, nazarDT__isnull=False).order_by("-createdDT")
actions = Action.objects.filter(
customer=customer, nazarDT__isnull=False, deleted_at__isnull=True,
).order_by("-createdDT")
allUsers = User.objects.filter().exclude(username='admin').exclude(username='fesahat')
data = {'customer': customer, 'actions': actions, 'allUsers': allUsers}
return render(request, 'admin_panel/customer/nazar.html', data)
@@ -84,6 +93,8 @@ def _action_payload_from_request(request):
'finishDateTime': _parse_form_datetime(request.POST.get('FinishDateTime')),
# فقط برای «هماهنگی» معنا دارد؛ تاریخِ «تا کِی پیگیری نشود».
'holdRaw': _parse_form_datetime(request.POST.get('visitHoldUntil')),
# تأیید صریح کاربر برای «عقب بردن» تاریخ قرار دستی.
'force_hold': bool(request.POST.get('force_hold')),
'desc': request.POST.get('desc', None),
'result': request.POST.get('result', None),
'userID': request.POST.get('userID', None),
@@ -91,13 +102,13 @@ def _action_payload_from_request(request):
}
def _apply_visit_hold(customer_obj, payload, *, status, is_new):
def _apply_visit_hold(customer_obj, payload, *, status, is_new, force_hold=False):
"""هم‌راستاکردن «قرار بعدی» با اقدام ثبت/ویرایش‌شده.
قواعد:
* برای اقدام نوع «هماهنگی»:
- تاریخ قرار داده شده ⇒ ``set_visit_hold`` (فقط رو به جلو)
- تاریخ قرار داده شده ⇒ ``set_visit_hold`` (فقط رو به جلو، مگر تأیید کاربر)
- «انجام شد» تیک خورده و تاریخ خالی ⇒ پیش‌فرض ۱۴ روز
- لغو شده ⇒ (در ایجاد) پاک می‌شود
* برای **هر اقدام دیگری** (از جمله «بازدید»):
@@ -107,18 +118,116 @@ def _apply_visit_hold(customer_obj, payload, *, status, is_new):
هماهنگیِ *جدیدتر* آمده باشد؛ پس دست نمی‌زنیم. اعتبارسنجی
:func:`manual_holds_map` (شرط «آخرین فعالیت = هماهنگی») خودش تصمیم
می‌گیرد. بدون این تفکیک، ویرایش یک اقدام قدیمی قرار معتبر را پاک می‌کرد.
خروجی (دیکشنری، برای بازخورد شفاف به کاربر)::
{'ok': bool, 'reason': 'written'|'default'|'forward_only'|'cleared'|'',
'current': datetime|None, 'target': datetime|None}
``reason='forward_only'`` یعنی تاریخ درخواستی از قرار فعلی عقب‌تر است و
نوشته **نشد**؛ لایهٔ ویو باید همین را با تاریخ‌ها به کاربر نشان بدهد و اگر
خودش تأیید کرد، با ``force_hold=True`` دوباره صدا بزند.
"""
result = {'ok': True, 'reason': '', 'current': None, 'target': None,
'force_allowed': not is_new}
# «عقب‌بردن قرار» فقط در ویرایش مجاز است. در ایجادِ وظیفهٔ تازه، کاربر قصد
# اصلاح قرارِ موجود را ندارد و اجازه دادن به آن می‌تواند قولِ قبلیِ ثبت‌شده
# (شاید توسط کارشناس دیگر) را بی‌آنکه کسی بخواهد کوتاه کند.
force_hold = bool(force_hold) and not is_new
is_coordination = payload['actionType'] == Action.ActionType.HAMAHANGI
if not is_coordination or status == Action.Status.CANCELL:
return clear_visit_hold(customer_obj) if is_new else True
if is_new:
result['ok'] = bool(clear_visit_hold(customer_obj))
result['reason'] = 'cleared' if result['ok'] else ''
return result
raw = payload.get('holdRaw')
if raw is not None:
set_visit_hold(customer_obj, normalize_hold_until(raw))
return True
if payload['status'] == Action.Status.FINISH:
set_visit_hold(customer_obj, default_hold_until())
return True
target = normalize_hold_until(raw)
reason = 'written'
elif payload['status'] == Action.Status.FINISH:
target = default_hold_until()
reason = 'default'
else:
return result
current = as_aware(customer_obj.visit_hold_until)
result['current'] = current
result['target'] = target
written = set_visit_hold(customer_obj, target, force=force_hold)
result['ok'] = written
result['reason'] = reason if written else 'forward_only'
return result
# نگاشت «نوع اقدام → (تریگر پیامک، نوع پیامک)». تنها منبعِ تصمیم است تا مسیر
# «ایجاد» و «ویرایش» هرگز واگرا نشوند (باگ قبلی: ویرایش تریگر را صدا نمی‌زد).
_ACTION_EVENT_SMS = {
Action.ActionType.BAZDID: (notify_visit_scheduled, 'visit_created'),
Action.ActionType.NASB: (notify_install_scheduled, 'install_created'),
}
def _sync_action_event_sms(customer, action, *, previous_type=None, user=None):
"""هم‌راستاسازی پیامک رویدادی با نوع اقدام (مشترکِ ایجاد و ویرایش).
* نوع جدید **رویدادی** است (بازدید/نصب):
- اگر نوع عوض شده باشد (یا رکورد تازه باشد) ⇒ تریگر همان نوع صدا زده
می‌شود. تکرار ممکن نیست: کلید یکتاسازی ``{sms_type}:{action.pk}`` است.
- اگر نوع همان بوده باشد (ویرایش عادی متن/تاریخ) ⇒ هیچ پیامکی ساخته
نمی‌شود، پس ویرایش‌های پیاپی اسپم تولید نمی‌کنند.
* نوع جدید **غیررویدادی** است ولی قبلاً رویدادی بود ⇒ پیامک معلقِ همان رکورد
لغو می‌شود (کاربر عملاً بازدید/نصب را پس گرفته است).
خروجی: تعداد پیامک‌های ساخته‌شده.
"""
current = _ACTION_EVENT_SMS.get(action.actionType)
previous = _ACTION_EVENT_SMS.get(previous_type) if previous_type else None
# نوع رویدادی عوض شده (بازدید ↔ نصب) ⇒ پیامک معلقِ نوع قبلی همین رکورد لغو شود،
# وگرنه هر دو پیامک در صف می‌مانند.
if previous is not None and previous_type != action.actionType:
cancel_pending_sms_for_action(
customer, action_id=action.pk, sms_type=previous[1],
)
if current is None:
# نوع جدید رویدادی نیست؛ اگر قبلاً بود، پیامکش همین بالا لغو شد.
return 0
if previous_type == action.actionType:
return 0 # ویرایش عادی همان نوع ⇒ پیامک تازه‌ای لازم نیست
return current[0](customer, action, created_by=user)
def _jalali_day(value):
"""تاریخ شمسی روز (به وقت محلی) برای پیام‌های کاربر."""
if value is None:
return '—'
return JalaliDate(timezone.localtime(value)).strftime('%Y/%m/%d')
def _report_hold_result(request, result):
"""بازخورد شفافِ نتیجهٔ نوشتن «تا تاریخ پیگیری نشود» به کاربر.
قاعدهٔ «فقط رو به جلو» بی‌صدا نیست: اگر تاریخ درخواستی از قرار فعلی عقب‌تر
باشد، اینجا گفته می‌شود که ذخیره نشد و کاربر چطور می‌تواند خودش انجامش دهد.
"""
if result.get('reason') != 'forward_only':
return
current, target = result.get('current'), result.get('target')
tz = timezone.get_current_timezone()
if current is not None and target is not None \
and target.astimezone(tz).date() >= current.astimezone(tz).date():
# همان روز ⇒ تغییر واقعی‌ای لازم نیست؛ پیام دادن گمراه‌کننده است.
return
tail = ('برای ثبت، گزینهٔ «تاریخ قرار را عقب ببر» را تیک بزنید.'
if result.get('force_allowed')
else 'برای تغییر قرار، همان وظیفهٔ هماهنگی را ویرایش کنید.')
messages.warning(
request,
'تاریخ قرار ذخیره نشد؛ از قرار فعلی این مشتری (%s) عقب‌تر است. %s'
% (_jalali_day(current), tail)
)
def _reject(request, error, redirect_to):
@@ -157,11 +266,14 @@ def customerNewAction(request ,c_id):
# last_bazdid_dt فقط از تابع واحد بازمحاسبه می‌شود؛ نه از finishDateTime.
refresh_last_bazdid(customer_obj, reason=REASON_CREATE, user=request.user)
# «قرار بعدی» فقط برای هماهنگی.
_apply_visit_hold(customer_obj, payload, status=newAction.status, is_new=True)
hold_result = _apply_visit_hold(
customer_obj, payload, status=newAction.status, is_new=True,
force_hold=payload.get('force_hold', False),
)
_report_hold_result(request, hold_result)
# پیامک «بازدید شما برای تاریخ … ثبت شد» فقط برای اقدام از نوع بازدید.
if newAction.actionType == Action.ActionType.BAZDID:
notify_visit_scheduled(customer_obj, newAction, created_by=request.user)
# پیامک «بازدید/نصب شما برای تاریخ … ثبت شد» (برای اقدام از نوع بازدید یا نصب).
_sync_action_event_sms(customer_obj, newAction, user=request.user)
return redirect('/panel/customer/'+str(c_id)+'/action')
@@ -197,6 +309,9 @@ def customerEditAction(request ,c_id, action_id):
customer_obj = Customer.objects.get(id=int(c_id))
newAction = Action.objects.get(id=action_id)
was_done = newAction.status == Action.Status.FINISH
# نوع **قبلی** رکورد (پیش از بازنویسی) — برای تشخیص گذار «غیربازدید → بازدید»
# و برعکس. اگر بعد از ``save()`` خوانده شود، همیشه مقدار جدید است.
previous_type = newAction.actionType
newAction.actionType = payload['actionType']
newAction.actionDateTime = payload['actionDateTime']
@@ -218,27 +333,95 @@ def customerEditAction(request ,c_id, action_id):
# «قرار بعدی»: با ویرایش هم‌راستا می‌شود. برای اقدام غیرهماهنگی عمداً
# پاک نمی‌کنیم؛ رکورد ویرایش‌شده ممکن است قدیمی‌تر از یک هماهنگیِ جدیدتر
# باشد و پاک‌کردن، قرار معتبر را از بین ببرد.
_apply_visit_hold(customer_obj, payload, status=newAction.status, is_new=False)
hold_result = _apply_visit_hold(
customer_obj, payload, status=newAction.status, is_new=False,
force_hold=payload.get('force_hold', False),
)
_report_hold_result(request, hold_result)
# پیامک «بازدید/نصب شما برای تاریخ … ثبت شد».
# فقط *گذار* نوع اقدام بررسی می‌شود، نه هر ویرایش (جزئیات در
# ``_sync_action_event_sms``):
# * غیررویدادی → بازدید/نصب ⇒ تریگر همان نوع صدا زده می‌شود.
# * بازدید/نصب → نوع دیگر ⇒ پیامک معلقِ همان رکورد لغو می‌شود.
_sync_action_event_sms(
customer_obj, newAction, previous_type=previous_type, user=request.user,
)
#return redirect('/panel/customer/'+str(c_id)+'/action')
return redirect(request.META.get('HTTP_REFERER', 'redirect_if_referer_not_found'))
@login_required(login_url='/panel/p_login/')
def customerActionDelete(request, c_id, action_id):
"""«حذف» یک اقدام (soft-delete).
فقط POST مجاز است (تغییر وضعیت داده). اقدام فیزیکی حذف **نمی‌شود**؛
``status`` به ``cancell`` می‌رود و ``deleted_at``/``deleted_by`` پر می‌شوند.
چرا soft-delete؟ چون سابقهٔ CRM باید قابل حسابرسی بماند و منطق محاسبات
(entezar / ActionList / داشبورد) از قبل فقط ``waite``/``finish`` را می‌بیند؛
پس همین مقدار، اقدام را از همهٔ خروجی‌ها کنار می‌گذارد.
"""
customer_obj = get_object_or_404(Customer, id=c_id)
action = get_object_or_404(Action, id=action_id, customer=customer_obj)
back_url = '/panel/customer/%d/action' % customer_obj.pk
if request.method != 'POST':
# دسترسی مستقیم با GET نباید چیزی را عوض کند.
return redirect(back_url)
try:
cancelled = soft_delete_action(action, user=request.user)
except ActionDeleteError as error:
messages.error(request, str(error))
return redirect(back_url)
label = action.get_actionType_display() or 'اقدام'
message = '«%s» حذف شد و از فهرست‌های انتظار/وظایف خارج شد.' % label
if cancelled:
message += ' %d پیامک ارسال‌نشدهٔ مرتبط هم لغو شد.' % cancelled
messages.success(request, message)
return redirect(back_url)
@login_required(login_url='/panel/p_login/')
def ActionListAll(request):
actions = Action.objects.filter().order_by("-id")
# اقدام‌های حذف‌شده (soft-delete) در فهرست «همه» نمایش داده نمی‌شوند.
actions = Action.objects.filter(deleted_at__isnull=True).order_by("-id")
allUsers = User.objects.filter().exclude(username='admin').exclude(username='fesahat')
data = {'actions': actions, 'allUsers': allUsers}
return render(request, 'admin_panel/ActionList.html', data)
def _local_day_range(day=None):
"""بازهٔ ``[آغاز روز، پایان روز]`` به وقت محلی و aware.
قبلاً این دو ویو رشته‌های naive می‌ساختند (``'%Y-%m-%d 00:00:00'``) و Django
آن‌ها را با منطقهٔ زمانی پیش‌فرض تفسیر می‌کرد؛ هر تغییر ساعت/تایم‌زون باعث
جابه‌جایی مرز «وظایف امروز» می‌شد. حالا مرز صریح و aware است.
در روزهای تغییر ساعت (مثلاً ۲۲ مارس در تهران) نیمه‌شب ممکن است وجود نداشته
باشد؛ آن حالت با ``is_dst=False`` پوشش داده می‌شود تا یک تاریخ مرزی، صفحهٔ
وظایف را نشکند (همان کاری که ``visit_policy.as_aware`` می‌کند).
"""
day = day or timezone.localdate()
bounds = []
for moment in (datetime.datetime.combine(day, datetime.time.min),
datetime.datetime.combine(day, datetime.time.max)):
try:
bounds.append(timezone.make_aware(moment))
except Exception:
bounds.append(timezone.make_aware(moment, is_dst=False))
return bounds[0], bounds[1]
@login_required(login_url='/panel/p_login/')
def ActionListToday(request):
startTodayDT= datetime.datetime.today().strftime('%Y-%m-%d 00:00:00')
endTodayDT= datetime.datetime.today().strftime('%Y-%m-%d 23:59:59')
#startTodayDT = datetime.datetime.strptime(startTodayDT, '%Y-%m-%d-00:00:00')
#endTodayDT = datetime.datetime.strptime(endTodayDT, '%Y-%m-%d-23:59:59')
#return HttpResponse(todayDT)
actions = Action.objects.filter(actionDateTime__gte=startTodayDT,actionDateTime__lte=endTodayDT, forUser__id=request.user.id, status='waite').order_by("-actionDateTime")[:50]
startTodayDT, endTodayDT = _local_day_range()
actions = Action.objects.filter(
actionDateTime__range=(startTodayDT, endTodayDT),
forUser__id=request.user.id, status='waite',
).order_by("-actionDateTime")[:50]
allUsers = User.objects.filter().exclude(username='admin').exclude(username='fesahat')
data = {'actions': actions, 'allUsers': allUsers}
return render(request, 'admin_panel/ActionList.html', data)
@@ -249,22 +432,19 @@ def ActionListUserToday(request, u_id):
status = request.GET.get('status', None)
unixADT = dateTimeAction
if dateTimeAction:
dateTimeAction = datetime.datetime.fromtimestamp(int(dateTimeAction)/1e3)
startTodayDT = dateTimeAction.strftime('%Y-%m-%d 00:00:00')
endTodayDT= dateTimeAction.strftime('%Y-%m-%d 23:59:59')
# timestamp میلی‌ثانیه‌ای GET → تاریخ محلی (نه naive UTC).
moment = as_aware(datetime.datetime.fromtimestamp(int(dateTimeAction) / 1e3))
day = moment.astimezone(timezone.get_current_timezone()).date()
else:
startTodayDT= datetime.datetime.today().strftime('%Y-%m-%d 00:00:00')
endTodayDT= datetime.datetime.today().strftime('%Y-%m-%d 23:59:59')
#startTodayDT = datetime.datetime.strptime(startTodayDT, '%Y-%m-%d-00:00:00')
#endTodayDT = datetime.datetime.strptime(endTodayDT, '%Y-%m-%d-23:59:59')
#return HttpResponse(todayDT)
day = timezone.localdate()
startTodayDT, endTodayDT = _local_day_range(day)
if status == 'waite' or status == None or status == '' or status == ' ':
status = 'waite'
actions = Action.objects.filter(actionDateTime__gte=startTodayDT,actionDateTime__lte=endTodayDT, forUser__id=u_id, status=status).order_by("-actionDateTime")[:50]
actions = Action.objects.filter(actionDateTime__range=(startTodayDT, endTodayDT), forUser__id=u_id, status=status).order_by("-actionDateTime")[:50]
actionPr = Action.objects.filter(actionDateTime__lt=startTodayDT, forUser__id=u_id, status=status).order_by("-actionDateTime")[:200]
elif status == 'finish':
actionPr = None
actions = Action.objects.filter(actionDateTime__gte=startTodayDT,actionDateTime__lte=endTodayDT, forUser__id=u_id, status=status).order_by("-actionDateTime")[:50]
actions = Action.objects.filter(actionDateTime__range=(startTodayDT, endTodayDT), forUser__id=u_id, status=status).order_by("-actionDateTime")[:50]
allUsers = User.objects.filter().exclude(username='admin').exclude(username='fesahat')
data = {'actions': actions, 'allUsers': allUsers, 'u_id': u_id, "unixADT": unixADT, "status": status, 'actionPr': actionPr}
+16 -10
View File
@@ -17,7 +17,6 @@ from ..sms.balance import customer_balance
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 (SOURCE_COORDINATION, SOURCE_MANUAL, SOURCE_SCHEDULED,
appointment_sources_map, as_aware,
followups_on_date, last_visit_map, overdue_followups,
@@ -164,11 +163,9 @@ def customerNew2(request ,c_id):
#all_prd = serializers.serialize("json", all_prda)
#return JsonResponse(all_prd,safe = False)
dateNow = datetime.datetime.now().date
sms_toggles = customer_toggle_map(customer)
data = {
'customer': customer, 'categorys': categorys, 'dateNow': dateNow,
'factorha': factorha, 'mandehesabCs': mandehesabCs,
'sms_toggles': sms_toggles,
}
return render(request, 'admin_panel/customerNew2.html', data)
@@ -494,8 +491,8 @@ def entezar(request):
# ---------- نمای «نیازمند پیگیری» (یک تاریخ مشخص) ----------
# این نما **مستقل** از «عقب‌افتاده» است: مشتری سالمی که با او قرار تماس
# گذاشته شده هم باید دیده شود. قاعدهٔ حذف «بازدید» در خود
# ``followups_on_date`` اعمال می‌شود (مشتریِ دارای قرار بازدید نمی‌آید).
# گذاشته شده هم باید دیده شود. قاعدهٔ حذفِ «بازدید/نصب» در خود
# ``followups_on_date`` اعمال می‌شود (مشتریِ دارای قرار بازدید یا نصب نمی‌آید).
today_followups = followups_on_date(today_date, now=now)
# پیگیری‌های عقب‌افتاده: موعدشان گذشته و هنوز تعیین تکلیف نشده‌اند.
@@ -628,7 +625,8 @@ def entezar(request):
entry = sources.get(cid) or {}
if not entry:
return 0
if len(entry) > 1:
days = {value.astimezone(current_tz).date() for value in entry.values()}
if len(days) > 1:
return 4
if SOURCE_MANUAL in entry:
return 1
@@ -699,8 +697,10 @@ def entezar(request):
try:
entezar_bazdid = CacheTable.objects.get(key='9mahbazdid')
# کارت داشبورد = «کارِ فوری» = تعیین تکلیف نشده.
entezar_bazdid.value = counts['uncoordinated']
entezar_bazdid.save()
# فقط در صورت تغییر می‌نویسیم تا یک GET ساده بی‌دلیل دیتابیس را تغییر ندهد.
if str(entezar_bazdid.value) != str(counts['uncoordinated']):
entezar_bazdid.value = str(counts['uncoordinated'])
entezar_bazdid.save(update_fields=['value'])
except CacheTable.DoesNotExist:
pass
@@ -812,14 +812,20 @@ def entezar(request):
entry.update(followup_entry)
flags = [key for key in (SOURCE_MANUAL, SOURCE_SCHEDULED,
SOURCE_COORDINATION) if key in entry]
# «N قرار» = تعداد **روزهای یکتای** قرار، نه تعداد منابع. یک هماهنگی
# که تاریخ تعویقش همان روز است، هم‌زمان «manual» و «coordination»
# تولید می‌کند ولی دو قرارِ متفاوت نیست (برچسب «۲ قرار» گمراه‌کننده بود).
appointment_days = {
entry[key].astimezone(current_tz).date() for key in flags
}
appointment_rows[customer.id] = {
'manual': entry.get(SOURCE_MANUAL),
'scheduled': entry.get(SOURCE_SCHEDULED),
'coordination': entry.get(SOURCE_COORDINATION),
'overdue_days': overdue_days.get(customer.id, 0),
'last_visit': visits.get(customer.id),
# تعداد منابع (۰ تا ۳) — قالب بر اساس آن برچسب وضعیت را می‌سازد.
'source_count': len(flags),
# تعداد قرارهای یکتا (۱ تا ۳ روز متمایز) — قالب بر اساس آن برچسب می‌سازد.
'source_count': len(appointment_days),
# تاریخ پیگیری این نمای خاص (برای ستون اختصاصی نمای «امروز»).
'followup_at': (min(followup_entry.values())
if followup_entry else None),
+7 -4
View File
@@ -156,9 +156,10 @@ def report_debtors_no_vosol(request):
).values('customer').annotate(c=Count('id')).values('c')
# استخراج آخرین سابقه اکشن وصول
# نکته: اقدام حذف‌شده/لغوشده نباید «آخرین اقدام وصول» به‌حساب بیاید.
latest_vosol_qs = Action.objects.filter(
customer=OuterRef('pk'), actionType='vosol'
).order_by('-createdDT')
customer=OuterRef('pk'), actionType='vosol',
).exclude(status='cancell').order_by('-createdDT')
# --- ۳. ساخت کوئری پایه (فقط مشتریان فعال و دارای معوقه) ---
base_qs = Customer.objects.filter(is_active=True).annotate(
@@ -939,7 +940,8 @@ def rezayat_dashboard(request):
# دریافت تمامی نظرسنجی‌های ثبت شده معتبر (که تاریخ نظرسنجی دارند)
valid_surveys = Action.objects.filter(
nazarDT__isnull=False,
grade__in=[5, 10, 15, 20]
grade__in=[5, 10, 15, 20],
deleted_at__isnull=True,
)
# ۱. محاسبات شاخص‌های کلیدی (KPIs)
@@ -965,7 +967,8 @@ def rezayat_dashboard(request):
# ۴. واکشی لیست مشتریان نیازمند پیگیری (شامل متوسط ۱۵، ضعیف ۱۰ و بد ۵)
low_score_actions = Action.objects.filter(
nazarDT__isnull=False,
grade__in=[5, 10, 15] # <-- امتیاز ۱۵ (متوسط) نیز اضافه شد
grade__in=[5, 10, 15], # <-- امتیاز ۱۵ (متوسط) نیز اضافه شد
deleted_at__isnull=True,
).order_by('-nazarDT')
allUsers = User.objects.filter().exclude(username='admin').exclude(username='fesahat')
+82 -14
View File
@@ -17,6 +17,7 @@ from ..sms.parts import ENCODING_LABELS, describe, parts_count
from ..sms.phones import (customer_phone_entries, save_primary_normalized,
stored_primary_normalized)
from ..sms.render import format_jalali, render_template
from ..sms.rules import customer_toggle_map
from ..sms.timing import apply_timing, current_timing_value, timing_for
_manage_required = permission_required("admin_panel.ManageSMS", raise_exception=True)
@@ -40,7 +41,9 @@ SENT_STATUSES = [
# نیازمند اقدام را نشان می‌دهد؛ پیامکِ ارسال‌شده از صف بیرون می‌رود و سابقه‌اش در
# صفحهٔ «گزارش ارسال» (/panel/sms/report) دیده می‌شود. این کار جلوی حذفِ ناخواستهٔ
# تاریخچه از روی صف را می‌گیرد و صف را به فهرستِ کاریِ تمیز تبدیل می‌کند.
DONE_STATUSES = [SMSQueue.Status.SENT]
# پیامک «لغوشده» هم دیگر کاری برای انجام ندارد (مثلاً چون اقدامِ مرتبطش حذف شده)،
# پس از صف بیرون می‌رود.
DONE_STATUSES = [SMSQueue.Status.SENT, SMSQueue.Status.CANCELLED]
def _notice(url, code, **extra):
@@ -56,6 +59,16 @@ def _notice(url, code, **extra):
def _customer_url(c_id):
return reverse("admin_panel:customer", kwargs={"c_id": c_id})
def _customer_sms_url(c_id):
"""صفحهٔ «سوابق و تنظیمات پیامک» مشتری — مقصد بازگشتِ عملیات پیامکی.
تنظیمات پیامک از صفحهٔ فاکتور به این صفحه منتقل شده؛ پس بعد از هر تغییر
(تیک نوع پیامک، شمارهٔ اصلی، ارسال سفارشی) کاربر باید همین‌جا بماند، نه به
صفحهٔ اصلی مشتری پرتاب شود.
"""
return reverse("admin_panel:customerSmsHistory", kwargs={"c_id": c_id})
# متغیرهای قابل استفاده در متن قالب‌ها (برای راهنمای پنل).
TEMPLATE_VARIABLES = [
("{name}", "نام مشتری"),
@@ -77,6 +90,7 @@ SMS_TYPE_UI = {
SMSType.CHEQUE_REGISTRATION: {"icon": "🧾", "when": "چند روز پیش از سررسید چک"},
SMSType.VISIT: {"icon": "🔧", "when": "پس از گذشت دورهٔ بازدید دستگاه"},
SMSType.VISIT_CREATED: {"icon": "📅", "when": "پس از ثبت اقدام بازدید"},
SMSType.INSTALL_CREATED: {"icon": "🛠️", "when": "پس از ثبت اقدام نصب"},
SMSType.PAYMENT_THANKS: {"icon": "🙏", "when": "پس از ثبت پرداخت"},
SMSType.NESIYE_DUE: {"icon": "💳", "when": "پیش از سررسید نسیه"},
}
@@ -311,8 +325,12 @@ def sms_customer_toggle(request, c_id):
)
setting.is_enabled = enabled
setting.save()
return _notice(_customer_url(c_id), "customer_sms_saved")
return redirect("admin_panel:customer", c_id=c_id)
# ``open=settings`` ⇒ صفحه با همان مودال تنظیمات باز می‌شود تا کاربر
# چند نوع را پشت‌سرهم روشن/خاموش کند و هر بار مودال را باز نکند.
return _notice(
_customer_sms_url(c_id), "customer_sms_saved", open="settings",
)
return redirect("admin_panel:customerSmsHistory", c_id=c_id)
@login_required(login_url='/panel/p_login/')
@@ -334,13 +352,13 @@ def sms_primary_toggle(request, c_id):
"",
)
if not normalized:
return _notice(_customer_url(c_id), "primary_not_found")
return _notice(_customer_sms_url(c_id), "primary_not_found")
current = [n for n in stored_primary_normalized(customer) if n != normalized]
if enabled:
current.append(normalized)
save_primary_normalized(customer, current)
return _notice(_customer_url(c_id), "primary_updated")
return redirect("admin_panel:customer", c_id=c_id)
return _notice(_customer_sms_url(c_id), "primary_updated")
return redirect("admin_panel:customerSmsHistory", c_id=c_id)
@login_required(login_url='/panel/p_login/')
@@ -356,15 +374,16 @@ def sms_send_custom(request, c_id):
text = (request.POST.get("text", "") or "").strip()
rendered = render_template(text, {"name": customer.name_family}).strip()
if not rendered:
return _notice(_customer_url(c_id), "custom_empty")
# خطا ⇒ همان مودال ارسال سفارشی دوباره باز شود تا متن از دست نرود.
return _notice(_customer_sms_url(c_id), "custom_empty", open="custom")
created = enqueue_sms(
customer=customer, sms_type="", message=rendered,
created_by=request.user,
approved=True, # متن را خودِ مدیر نوشته و ارسال را خواسته است.
)
code = "custom_queued" if created else "custom_no_phone"
return _notice(_customer_url(c_id), code)
return redirect("admin_panel:customer", c_id=c_id)
return _notice(_customer_sms_url(c_id), code)
return redirect("admin_panel:customerSmsHistory", c_id=c_id)
@login_required(login_url='/panel/p_login/')
@@ -426,11 +445,17 @@ def sms_queue(request):
@login_required(login_url='/panel/p_login/')
@_manage_required
def customer_sms_history(request, c_id):
"""سوابق پیامک‌های یک مشتری روی صفحهٔ خود مشتری.
"""سوابق + تنظیمات پیامک یک مشتری (همه در یک صفحه).
همهٔ پیامک‌هایی که برای شماره‌های این مشتری در صف قرار گرفته/ارسال شده‌اند
(هر وضعیتی) با متن دقیق، تاریخ شمسی و وضعیت تحویل (دلیوری) نمایش داده می‌شوند و
فهرست صفحه‌بندی می‌شود. با ``?status=`` و ``?delivery=`` می‌توان فیلتر کرد.
ساختار صفحه عمداً دو لایه است تا **تنظیمات، سوابق را شلوغ نکند**:
* لایهٔ اول (همیشه دیده می‌شود): آمار کوتاه، فیلترها و فهرست سوابق.
* لایهٔ دوم (فقط با کلیک): تنظیمات پیامک، شماره‌های اصلی و ارسال سفارشی،
همه داخل مودال‌های Bulma.
تنظیماتی که قبلاً داخل باکس «پیامک» صفحهٔ ایجاد فاکتور بود
(``_customer_sms.html``) حالا اینجاست؛ آن باکس حذف شد تا فاکتور و سوابق هر
دو تمام‌عرض بمانند.
"""
customer = get_object_or_404(Customer, id=c_id)
status = request.GET.get("status", "")
@@ -444,6 +469,34 @@ def customer_sms_history(request, c_id):
qs = qs.filter(delivery_status=delivery)
paginator = Paginator(qs, 20)
page = paginator.get_page(request.GET.get("page"))
# --- آمار کارت‌های بالای صفحه (کل تاریخچهٔ همین مشتری، بدون فیلتر) ---
all_items = SMSQueue.objects.filter(customer=customer)
status_counts = dict(
all_items.order_by().values_list("status").annotate(n=Count("id"))
)
total_count = all_items.count()
# --- تنظیمات: نوع‌های پیامک با وضعیت مؤثر همین مشتری + «ارسال خودکار» قالب ---
# ``auto_send`` تنظیمی سراسری است (صفحهٔ قالب‌ها)، ولی دانستنش اینجا کمک
# می‌کند کاربر بداند پیامکِ این نوع بی‌درنگ می‌رود یا منتظر تأیید می‌ماند.
auto_send = dict(
SMSTemplate.objects.values_list("sms_type", "auto_send")
)
type_labels = dict(SMSType.choices)
sms_toggles = [
{
"code": item["code"],
"label": item["label"],
"enabled": item["enabled"],
"auto_send": bool(auto_send.get(item["code"])),
"icon": SMS_TYPE_UI.get(item["code"], {}).get("icon", "✉️"),
"when": SMS_TYPE_UI.get(item["code"], {}).get("when", ""),
}
for item in customer_toggle_map(customer)
]
enabled_count = sum(1 for item in sms_toggles if item["enabled"])
data = {
"customer": customer,
"items": page,
@@ -451,8 +504,23 @@ def customer_sms_history(request, c_id):
"delivery": delivery,
"status_choices": SMSQueue.Status.choices,
"delivery_choices": SMSQueue.DeliveryStatus.choices,
"total_count": SMSQueue.objects.filter(customer=customer).count(),
"total_count": total_count,
"filtered_count": paginator.count,
# تنظیمات (مودال)
"sms_toggles": sms_toggles,
"sms_enabled_count": enabled_count,
"sms_type_total": len(type_labels),
"phone_entries": customer_phone_entries(customer),
# آمار (نوار بالای صفحه)
"status_counts": status_counts,
"stat_sent": status_counts.get(SMSQueue.Status.SENT, 0),
# «در صف» = هر چیزی که هنوز ارسال نشده و منتظر اقدام/worker است.
"stat_pending": (
status_counts.get(SMSQueue.Status.PENDING, 0)
+ status_counts.get(SMSQueue.Status.APPROVED, 0)
+ status_counts.get(SMSQueue.Status.PROCESSING, 0)
),
"stat_failed": status_counts.get(SMSQueue.Status.FAILED, 0),
}
return render(request, "admin_panel/customerSmsHistory.html", data)
+252 -46
View File
@@ -337,6 +337,14 @@ SOURCE_MANUAL = "manual"
SOURCE_SCHEDULED = "scheduled"
SOURCE_COORDINATION = "coordination"
# اقدام‌هایی که **واقعاً** یک «قرار بعدی» را جایگزین می‌کنند.
# چرا فقط این‌ها؟ قرار دستی یعنی «تا این تاریخ تماس نگیر». تنها چیزی که این
# قرار را منتفی می‌کند، خودِ بازدیدی است که به‌جای تماس انجام شده (یا نصب که
# چرخه را از نو شروع می‌کند). یک سند وصول، یک تماس ورودی یا خروجی، یا هر
# اقدام دیگر، قرار را باطل نمی‌کند؛ در غیر این‌صورت قرارِ ثبت‌شده بی‌صدا می‌مرد
# و مشتری دوباره در فهرست تماس ظاهر می‌شد.
_SUPERSEDING_TYPES = (Action.ActionType.BAZDID, Action.ActionType.NASB)
def _future_actions(action_type, statuses, *, now_utc, whole_day=False):
"""اقدام‌های آیندهٔ یک نوع با تاریخ مؤثر (تاریخ اقدام، وگرنه تاریخ اتمام).
@@ -409,10 +417,13 @@ def _valid_manual_holds(holds):
قرارهایی که معتبرند:
۱. یک اقدام «هماهنگی» فعال (``waite``/``finish``) برای مشتری وجود دارد،
۲. و **آخرین فعالیتِ مشتری همان هماهنگی است** — اگر پس از هماهنگی، اقدام
دیگری (به‌ویژه «بازدید») ثبت شده باشد، قرار باطل است.
۲. و **آخرین فعالیتِ جایگزین‌کنندهٔ قرار** همان هماهنگی است — یعنی پس از
هماهنگی، بازدید (``bazdid``) یا نصب (``nasb``) ثبت نشده باشد.
بند ۲ همان چیزی است که «ثبت بازدید پس از هماهنگی» را خودکار بی‌اثر می‌کند.
ولی فقط اقدام‌های جایگزین‌کننده (``_SUPERSEDING_TYPES``) قرار را باطل
می‌کنند: سند وصول، تماس ورودی/خروجی و بقیهٔ اقدام‌ها قرار را نمی‌کشند —
وگرنه یک پرداخت ساده، قرارِ توافق‌شده را بی‌صدا از بین می‌برد.
این تابع جدا شده تا هم :func:`manual_holds_map` (قرارهای آینده) و هم
:func:`followups_on_date` (قرارهای یک روز مشخص، حتی گذشته) از **یک قاعدهٔ
@@ -426,36 +437,56 @@ def _valid_manual_holds(holds):
# تاریخ آینده). سناریوی رایج «همین حالا تماس گرفتم و قرار دو هفتهٔ بعد
# گذاشتم» تاریخ اقدام را «امروز» می‌ذارد؛ اگر شرط آینده‌بودن تاریخ اقدام را
# بگذاریم، همین مورد (هدف اصلی این قابلیت) بی‌اثر می‌شد. پس فقط وضعیت مهم است.
coordination_latest = dict(
Action.objects.filter(
customer_id__in=customer_ids,
actionType=Action.ActionType.HAMAHANGI,
status__in=(Action.Status.WAITE, Action.Status.FINISH),
# «چه کسی جدیدتر است» بر اساس (createdDT، id) سنجیده می‌شود، نه فقط createdDT.
# چرا: createdDT در SQLite/ویندوز دقت کافی ندارد و دو اقدام که در یک لحظه ثبت
# شوند مقدار یکسان می‌گیرند؛ با مقایسهٔ صرفاً `>` آن‌گاه «بازدید بعد از
# هماهنگی» تشخیص داده نمی‌شد و قرارِ مرده معتبر می‌ماند. id یکتای افزایشی
# همان ترتیب ثبت را می‌دهد، پس به‌عنوان مرزِ تساوی استفاده می‌شود.
coordination_latest = {
cid: (last, last_id)
for cid, last, last_id in (
Action.objects.filter(
customer_id__in=customer_ids,
actionType=Action.ActionType.HAMAHANGI,
status__in=(Action.Status.WAITE, Action.Status.FINISH),
)
.values("customer_id")
.annotate(last=Max("createdDT"), last_id=Max("id"))
.values_list("customer_id", "last", "last_id")
)
.values("customer_id")
.annotate(last=Max("createdDT"))
.values_list("customer_id", "last")
)
# آخرین فعالیتِ غیرهماهنگی (بازدید، نصب، تماس، …) برای هر مشتری.
other_latest = dict(
Action.objects.filter(customer_id__in=customer_ids)
.exclude(actionType=Action.ActionType.HAMAHANGI)
.values("customer_id")
.annotate(last=Max("createdDT"))
.values_list("customer_id", "last")
)
}
# آخرین فعالیتِ **جایگزین‌کنندهٔ قرار** (بازدید/نصب) برای هر مشتری.
# عمداً فقط این دو نوع: یک سند وصول یا تماس ورودی نباید قرار را باطل کند.
other_latest = {
cid: (last, last_id)
for cid, last, last_id in (
Action.objects.filter(
customer_id__in=customer_ids, actionType__in=_SUPERSEDING_TYPES,
)
.values("customer_id")
.annotate(last=Max("createdDT"), last_id=Max("id"))
.values_list("customer_id", "last", "last_id")
)
}
valid = {}
for customer_id, until in holds.items():
if until is None:
continue
coordinated_at = coordination_latest.get(customer_id)
if coordinated_at is None:
continue
activity_at = other_latest.get(customer_id)
if activity_at is not None and as_aware(activity_at) > as_aware(coordinated_at):
# پس از هماهنگی، اقدام دیگری ثبت شده ⇒ قرار منتفی است.
coordination = coordination_latest.get(customer_id)
if coordination is None:
continue
coordinated_at, coordinated_id = coordination
activity = other_latest.get(customer_id)
if activity is not None:
activity_at, activity_id = activity
newer = as_aware(activity_at) > as_aware(coordinated_at) or (
as_aware(activity_at) == as_aware(coordinated_at)
and activity_id > coordinated_id
)
if newer:
# پس از هماهنگی، اقدام دیگری ثبت شده ⇒ قرار منتفی است.
continue
valid[customer_id] = until
return valid
@@ -496,19 +527,25 @@ def _local_day_bounds(target_date):
)
def _pending_visit_customer_ids(*, since):
"""مشتریانی که **قرار بازدید** دارند و نباید در فهرست پیگیری بیایند.
def _pending_appointment_ids(*, since):
"""مشتریانی که **قرار بازدید/نصب** دارند و نباید در فهرست پیگیری بیایند.
قاعدهٔ محصولی: «اگر برای مشتری بازدید گذاشته شده، دیگر لازم نیست تماس
بگیریم». پس هر مشتری با اقدام ``bazdid + waite`` که تاریخش از ``since``
(آغاز روز مورد نظر) عقب‌تر نباشد، از فهرست پیگیری **حذف** می‌شود.
قاعدهٔ محصولی: «اگر برای مشتری بازدید یا نصب گذاشته شده، دیگر لازم نیست
تماس بگیریم». پس هر مشتری با اقدام ``bazdid``/``nasb`` و وضعیت ``waite`` که
تاریخش از ``since`` (آغاز روز مورد نظر) عقب‌تر نباشد، از فهرست پیگیری
**حذف** می‌شود.
چرا «نصب» هم؟ همان دو نوع، چرخهٔ بازدید را از نو شروع می‌کنند
(``_CYCLE_RESET_TYPES``) و قرار دستی را باطل می‌کنند؛ پس فهرست کار روزانه
هم باید همین قاعده را داشته باشد. قبلاً فقط ``bazdid`` دیده می‌شد و
«نصبِ برنامه‌ریزی‌شده» ردیف را باز می‌گذاشت.
چرا ``>= since`` و نه «هر بازدیدی»؟ بازدیدِ در انتظارِ بسیار قدیمی
(backlog بسته‌نشده) قرار فعال نیست؛ آن مشتری هنوز نیاز به تماس دارد.
"""
return set(
Action.objects.filter(
actionType=Action.ActionType.BAZDID,
actionType__in=_CYCLE_RESET_TYPES,
status=Action.Status.WAITE,
)
.filter(
@@ -534,7 +571,8 @@ def followups_on_date(target_date, *, now=None):
امروز یا آینده — را می‌بیند تا کارشناس بتواند با تقویم عقب/جلو برود.
خروجی: ``{customer_id: {منبع: تاریخ}}`` مرتب بر اساس زودترین ساعت موعد.
مشتریانی که قرار **بازدید** دارند در خروجی نیستند.
مشتریانی که قرار **بازدید/نصب** دارند، یا بازدید/نصبشان از همان روز موعد به
بعد **انجام شده**، در خروجی نیستند (فهرست، «کارِ باقی‌مانده» است نه آرشیو).
"""
day_start, day_end = _local_day_bounds(target_date)
@@ -579,11 +617,35 @@ def followups_on_date(target_date, *, now=None):
if current is None or when < current:
bucket[SOURCE_COORDINATION] = when
# ۳) حذف مشتریانی که برایشان «بازدید» گذاشته شده است.
# ۳) حذف مشتریانی که برایشان «بازدید» یا «نصب» گذاشته شده است.
if result:
for customer_id in _pending_visit_customer_ids(since=day_start):
for customer_id in _pending_appointment_ids(since=day_start):
result.pop(customer_id, None)
# ۴) حذف مشتریانی که بازدید/نصبشان از همان روزِ موعد به بعد **انجام شده** است.
# قاعدهٔ کسب‌وکار: اگر بازدید (یا نصب) انجام شده، تماسِ آن روز لازم نیست. قبلاً
# فقط بازدیدِ «در انتظار» دیده می‌شد، پس مشتریِ بازدیدشده باز هم در فهرست
# «نیازمند پیگیری امروز» می‌ماند (۹۲٪ ردیف‌های دادهٔ واقعی).
# لنگر از :func:`cycle_anchor_map` می‌آید — یعنی همان دو نوعی که چرخه را از نو
# شروع می‌کنند (``bazdid``/``nasb``). برای ``bazdid`` خروجی عیناً همان مقدار
# قبلی (``last_visit_map``) است، پس رفتار موجود تغییر نمی‌کند و «نصب» هم ردیف
# را می‌بندد. عمداً ``last_visit_map`` گشاد نشد: آن «تاریخ آخرین بازدید» است
# (ستون جدول، ``last_bazdid_dt`` و پیامک) و نباید نصب را بازدید حساب کند.
# فقط برای امروز/آینده اعمال می‌شود تا مرور روزهای گذشته (تقویم) سندِ کاریِ
# همان روز را با داده‌های بعدی بازنویسی نکند.
if result:
now = _now(now)
current_tz = timezone.get_current_timezone()
if target_date >= now.astimezone(current_tz).date():
anchors = cycle_anchor_map(now=now, customer_ids=list(result))
for customer_id, done_at in anchors.items():
bucket = result.get(customer_id)
if not bucket or done_at is None:
continue
due_day = min(bucket.values()).astimezone(current_tz).date()
if done_at.astimezone(current_tz).date() >= due_day:
result.pop(customer_id, None)
return result
@@ -598,8 +660,12 @@ def overdue_followups(*, now=None, target_date=None):
* اگر مشتری هماهنگیِ **امروز یا آینده** داشته باشد، عقب‌افتاده نیست
(موعدش تازه است).
* اگر برایش **قرار بازدید** ثبت شده باشد، از فهرست خارج می‌شود — همان قاعدهٔ
:func:`followups_on_date` («بازدید گذاشته شده ⇒ تماس لازم نیست»).
* اگر برایش **قرار بازدید/نصب** ثبت شده باشد، از فهرست خارج می‌شود — همان
قاعدهٔ :func:`followups_on_date` («بازدید/نصب گذاشته شده ⇒ تماس لازم نیست»).
* اگر بازدید یا نصبش **از همان موعد به بعد انجام شده** باشد، از فهرست خارج
می‌شود (پیگیری با بازدید/نصب بسته شده است).
* اگر **قرار دستیِ معتبر** داشته باشد («تا تاریخ پیگیری نشود»)، از فهرست
خارج می‌شود؛ چون خودِ کاربر صریحاً خواسته تا آن تاریخ تماس نگیریم.
* هماهنگیِ لغوشده (``cancell``) اصلاً دیده نمی‌شود.
خروجی: ``{customer_id: تاریخِ موعدِ ازدست‌رفته}`` — تا هم شمارش کنیم و هم
@@ -632,15 +698,35 @@ def overdue_followups(*, now=None, target_date=None):
if not overdue:
return {}
# حذف مشتریانی که برایشان بازدید گذاشته شده است (قرار بازدید از آن روز).
# «از آن روز» = زودترین موعدِ ازدست‌رفته؛ بازدیدی که بعد از آن گذاشته شده
# تکلیف مشتری را روشن کرده است.
pending_visits = _pending_visit_customer_ids(
# حذف مشتریانی که برایشان بازدید/نصب گذاشته شده است (قرار از آن روز).
# «از آن روز» = زودترین موعدِ ازدست‌رفته؛ بازدید/نصبی که بعد از آن گذاشته
# شده تکلیف مشتری را روشن کرده است.
pending_visits = _pending_appointment_ids(
since=_local_day_bounds(min(overdue.values()))[0]
)
for customer_id in pending_visits:
overdue.pop(customer_id, None)
current_tz = timezone.get_current_timezone()
# بازدید/نصبِ **انجام‌شده** از همان موعد به بعد ⇒ این پیگیری انجام شده است.
# قبلاً فقط بازدیدِ «در انتظار» دیده می‌شد و همین، فهرست «از روزهای قبل»
# را پر از مشتریانی می‌کرد که بازدیدشان ثبت شده بود (۸۸٪ دادهٔ واقعی).
# لنگر همان ``cycle_anchor_map`` است (``bazdid``/``nasb``) تا «نصب» هم مثل
# بازدید پیگیری را ببندد.
anchors = cycle_anchor_map(now=now, customer_ids=list(overdue))
for customer_id, done_at in anchors.items():
if done_at is None:
continue
if done_at.astimezone(current_tz).date() >= overdue[customer_id]:
overdue.pop(customer_id, None)
# «قرار دستیِ معتبر» یعنی کاربر صریحاً گفته «تا این تاریخ پیگیری نشود»؛
# چنین مشتری‌ای نمی‌تواند هم‌زمان «عقب‌افتاده» شمرده شود. قبلاً این ستون
# در این محاسبه دیده نمی‌شد (۳۸ ردیف متناقض در دادهٔ واقعی).
for customer_id in manual_holds_map(now=now):
overdue.pop(customer_id, None)
return overdue
@@ -726,12 +812,20 @@ def default_hold_until(*, now=None, days=HOLD_DEFAULT_DAYS):
return end_of_local_day(base)
def set_visit_hold(customer, until, *, now=None):
"""ثبت قرار دستی؛ فقط اگر **جلوتر** از مقدار فعلی باشد می‌نویسد.
def set_visit_hold(customer, until, *, now=None, force=False):
"""ثبت قرار دستی؛ به‌طور پیش‌فرض فقط اگر **جلوتر** از مقدار فعلی باشد می‌نویسد.
قاعدهٔ «فقط رو به جلو» (مثل ``refresh_last_bazdid``) جلوی این را می‌گیرد که
ثبت هماهنگی توسط کارشناس دوم، قرارِ دورترِ قبلی را عقب بکشد.
خروجی: ``True`` اگر نوشته شد.
قاعدهٔ «فقط رو به جلو» جلوی این را می‌گیرد که ثبت هماهنگی توسط کارشناس دوم،
قرارِ دورترِ قبلی را عقب بکشد.
``force=True`` برای وقتی است که کاربر **خودش** صریحاً تأیید کرده تاریخ عقب
برود (تیک «تاریخ قرار را عقب ببر» در مودالِ **ویرایش** ⇒ فیلد ``force_hold``).
در «ایجاد وظیفهٔ تازه» ویو این پارامتر را نمی‌فرستد. این پارامتر فقط از مسیر
ویو و پس از تأیید کاربر استفاده می‌شود؛ منطق نمایش/فهرست‌ها تغییری نمی‌کند و
ستون همچنان همان جای ذخیرهٔ تاریخ است.
خروجی: ``True`` اگر نوشته شد، وگرنه ``False`` (یا مقدار فعلی جلوتر است، یا
تاریخ نامعتبر).
"""
if customer is None or customer.pk is None:
return False
@@ -739,7 +833,7 @@ def set_visit_hold(customer, until, *, now=None):
if target is None:
return False
current = as_aware(customer.visit_hold_until)
if current is not None and current >= target:
if current is not None and current >= target and not force:
return False
Customer.objects.filter(pk=customer.pk).update(
visit_hold_until=target, visit_hold_set_at=_now(now),
@@ -936,3 +1030,115 @@ def normalize_finish_datetime(value, *, now=None):
"""تاریخ اتمام: اگر خالی بود، زمان ثبت قرار می‌گیرد (نه ``None``)."""
value = as_aware(value)
return value if value is not None else _now(now)
# ---------------------------------------------------------------------------
# حذف (soft-delete) یک اقدام
# ---------------------------------------------------------------------------
class ActionDeleteError(Exception):
"""حذف مجاز نیست؛ پیام برای نمایش به کاربر."""
def cancel_pending_sms_for_action(customer, *, action_id=None, sms_type=None, now=None):
"""لغو پیامک‌های **ارسال‌نشدهٔ** رویدادیِ همان مشتری («بازدید/نصب ثبت شد»).
چرا با ``action`` پیدا نمی‌کنیم؟ چون ``SMSQueue`` هیچ ارجاعی به رکورد اقدام
ندارد (فقط ``dedup_key`` متنی). برای جلوگیری از ارسال پیامکِ بازدیدی که
دیگر وجود ندارد، پیامک‌های معلق همان مشتری با نوع ``visit_created`` لغو
می‌شوند. پیامک‌های **ارسال‌شده** دست نمی‌خورند (سابقه است).
``sms_type`` (اختیاری): نوع پیامک مورد هدف؛ پیش‌فرض ``visit_created``
(رفتار قبلی، که حذف اقدام از آن استفاده می‌کند). برای «نصب» مقدار
``install_created`` داده می‌شود.
``action_id`` (اختیاری): فقط پیامک‌های همان رکورد اقدام را لغو می‌کند. کلید
یکتاسازی تریگر ``{sms_type}:{action_id}`` (یا با پسوند شماره) است؛ پس
«ویرایش یک رکورد از بازدید به نوع دیگر» نباید پیامک بازدیدِ *رکورد دیگر*
را از بین ببرد. پیش‌فرض (``None``) همهٔ پیامک‌های معلق همان نوع را لغو
می‌کند.
خروجی: تعداد رکوردهای لغوشده.
"""
# ایمپورت محلی: sms.models در زمان ایمپورت این ماژول ممکن است آماده نباشد.
from .models import SMSQueue, SMSType
if customer is None or customer.pk is None:
return 0
now = _now(now)
sms_type = sms_type or SMSType.VISIT_CREATED
pending = (SMSQueue.Status.PENDING, SMSQueue.Status.APPROVED)
query = (
SMSQueue.objects.filter(
customer_id=customer.pk,
sms_type=sms_type,
status__in=pending,
)
.exclude(status=SMSQueue.Status.SENT)
)
if action_id is not None:
# ``startswith`` تنها کافی نیست: کلید اقدام ۷۷ پیشوندِ کلید اقدام ۷۷۵ هم
# هست. پس مرز «:» صریح گذاشته می‌شود (شکل ``…:{action_id}:{number}``).
base = "%s:%s" % (sms_type, action_id)
query = query.filter(
models.Q(dedup_key=base) | models.Q(dedup_key__startswith=base + ":")
)
return query.update(status=SMSQueue.Status.CANCELLED, updated_at=now)
def soft_delete_action(action, *, user=None, now=None):
"""«حذف» یک اقدام: soft-delete با ``status='cancell'`` + حسابرسی.
چرا ``cancell`` و نه ستون ``is_deleted``؟ چون **تمام** محاسبات
(:func:`last_visit_map`، :func:`cycle_anchor_map`،
:func:`appointment_sources_map`، :func:`followups_on_date`،
:func:`overdue_followups`) از قبل فقط ``waite``/``finish`` را می‌بینند؛ پس
همین یک مقدار، اقدام را از همهٔ خروجی‌ها حذف می‌کند — بدون Manager سفارشی،
بدون تغییر ایندکس پوشا و بدون دست‌زدن به ۳۱ کوئری.
پیامدهای جانبی که همین‌جا انجام می‌شوند:
۱. ``last_bazdid_dt`` بازمحاسبه می‌شود (کش کهنه نماند).
۲. قرار دستی اگر دیگر مرجعی نداشته باشد، خودکار بی‌اثر می‌شود
(قاعدهٔ «ستون، مرجع نیست»).
۳. پیامک‌های **ارسال‌نشده**ی رویدادیِ همان مشتری لغو می‌شوند (``visit_created``
و برای اقدام «نصب»، ``install_created``).
ممنوعیت: اقدامی که نظرسنجی ثبت‌شده دارد (``nazarDT`` پر است) حذف نمی‌شود؛
زیرا هیچ آرشیو/کپی‌ای وجود ندارد و گزارش رضایت‌سنجی برای همیشه تغییر می‌کرد.
"""
if action is None or action.pk is None:
raise ActionDeleteError("اقدام معتبر نیست.")
if action.status == Action.Status.CANCELL and action.deleted_at is not None:
raise ActionDeleteError("این اقدام قبلاً حذف شده است.")
if action.nazarDT is not None:
raise ActionDeleteError(
"این اقدام دارای نظرسنجی ثبت‌شده است و قابل حذف نیست. "
"برای کنار گذاشتن آن، از ویرایش و تغییر وضعیت استفاده کنید."
)
now = _now(now)
customer = action.customer
with transaction.atomic():
Action.objects.filter(pk=action.pk).update(
status=Action.Status.CANCELL,
deleted_at=now,
deleted_by=user if getattr(user, "pk", None) else None,
)
action.status = Action.Status.CANCELL
action.deleted_at = now
if customer is not None:
refresh_last_bazdid(customer, reason=REASON_CANCEL, user=user, now=now)
cancelled = cancel_pending_sms_for_action(customer, now=now)
# اقدام «نصب» پیامک رویدادیِ خودش را هم دارد (نوع جدا)؛ حذف آن باید
# پیامک معلقش را هم لغو کند، وگرنه پیامک «نصب ثبت شد» بی‌مرجع می‌ماند.
if action.actionType == Action.ActionType.NASB:
from .models import SMSType
cancelled += cancel_pending_sms_for_action(
customer, sms_type=SMSType.INSTALL_CREATED, now=now,
)
else:
cancelled = 0
return cancelled
File diff suppressed because it is too large. Load diff
+770
View File
@@ -0,0 +1,770 @@
# تحلیل کامل: دو فیلد تاریخِ «اقدام»، صفحهٔ انتظار و پیامک‌های بازدید
> دامنه: `Action` (ایجاد/ویرایش/حذف)، `Customer.visit_hold_until`، ویوی
> `/panel/entezar/` (`?view=today` و `?view=today&missed=1`)، و اسکنر/تریگرهای پیامک.
> روش: خواندن کدِ تولیدی + `ripwire` (blast radius) + `rg` + **ممیزی فقط-خواندنی روی
> کپیِ دیتابیس واقعی** + شبیه‌سازیِ ۳۲ سناریو در تست‌های Django.
> **هیچ خطی از کدِ تولیدی تغییر نکرد.** مصنوعات این بررسی:
>
> | فایل | نقش |
> |---|---|
> | `scripts/entezar_action_audit.py` | ممیزی عددیِ فقط-خواندنی روی دیتابیس واقعی (کپی موقت در /tmp) |
> | `tests/test_entezar_action_simulation.py` | ۳۲ سناریوی شبیه‌سازی (فرم، انتظار، پیامک، باگ missed) |
> | `docs/entezar_action_fields_report.md` | همین سند |
> | `docs/entezar_action_fields_report.html` | نسخهٔ بصریِ تک‌فایل (Tailwind CDN، RTL، نمودار و خط زمانی) |
>
> تاریخ اجرای ممیزی: ۲۰۲۶-۱۰-۲۲ (۱۴۰۵/۰۷/۳۰) — دیتابیس
> `data9/khatibi92669266_backups_db_backup_20261021_223009.sqlite3`.
---
## ۰. پاسخ کوتاه (Executive Summary)
| پرسش | پاسخ |
|---|---|
| فیلد «تاریخ و ساعتی که کار باید انجام شود» چیست؟ | `Action.actionDateTime` — «زمانِ انجام/تماس» وظیفه. برای «بازدید» = زمان قرار بازدید؛ برای «هماهنگی» = **زمان تماس**؛ برای بقیه = زمان انجام وظیفه. |
| فیلد «تا تاریخ پیگیری نشود» چیست؟ | `Customer.visit_hold_until` (پایانِ روز) — فقط برای «هماهنگی» ذخیره می‌شود؛ قرارِ بعدیِ مشتری. |
| اثر فیلد اول کجاست؟ | ۸ مصرف‌کننده: لیست وظایف پنل، «آخرین بازدید» و دورهٔ بازدید، لنگر چرخهٔ پیامک، قرار «بازدید برنامه‌ریزی‌شده»، قرار «هماهنگی»، فهرست پیگیری یک روز، فهرست «عقب‌افتاده‌ها»، و متن پیامک «ثبت بازدید». |
| اثر فیلد دوم کجاست؟ | ۳ مصرف‌کننده: ستون/برچسب «قرار دستی» در صفحهٔ انتظار، فهرست پیگیری روزِ همان قرار، و **مسدودسازی یادآوری دوره‌ای «بازدید» (SMS)** تا پایان آن روز. |
| روی SMS چه اثری دارد؟ | `visit` (دوره‌ای) با هر سه منبعِ قرار (دستی، بازدید برنامه‌ریزی‌شده، هماهنگی آینده) مسدود می‌شود؛ `visit_created` (رویدادی) **فقط** با ثبت اقدام «بازدید» ارسال می‌شود و از `actionDateTime` مقدار `{date}` می‌گیرد — فیلد تعویق روی آن هیچ اثری ندارد. |
| تیک «این کار انجام شده است» در هماهنگی؟ | تیک‌دار ⇒ `status=finish` + `finishDateTime` (خالی ⇒ زمان ثبت) + اگر تاریخ تعویق خالی باشد **پیش‌فرض ۱۴ روز** قرار. بدون تیک ⇒ `waite` و بدون پیش‌فرض؛ فقط اگر تاریخ تعویق پر شود قرار ثبت می‌شود. |
| چرا `missed=1` پر از مشتریِ بازدیدشده است؟ | چون `overdue_followups` **فقط بازدیدِ `waite` (در انتظار)** را حذف می‌کند و بازدیدِ `finish` را هرگز نمی‌بیند؛ همچنین ستون «تا تاریخ پیگیری نشود» را هم نمی‌بیند. |
| «۲ قرار» یعنی چه؟ | برچسب `source_count > 1` — مشتری هم‌زمان بیش از یک **منبعِ** قرار دارد (دستی/بازدید برنامه‌ریزی‌شده/هماهنگی)، نه لزوماً دو قرارِ متفاوت. |
| «قرار دستی» یعنی چه؟ | همان `Customer.visit_hold_until`؛ از فیلد «تا تاریخ پیگیری نشود» نوشته می‌شود و فقط وقتی معتبر است که آخرین فعالیتِ مشتری یک «هماهنگیِ فعال» باشد. |
---
## ۱. نقشهٔ داده و مسیر ذخیره‌سازی
### ۱.۱ ستون‌های دخیل
| موجودیت.ستون | نوع | نویسنده | خواننده‌ها |
|---|---|---|---|
| `Action.actionDateTime` | `DateTimeField(null=True)` | `dateTimeAnjam` فرم (`views/actions.py:90,151,209`) | ۸ مصرف‌کننده — بخش ۲ |
| `Action.finishDateTime` | `DateTimeField(null=True)` | `is_done` + `FinishDateTime` (خالی ⇒ `now`) | «تاریخ مؤثر بازدید»، فهرست پیگیری، تصمیم «انجام‌شده» |
| `Action.status` | `waite/finish/cancell` | تیک «انجام شد» | تمام تصمیم‌ها؛ `cancell` = لغو/حذف نرم |
| `Action.createdDT` | `auto_now_add` | سیستم | قاعدهٔ «آخرین فعالیت» در `_valid_manual_holds` |
| `Customer.visit_hold_until` | `DateTimeField(null=True)` | `visitHoldUntil` هماهنگی (`_apply_visit_hold`) | ۳ مصرف‌کننده — بخش ۳ |
| `Customer.visit_hold_set_at` | `DateTimeField(null=True)` | همان لحظهٔ ثبت (حسابرسی) | فقط نمایش/دیباگ |
| `Customer.last_bazdid_dt` | `DateTimeField(null=True)` | `refresh_last_bazdid` (مشتق) | داشبورد/گزارش‌های قدیمی |
### ۱.۲ مسیر فرم تا دیتابیس
```
customerActionInc.html
dateTimeAnjam ─────────────► _parse_form_datetime(ms) ─► payload['actionDateTime'] ─► Action.actionDateTime
visitHoldUntil ────────────► _parse_form_datetime(ms) ─► payload['holdRaw']
checkIsDone ───────────────► payload['status'] = finish | waite
FinishDateTime ────────────► normalize_finish_datetime (خالی ⇒ now)
│
└─ اگر نوع = هماهنگی:
holdRaw → normalize_hold_until (گذشته ✗، >۱۸۳ روز ✗) → set_visit_hold (فقط رو به جلو)
خالی + finish → default_hold_until() = +۱۴ روز
└─ اگر نوع ≠ هماهنگی و ایجاد تازه:
clear_visit_hold(customer)
```
نکتهٔ کلیدی: `visitHoldUntil` **فقط** زمانی نوشته می‌شود که `actionType == 'hamahangi'`
باشد (`views/actions.py:118-128`). برای «بازدید» و بقیهٔ انواع، حتی اگر مقدار بفرستید،
نادیده گرفته می‌شود (تست: `test_hold_until_ignored_for_visit_actions`).
---
## ۲. فیلد «تاریخ و ساعتی که کار باید انجام شود» — `actionDateTime`
### ۲.۱ یک فیلد، سه معنا (بسته به نوع اقدام)
| نوع اقدام | معنی `actionDateTime` | مرز زمانی در تصمیم‌ها |
|---|---|---|
| `bazdid` + `waite` | زمانِ **قرار بازدید** | تا **پایان روز** (`whole_day=True`) |
| `bazdid` + `finish` | زمانِ **انجام بازدید** | اگر گذشته باشد، «تاریخ مؤثر بازدید» |
| `hamahangi` (هر دو وضعیت) | زمانِ **تماس/هماهنگی** | **لحظه‌ای** (`whole_day=False`) — نه پایان روز |
| `nasb` / `vosol` / `inCall` / `outCall` | زمانِ انجام وظیفه | فقط لیست وظایف |
این تفکیک در `visit_policy._future_actions` (خط ۳۴۱-۳۶۱) صریح مستند شده است: در
هماهنگی، `actionDateTime` «زمان تماس» است، پس «تماسِ امروز» نباید مشتری را تا آخر
امروز پنهان کند؛ در بازدید برنامه‌ریزی‌شده برعکس، قرارِ امروز باید تا پایان امروز معتبر بماند.
### ۲.۲ جدول مصرف‌کننده‌ها
| # | مصرف‌کننده | فایل:تابع | چه می‌کند |
|---|---|---|---|
| ۱ | لیست وظایف امروز/عقب‌افتادهٔ کاربر | `views/actions.py:276-308` (`ActionListToday`, `ActionListUserToday`) | `actionDateTime` بین ۰۰:۰۰ تا ۲۳:۵۹ امروز + `status=waite` ⇒ «وظایف امروز»؛ `< شروع امروز` ⇒ «وظایف عقب‌افتاده» |
| ۲ | «آخرین بازدید» واقعی | `visit_policy.last_visit_map` (۱۴۳) و `effective_visit_dt` (۹۰) | فقط `bazdid+finish`؛ تاریخ مؤثر = `actionDateTime` اگر از حالا جلوتر نباشد، وگرنه `finishDateTime` |
| ۳ | لنگر چرخهٔ بازدید و پیامک | `visit_policy.cycle_anchor_map` (۲۱۴) → `sms/scanner.scan_visit` (۲۱۸-۲۴۵) | آخرین بازدید مؤثر (یا تاریخ نصب) + دورهٔ ۹ ماه ⇒ موعد یادآوری |
| ۴ | قرار «بازدید برنامه‌ریزی‌شده» | `scheduled_visits_map` (۳۸۰) — `bazdid+waite` با `actionDateTime ≥ آغاز امروز` | مشتری از «تعیین‌تکلیف‌نشده» خارج و از یادآوری SMS معاف می‌شود |
| ۵ | قرار «هماهنگی» | `coordination_holds_map` (۳۹۱) — `hamahangi` با `actionDateTime ≥ now` | همان اثر، ولی مرز **لحظه‌ای** |
| ۶ | فهرست پیگیری یک روز | `followups_on_date` (۵۲۲) | `hamahangi` با تاریخ همان روز (یا `finishDateTime` اگر `actionDateTime` خالی باشد) + قرار دستی همان روز |
| ۷ | فهرست «عقب‌افتاده‌ها» | `overdue_followups` (۵۹۰) | **آخرین** `hamahangi` با تاریخ قبل از امروز ⇒ «موعد ازدست‌رفته» (منشأ باگ — بخش ۶) |
| ۸ | متن پیامک «ثبت بازدید» | `sms/triggers.notify_visit_scheduled` (۵۲-۶۱) | `{date}` = `action.actionDateTime` (شمسی می‌شود) |
همچنین در قالب‌ها: ستون تاریخ در `customerActionInc.html:117,169`، `customerAction.html:42`
و مودال ویرایش (`ActionJson` → `dateTimePickerAnjam.setDate`).
### ۲.۳ اعتبارسنجی
* `validate_action_payload` (۹۰۳): نوع/وضعیت باید مجاز باشد؛ فقط برای
`bazdid+finish` سقف «۳۰ روز آینده» اعمال می‌شود (`FINISH_MAX_FUTURE_DAYS`).
* برای هماهنگی هیچ سقفی روی `actionDateTime` نیست.
* اگر تاریخ اتمام خالی بماند و تیک «انجام شد» زده شود، `finishDateTime = now`.
---
## ۳. فیلد «تا تاریخ پیگیری نشود» — `Customer.visit_hold_until`
### ۳.۱ قواعد نوشتن
| قاعده | پیاده‌سازی | اثر |
|---|---|---|
| فقط هماهنگی | `_apply_visit_hold` (`actions.py:101-128`) | برای بازدید/نصب/وصول بی‌اثر |
| پایانِ روز | `end_of_local_day` (۷۰۳) | «تا آخر ۲۳:۵۹:۵۹ همان روز» |
| خالی + تیک «انجام شد» | `default_hold_until()` با `HOLD_DEFAULT_DAYS=14` (۶۷۹، ۷۲۳) | ۱۴ روز تعویق خودکار |
| خالی + بدون تیک | هیچ نوشتنی | مشتری در فهرست می‌ماند |
| تاریخ گذشته | `normalize_hold_until` (۷۶۵) خطا | فرم با پیام فارسی رد می‌شود |
| بیش از ۱۸۳ روز | `HOLD_MAX_DAYS=183` (۶۸۰) خطا | جلوگیری از تعویق بی‌پایان |
| فقط رو به جلو | `set_visit_hold` (۷۲۹): مقدار فعلی ≥ هدف ⇒ نمی‌نویسد | هماهنگی دومِ کوتاه‌تر قرارِ دورتر را عقب نمی‌کشد |
| ثبت اقدام غیرهماهنگی در حالت ایجاد | `clear_visit_hold` | ستون پاک می‌شود (تست: `test_creating_visit_clears_hold_column`) |
| ویرایش اقدام | عمداً دست نمی‌زند | ویرایش گذشته نباید قرارِ فعلی را پاک کند |
### ۳.۲ قاعدهٔ اعتبار («ستون، مرجع نیست»)
`_valid_manual_holds` (۴۰۵-۴۶۰): یک قرار دستی فقط وقتی معتبر است که
1. برای مشتری یک اقدام `hamahangi` با وضعیت `waite` یا `finish` وجود داشته باشد، و
2. **آخرین فعالیتِ مشتری همان هماهنگی باشد** — اگر بعد از آن، اقدامِ غیرهماهنگی
(بازدید، وصول، تماس، …) با `createdDT` بزرگ‌تر ثبت شده باشد، قرار باطل است.
نتیجهٔ عملی: ستون ممکن است پر باشد ولی «مرده». در دادهٔ واقعی از ۵۴ مقدار پرشده،
۵۱ معتبر و ۳ بی‌اعتبار است (۱ بدون هماهنگی فعال، ۲ به‌خاطر اقدام بعدی).
### ۳.۳ مصرف‌کننده‌ها
| مصرف‌کننده | فایل:تابع | اثر |
|---|---|---|
| منبع `manual` صفحهٔ انتظار | `manual_holds_map` (۴۶۳) → `appointment_sources_map` (۶۴۷) | مشتری از «تعیین‌تکلیف‌نشده» خارج و در `?view=manual` دیده می‌شود |
| فهرست پیگیری روزِ قرار | `followups_on_date` (۵۴۳-۵۵۴) | دقیقاً در همان روز (حتی گذشته) در `?view=today` ظاهر می‌شود |
| مسدودسازی یادآوری دوره‌ای SMS | `next_appointment_map` (۲۹۷) → `scan_visit` (`scanner.py:220,232`) | تا پایان آن روز پیامک `visit` صف نمی‌شود |
| نمایش در جدول | `entezar.html:516-518` | ستون «قرار دستی» + برچسب `قرار دستی` |
| مودال ویرایش | `customerActionInc.html:66,706` (`customer_hold`) | مقدار ذخیره‌شده در فیلد بارگذاری می‌شود |
---
## ۴. اثر دو فیلد بر پیامک‌ها
### ۴.۱ دو نوع پیامکِ بازدید — دو منطق کاملاً متفاوت
| نوع | زمان ارسال | تصمیم‌گیر | داده‌های دخیل |
|---|---|---|---|
| `visit` (دوره‌ای) | اسکنر (`scan_visit`) وقتی موعدِ «آخرین بازدید + ۹ ماه» رسیده باشد | `cycle_anchor_map` + `next_appointment_map` | **هر دو فیلد** (فیلد اول به‌واسطهٔ لنگر، فیلد دوم به‌واسطهٔ قرار) |
| `visit_created` (رویدادی) | بلافاصله پس از ثبت اقدام **بازدید** — هم در «ایجاد» و هم وقتی نوع اقدام در «ویرایش» **به** بازدید تغییر کند | `notify_visit_scheduled` | فقط `actionDateTime` (برای متن `{date}`) |
| `install_created` (رویدادی) | بلافاصله پس از ثبت اقدام **نصب** — همان دو مسیر (ایجاد و تغییر نوع در ویرایش) | `notify_install_scheduled` | فقط `actionDateTime` (برای متن `{date}`) |
### ۴.۲ ماتریس اثر (نتیجهٔ شبیه‌سازی، همه با `scan_visit`)
| سناریو | یادآوری `visit` | پیامک `visit_created` |
|---|---|---|
| مشتری عقب‌افتاده، هیچ اقدامی | ✅ ارسال | — |
| «تا تاریخ پیگیری نشود» = +۱۰ روز (با هماهنگی فعال) | ⛔ تا پایان روزِ دهم؛ از فردای آن ✅ | — |
| ستون `visit_hold_until` پر، ولی **بدون** اقدام هماهنگی | ✅ ارسال (ستون بی‌اثر) | — |
| بازدید `waite` با تاریخ +۵ روز | ⛔ (تا پایان آن روز) | — |
| هماهنگی با تاریخ +۵ روز (بدون تعویق) | ⛔ | — |
| هماهنگی «انجام‌شده» با تاریخ گذشته و بدون تعویق | ✅ ارسال | — |
| ثبت اقدام بازدید (هر وضعیتی) | — | ✅ با `{date}` = `actionDateTime` |
| ثبت اقدام نصب (هر وضعیتی) | — | ✅ `install_created`، مستقل از `visit_created` |
| ثبت اقدام هماهنگی | — | ❌ |
| قرار دستی فعال + ثبت بازدید | — | ✅ (فیلد دوم مانع نیست) |
تست‌های متناظر: `SmsSimulationTests` در `tests/test_entezar_action_simulation.py`
(`test_hold_until_suppresses_visit_reminder`، `test_hold_column_without_coordination_is_inert`،
`test_scheduled_future_visit_suppresses_reminder`، `test_future_coordination_suppresses_reminder`،
`test_visit_action_sends_visit_created_with_action_datetime`، …).
### ۴.۳ چرا «تعویق» روی `visit_created` اثر ندارد؟
چون این پیامک در ویوهای اقدام (`views/actions.py`، تابع `_sync_action_event_sms`) فقط
برای `actionType == bazdid` صدا زده می‌شود، و اصلاً از `next_appointment_map` عبور
نمی‌کند. یعنی اگر هماهنگی بگذارید و بعد بازدیدی ثبت کنید، پیامکِ «بازدید شما برای
تاریخ … ثبت شد» می‌رود حتی اگر «تا تاریخ پیگیری نشود» فعال باشد. همین قاعده برای
`install_created` (اقدام از نوع نصب) هم برقرار است. (اگر رفتار مطلوب چیز دیگری است،
باید در `notify_visit_scheduled`/`notify_install_scheduled` یا در شرط فراخوانی آن‌ها
تغییر کند.)
### ۴.۴ جزئیات مهم `scan_visit`
* `next_appointment_map` = **اجتماع** سه منبع (`manual`، `scheduled`، `coordination`)؛
وجود هر کدام ⇒ `continue` (خط ۲۳۲). پس قرار دستی و تاریخ بازدید و تاریخ هماهنگی هر سه
یادآوری را متوقف می‌کنند — این همان «یک جواب برای صفحه و پیامک» است.
* `dedup_key = visit:{customer_id}:{YYYY-MM موعد}` ⇒ هر دوره حداکثر یک پیامک؛ حذف از صف
فقط همان دوره را کنسل می‌کند.
* خط شروع (`scan_since`) و سقف‌ها می‌توانند ارسال موارد قدیمی را متوقف کنند.
* لنگر چرخه **تماس تلفنی را ریست نمی‌کند**؛ فقط بازدیدِ `finish` (یا نصب) لنگر است.
---
## ۵. تیک «این کار انجام شده است» در اقدام هماهنگی
مسیر تصمیم (`_action_payload_from_request` + `_apply_visit_hold`):
| وضعیت فرم | `Action.status` | `finishDateTime` | `visit_hold_until` | اثر بر فهرست انتظار | اثر بر SMS |
|---|---|---|---|---|---|
| تیک + تاریخ تعویق پر | `finish` | اگر خالی ⇒ `now` | همان تاریخ (پایان روز، فقط رو به جلو) | مشتری تا آن روز پنهان؛ همان روز در `?view=today` برمی‌گردد | یادآوری `visit` تا آن روز قطع |
| تیک + تاریخ تعویق خالی | `finish` | `now` | **+۱۴ روز** | همان | همان |
| بدون تیک + تاریخ تعویق پر | `waite` | `NULL` | همان تاریخ | همان | همان |
| بدون تیک + تاریخ خالی | `waite` | `NULL` | بدون تغییر | مشتری در فهرست می‌ماند | بدون اثر |
نکته‌های ریز:
* در UI، برای هماهنگیِ تیک‌خورده پیش از submit یک **تأیید دو مرحله‌ای** نمایش داده می‌شود
(`holdDoneWarning` / `holdConfirmYes` / `holdConfirmNo` در `customerActionInc.html:609-665`).
اگر کاربر «نه» را بزند، تیک به‌صورت سمت‌کلاینت برداشته می‌شود ⇒ همان سطر سوم/چهارم جدول.
* «انجام شد» هیچ اثر مستقیمی روی «آخرین بازدید» (`last_bazdid_dt`) ندارد؛ هماهنگی هرگز
وارد `last_visit_map` نمی‌شود (`HamahangiIsolationTests`).
* «انجام شد» بدون تاریخ تعویق، **همیشه** ۱۴ روز تعویق می‌سازد؛ اگر کارشناس فقط می‌خواست
تماس را لاگ کند، این پیش‌فرض ناخواسته مشتری را دو هفته پنهان می‌کند.
* در حالت **ویرایش**، `_apply_visit_hold` قرار را پاک نمی‌کند؛ حتی تغییر نوع اقدام از
هماهنگی به بازدید، ستون را دست‌نخورده می‌گذارد (ولی قاعدهٔ «آخرین فعالیت» می‌تواند
آن را بی‌اعتبار کند).
---
## ۶. صفحهٔ `/panel/entezar/` — نمای `?view=today`
### ۶.۱ مسیر محاسبه (گام‌به‌گام)
`views/customers.py:entezar` (۳۷۲-۸۵۳):
1. `now`, `due_cut = visit_due_at(now)` (امروز − ۹ ماه)، `period_months`.
2. `visits = last_visit_map()` — منبع «آخرین بازدید».
3. `sources = appointment_sources_map()` — سه منبع قرارِ **آینده**:
`manual` (ستون تعویق معتبر)، `scheduled` (`bazdid+waite` با تاریخ ≥ آغاز امروز)،
`coordination` (`hamahangi` با تاریخ ≥ همین لحظه).
4. پایهٔ نمای «عقب‌افتاده» (سایر `view`ها): مشتریان `laghv_bazdid=False`، نام‌دار،
`installDate ≤ due_cut`، و سپس `stale_ids` = کسانی که «بازدید معتبر ندارند یا بازدیدشان
≤ `due_cut` است».
5. نمای `today` **جدا** است: `today_followups = followups_on_date(today)` — یعنی
«قرار دستیِ همان روز» + «هماهنگی با تاریخ همان روز»؛ منهای مشتریانی که بازدیدِ
**در انتظار** از آغاز امروز دارند.
6. `_followup_ids`: فیلتر `is_active`، `laghv_bazdid=False`، نام غیرتهی + جست‌وجو؛
مرتب‌سازی بر اساس زودترین موعد.
7. `appointment_rows` برای هر ردیف: ادغام `sources[cid]` با `followups[cid]`، سپس
`source_count` = تعداد کلیدهای موجود از میان `manual/scheduled/coordination`.
8. شمارنده‌ها: `counts['followup_today']` و `counts['followup_overdue']` از همان مسیر
فهرست ⇒ «عدد = تعداد ردیف».
9. عارضهٔ جانبی: ویو در هر GET مقدار `CacheTable('9mahbazdid')` را با
`counts['uncoordinated']` بازنویسی می‌کند (خط ۶۹۹-۷۰۵) — یعنی یک صفحهٔ خواندنی،
نوشتن در دیتابیس دارد.
### ۶.۲ ستون «وضعیت قرار» — معنی برچسب‌ها
قالب `entezar.html:502-515`:
```django
{% if appt.source_count > 1 %} <span>{{ appt.source_count }} قرار</span> ← «۲ قرار»
{% elif appt.manual %} قرار دستی
{% elif appt.scheduled %} بازدید برنامه
{% elif appt.coordination %} هماهنگی
{% else %} بدون قرار
```
* **«N قرار»** = تعداد **منابعِ** قرار، نه تعداد قرارهای واقعی. سه منبع مستقل‌اند و
می‌توانند هم‌زمان وجود داشته باشند.
* «قرار دستی» = ستون `visit_hold_until` (فیلد «تا تاریخ پیگیری نشود»).
* «بازدید برنامه» = اقدام `bazdid` با وضعیت `waite` و تاریخ ≥ آغاز امروز.
* «هماهنگی» = اقدام `hamahangi` (وضعیت `waite` یا `finish`) با تاریخ ≥ همین لحظه.
* ترتیب اولویت برچسب: چندقراره ← دستی ← بازدید ← هماهنگی ← بدون قرار. (ستون
`sort=status` هم همین ترتیب را دارد: 0 بدون، 1 دستی، 2 بازدید، 3 هماهنگی، 4 چندقراره.)
### ۶.۳ شواهد واقعی «۲ قرار»
اعداد زیر روی **کل مشتریان فعالی** که در لحظهٔ ممیزی حداقل یک منبع قرار آینده دارند
محاسبه شده است (نه فقط ردیف‌های یک نما):
| سنجه | مقدار |
|---|---|
| مشتریان با ۱ منبع | ۲٫۲۶۷ |
| مشتریان با ۲ منبع | ۱۶ |
| ترکیب `manual + coordination` | ۱۲ (۱۱ مورد تاریخ‌های **متفاوت**، ۱ مورد هم‌روز) |
| ترکیب `scheduled + coordination` | ۴ |
| بیش از ۲ منبع | ۰ |
نمونه‌ها: `#833` دستی ۲۰۲۶-۱۱-۰۴ / هماهنگی ۲۰۲۶-۱۲-۳۰ — `#2888` هر دو ۲۰۲۶-۱۱-۱۱
(همان یک هماهنگی هم «هماهنگی» شمرده شده هم «قرار دستی»).
**پیامد:** «۲ قرار» همیشه یعنی «دو توافق» نیست. یک هماهنگیِ انجام‌شده که تاریخ تعویق
همان روز را دارد، دو منبع تولید می‌کند و برچسب «۲ قرار» می‌گیرد. اگر هدف نمایش
«تعداد قرارهای واقعی» است، باید منابعِ هم‌منشأ (هماهنگی + قرار دستیِ حاصل از همان اقدام)
یک قرار شمرده شوند.
### ۶.۴ «قرار دستی» — تعریف دقیق
| جنبه | مقدار |
|---|---|
| منبع داده | `Customer.visit_hold_until` (پایانِ روز) |
| نویسنده | فقط اقدام `hamahangi` (فیلد «تا تاریخ پیگیری نشود» / پیش‌فرض ۱۴ روز) |
| شرط اعتبار | یک `hamahangi` فعال + «آخرین فعالیت همان هماهنگی» (`_valid_manual_holds`) |
| شرط نمایش در `?view=today` | `visit_hold_until` داخل بازهٔ همان روز |
| شرط نمایش در `sources` | `visit_hold_until > now` (یعنی قرارِ آینده) |
| اثر | خروج از «تعیین‌تکلیف‌نشده»، ورود به فیلتر `manual`، قطع یادآوری SMS تا آن روز |
عدد واقعی: ۵۴ مشتری ستون پر دارند (۵۱ معتبر)، فقط ۴ ردیف در نمای امروز با منبع
`manual` دیده می‌شوند (۲ فقط-دستی، ۲ دستی+هماهنگی).
---
## ۷. باگ `?view=today&missed=1` — چرا این فهرست پر از مشتریِ بازدیدشده بود؟
> این باگ **رفع شد** (بخش ۹): فهرست از ۱۱۲ ردیف به ۴۲ ردیف رسید و هیچ ردیف
> متناقضی (قرار معتبر + عقب‌افتاده) باقی نماند. متن زیر تحلیل زمان کشف است.
### ۷.۱ تعریف فعلی «عقب‌افتاده» در کد
`visit_policy.overdue_followups` (۵۹۰-۶۴۴):
```python
# آخرین موعد هماهنگی هر مشتری (فقط اقدام hamahangi با waite/finish)
when = as_aware(action_dt) or as_aware(finish_dt) # ← finish هم با actionDateTime
latest[cid] = max(روز when)
overdue = {cid: day for ... if day < today} # ← موعد در گذشته
# تنها حذف‌کننده: بازدیدهای «در انتظار» از زودترین موعد به بعد
pending_visits = _pending_visit_customer_ids(since=آغاز روزِ زودترین موعد)
```
و `_pending_visit_customer_ids` (۴۹۹-۵۱۹) فقط:
```python
actionType='bazdid', status='waite'
```
### ۷.۲ سه ریشهٔ خطا (هم‌راستا با هم)
| # | ریشه | چرا باعث حضور مشتریِ «انجام‌شده» می‌شود |
|---|---|---|
| R1 | **بازدیدِ `finish` هرگز دیده نمی‌شود** | قرار بازدید فقط با `waite` تعریف شده؛ بازدیدی که انجام و بسته شده (`finish`) هیچ اثری در حذف ندارد |
| R2 | **ستون «تا تاریخ پیگیری نشود» نادیده گرفته می‌شود** | `overdue_followups` اصلاً `visit_hold_until` را نمی‌خواند، پس مشتری‌ای که قرارِ آیندهٔ معتبر دارد هم در فهرست «عقب‌افتاده» می‌ماند (و در شمارندهٔ قرمز هم شمرده می‌شود) |
| R3 | **`finish` هم با `actionDateTime` سنجیده می‌شود** | برای هماهنگیِ انجام‌شده، `actionDateTime` «زمان تماس» است نه «قرار بعدی» — همان چیزی که `_future_actions` با `whole_day=False` مستند کرده. نتیجه: هر تماسِ تمام‌شده تا ابد «عقب‌افتاده» می‌ماند تا هماهنگی تازه‌ای ثبت شود |
قاعدهٔ موجود فقط وقتی کار می‌کند که کارشناس **بازدیدِ در انتظار** ثبت کرده باشد؛ یعنی
سناریوی واقعیِ «تماس گرفتم و فردا بازدید می‌کنم» درست عمل می‌کند، اما «تماس گرفتم،
بازدید انجام شد و ثبت شد» هرگز از فهرست خارج نمی‌شود.
### ۷.۳ اعداد واقعی (کپی دیتابیس ۲۰۲۶-۱۰-۲۱)
`?view=today&missed=1` = **۱۱۲ ردیف**:
| سنجه | تعداد | درصد |
|---|---|---|
| حداقل یک بازدیدِ `finish` دارند | ۹۹ | ۸۸٫۴٪ |
| بازدید **پس از ثبت هماهنگی** (createdDT بزرگ‌تر) | ۳۲ | ۲۸٫۶٪ |
| آخرین بازدید **بعد از موعد هماهنگی** | ۲۴ | ۲۱٫۴٪ |
| هیچ بازدیدی ندارند (واقعاً نیازمند پیگیری) | ۱۳ | ۱۱٫۶٪ |
| آخرین هماهنگی `finish` (تماس انجام شده) | ۹۹ | ۸۸٫۴٪ |
| آخرین هماهنگی `waite` | ۱۳ | ۱۱٫۶٪ |
| «قرار دستی» **معتبرِ آینده** دارند و باز هم در فهرست‌اند | ۳۸ | ۳۳٫۹٪ |
| `finish` بدون قرار دستیِ فعال | ۶۱ | ۵۴٫۵٪ |
| بازدیدِ در انتظار (هر تاریخی) دارند | ۰ | ۰٪ |
### ۷.۴ دو نمونهٔ واقعی (خط زمانی کامل)
**#236 — خانم مرضیه جمالی** (`--customer 236`):
```
#77042 hamahangi finish action=2026-10-17 14:31 finish=2026-10-17 15:26 created=2026-10-15
#77176 bazdid finish action=2026-10-18 15:26 finish=2026-10-19 16:12 created=2026-10-17
```
هماهنگی ۱۵ اکتبر ثبت شده، تماس ۱۷ اکتبر انجام شده، **بازدید ۱۸ اکتبر ثبت شده** — ولی
مشتری هنوز در `missed` است، چون R1 و R3 (تاریخِ هماهنگی گذشته است و بازدید `finish`
نادیده گرفته می‌شود).
**#1431 — آقای حمید جمالی:**
```
#63749 hamahangi finish action=2026-10-16 finish=2026-10-17 created=2026-02-09
#77122 bazdid finish action=2026-10-18 created=2026-10-17
#77262 bazdid finish action=2026-10-20 created=2026-10-18
```
دو بازدیدِ انجام‌شده بعد از تماس؛ باز هم در `missed`.
### ۷.۵ چرا «بازدید انجام شده» در ستون «آخرین بازدید» دیده می‌شود ولی چیزی را عوض نمی‌کند؟
ستون «آخرین بازدید» از `last_visit_map` می‌آید (که `bazdid+finish` را می‌بیند)، اما
تصمیم حضور در فهرست از `overdue_followups` می‌آید (که آن را نمی‌بیند). یعنی **دو
تعریف واگرا**؛ دقیقاً همان چیزی که خود `visit_policy` در docstring ابتدایی می‌خواست از
بین ببرد.
### ۷.۶ نمای `today` هم آلوده است (به شکل دیگری)
`?view=today` = **۱۳ ردیف**:
| سنجه | مقدار |
|---|---|
| فقط `coordination` | ۹ |
| فقط `manual` | ۲ |
| `coordination + manual` | ۲ |
| هماهنگی‌های امروز با وضعیت `finish` (تماس انجام‌شده) | ۱ |
| هماهنگی‌های امروز با وضعیت `waite` | ۸ |
| ردیف‌هایی که قرار دستیِ آینده هم دارند (بعداً دوباره می‌آیند) | ۴ |
| ردیف‌هایی که آخرین بازدیدشان در/بعد از همان روز موعد است | ۰ |
> «وضعیت» در دو سطر بالا، وضعیت **آخرین اقدام هماهنگی** همان مشتری است (ممکن است با
> اقدامی که منبع `coordination` امروز را ساخته یکی نباشد).
نتیجه: این نما «موعدِ تماس» را با «موعدِ پیگیری» مخلوط می‌کند؛ تماسِ انجام‌شدهٔ امروز هم
یک ردیف می‌سازد، و اگر همان تماس قرار دستی هم داده باشد، مشتری دو بار (امروز و روزِ
قرار) در فهرست‌های کاری ظاهر می‌شود. همچنین `_pending_visit_customer_ids` فقط
`waite` را حذف می‌کند، پس **بازدیدِ امروزِ انجام‌شده** هم ردیف امروز را حذف نمی‌کند.
### ۷.۷ تناقض آشکار: یک مشتری هم‌زمان «قرار دارد» و «عقب‌افتاده» است
۳۸ مشتری از ۱۱۲ ردیف، در `appointment_sources_map` (و در فیلتر `?view=manual`) قرار
معتبر دارند — یعنی از «تعیین‌تکلیف‌نشده» خارج‌اند — ولی همان لحظه در `missed=1` و در
شمارندهٔ قرمز دیده می‌شوند. مثال: `#3339` قرار دستی ۲۰۲۶-۱۰-۲۲ (امروز) دارد و در
`sources` هم منبع هماهنگی ۲۰۲۶-۱۲-۱۶ دارد؛ یعنی «۲ قرار» گرفته، در حالی که وضعیت
پیگیری‌اش متناقض نمایش داده می‌شود.
---
## ۸. جمع‌بندی ریشه‌ای و پیشنهاد رفع
> وضعیت: **اعمال شد** — فهرست نهایی رفع‌ها، کد تغییرکرده و اثر عددی در بخش ۹.
### ۸.۱ دو تعریف واگرا از «تعیین‌تکلیف شده»
| لایه | معیار فعلی |
|---|---|
| «تعیین‌تکلیف‌نشده» (کارت‌ها/فیلترها) | اجتماع سه منبع قرارِ آینده + اعتبار قرار دستی |
| «عقب‌افتاده» (`missed`) | فقط `hamahangi` + فقط بازدیدِ `waite` |
رفع باگ = هم‌ترازکردن لایهٔ دوم با لایهٔ اول.
### ۸.۲ حداقل تغییرِ ممکن (پیشنهاد، بدون اعمال)
۱. **در `overdue_followups`** بعد از حذف `pending_visits`:
```python
# الف) هر بازدیدِ انجام‌شده که تاریخ مؤثرش از روزِ موعد عقب‌تر است، تکلیف را روشن کرده
for customer_id, action_dt, finish_dt in Action.objects.filter(
actionType=Action.ActionType.BAZDID, status=Action.Status.FINISH,
).values_list('customer_id', 'actionDateTime', 'finishDateTime').iterator():
eff = as_aware(action_dt) if action_dt and as_aware(action_dt) <= now else as_aware(finish_dt)
if eff and eff.date() >= overdue.get(customer_id, today):
overdue.pop(customer_id, None)
# ب) قرار دستیِ معتبر همان چیزی است که کاربر صریحاً گفته «تا این تاریخ پیگیری نکن»
for customer_id in manual_holds_map(now=now):
overdue.pop(customer_id, None)
```
۲. **در `followups_on_date`**: همان قاعدهٔ «بازدید انجام‌شدهٔ بعد از موعد» را هم اعمال
کنید (فیلتر سوم بعد از `pending_visits`)، و اگر مشتری هم `manual` دارد و هم
`coordination` از **همان اقدام**، فقط `manual` را نگه دارید تا ردیف تکراری/«۲ قرار»
کاذب ساخته نشود.
۳. **معنای تاریخ `finish` در هماهنگی**: تصمیم محصولی لازم است؛ پیشنهاد: موعدِ
`finish` را `visit_hold_until` بدانید و `actionDateTime` را فقط برای `waite`
به‌عنوان موعد بشمارید. این تنها راه حذف ۹۹ ردیفِ «تماس انجام‌شده» از فهرست
عقب‌افتاده‌ها بدون از دست دادن قرارهای واقعی است.
۴. **`_valid_manual_holds`**: ابطال قرار را به اقدام‌های «واقعاً جایگزین قرار» محدود کنید
(`bazdid`/`nasb`) نه هر اقدام غیرهماهنگی؛ الان یک سند وصول یا تماس ورودی، قرار را
باطل می‌کند.
۵. **برچسب «N قرار»**: تعداد را بر اساس **تاریخ‌های یکتا** یا **اقدام‌های یکتا** حساب
کنید (نه تعداد کلیدهای منابع)، یا منابع هم‌منشأ را در `appointment_rows` ادغام کنید.
۶. **نوشتن در GET**: بازنویسی `CacheTable('9mahbazdid')` را از ویو به یک دستور/سیگنال
منتقل کنید (کش باید مشتق باشد، نه منبع).
### ۸.۳ ریسک‌ها
* هر تغییر در `overdue_followups` روی شمارندهٔ قرمزِ هدر (`entezarbazdid`/`followup_overdue`)
و هشدارهای صفحه اثر می‌گذارد؛ تست `test_overdue_counter_equals_missed_rows` را حفظ کنید.
* `scan_visit` از `next_appointment_map` می‌خواند، نه از `overdue_followups`؛ پس تغییر
فهرست، پیامک‌ها را جابه‌جا **نمی‌کند** (خوب است).
* تست‌های `MissedViewSimulationTests` رفتار فعلی را قفل کرده‌اند؛ بعد از رفع، باید
`assertIn` به `assertNotIn` تبدیل شود.
---
## ۹. باگ‌های شناسایی‌شده و رفع‌شده
> تاریخ رفع: ۲۰۲۶-۱۰-۲۲ · بدون تغییر در قرارداد داده‌ها (بدون migration) ·
> ۲۶۹ + ۱۹۹ تست قبلی سبز ماند + ۴۶ تست شبیه‌سازی/رگرسیون.
### ۹.۱ چرخهٔ کسب‌وکار (مبنا همهٔ تصمیم‌ها)
```
نصب دستگاه ──► ۹ ماه ──► «موعد بازدید» (کارت عقب‌افتاده + پیامک visit)
│
▼
تماس کارشناس (hamahangi)
├── انجام نشده (waite) ──► موعد تماس = actionDateTime ──► نمای «امروز»
└── انجام شد (finish) ───► «تا تاریخ پیگیری نشود» ──► پنهان تا آن روز + قطع SMS
│
▼
بازدید انجام شد (bazdid + finish)
──► لنگر چرخه از نو (۹ ماه) + پیامک visit_created
```
اصل حاکم بر همهٔ رفع‌ها: **قرار/بازدیدِ انجام‌شده باید فهرست کار را ببندد، ولی هیچ
مشتریِ بدون قرار نباید بی‌صدا از فهرست کار ناپدید شود.**
### ۹.۲ جدول باگ‌ها
| # | نوع | باگ | ریشه | رفع |
|---|---|---|---|---|
| B1 | کسب‌وکار | فهرست `missed=1` پر از مشتریانی که بازدیدشان انجام شده بود (۹۹ از ۱۱۲) | `overdue_followups` فقط بازدیدِ `waite` را حذف می‌کرد | بازدیدِ `finish` با تاریخ مؤثر ≥ موعد ⇒ حذف از فهرست |
| B2 | کسب‌وکار | ۳۸ ردیف هم‌زمان «قرار معتبر آینده» و «عقب‌افتاده» بودند | ستون `visit_hold_until` در `overdue_followups` خوانده نمی‌شد | قرار دستیِ معتبر ⇒ حذف از فهرست |
| B3 | کسب‌وکار | نمای «امروز» بعد از بازدیدِ همان روز، ردیف را نگه می‌داشت | `followups_on_date` فقط `waite` را حذف می‌کرد | بازدیدِ انجام‌شده از همان روز به بعد ⇒ حذف ردیف (فقط امروز/آینده) |
| B4 | کسب‌وکار | یک سند وصول یا تماس ورودی، «قرار دستی» را بی‌صدا باطل می‌کرد | `_valid_manual_holds` هر اقدام غیرهماهنگی را «جایگزین قرار» می‌گرفت | فقط `bazdid`/`nasb` قرار را باطل می‌کنند (`_SUPERSEDING_TYPES`) |
| B5 | کسب‌وکار | «۲ قرار» برای یک توافق (هماهنگی + تعویقِ همان روز) | `source_count` تعداد منابع بود، نه تعداد قرار | شمارش روزهای یکتا (و همان قاعده در `sort=status`) |
| B6 | فنی | مرز «وظایف امروز» با رشتهٔ naive ساخته می‌شد (وابسته به تایم‌زون/DST) | `strftime('%Y-%m-%d 00:00:00')` در `ActionListToday`/`ActionListUserToday` | `_local_day_range()` صریح و aware با پوشش DST |
| B7 | فنی | هر GET صفحهٔ انتظار، `CacheTable` را می‌نوشت (حتی بدون تغییر) | `save()` بی‌قید در ویو | فقط در صورت تغییر + `update_fields` |
| B8 | فنی (تست) | تست `test_overdue_column_hidden_only_in_followup_view` به ساعت اجرا وابسته بود | هماهنگی «امروز ساعت ۱۲» می‌توانست «آینده» شمرده شود | تست با مشتری بدون قرار، مستقل از ساعت |
| B9 | کسب‌وکار | اقدام **«نصب»** ردیف را از فهرست‌های کار (`?view=today` و `?view=today&missed=1`) حذف نمی‌کرد؛ فقط «بازدید» حذف می‌کرد | `followups_on_date`/`overdue_followups` در دو مرحلهٔ «قرار در انتظار» و «انجام‌شده» فقط `bazdid` را می‌دیدند (`_pending_visit_customer_ids` و `last_visit_map`) | آن دو مرحله به `_pending_appointment_ids` (با `_CYCLE_RESET_TYPES` = `bazdid`/`nasb`) و `cycle_anchor_map` منتقل شدند؛ `last_visit_map` دست‌نخورده ماند |
| B10 | رویداد | تغییر نوع یک اقدام از «هماهنگی» به «بازدید» در فرم **ویرایش**، پیامک `visit_created` نمی‌فرستاد | تریگر `notify_visit_scheduled` فقط در `customerNewAction` صدا زده می‌شد (یک فراخوان در کل کد) | در `customerEditAction` گذار نوع بررسی می‌شود: غیربازدید → بازدید ⇒ تریگر؛ بازدید → غیربازدید ⇒ `cancel_pending_sms_for_action(action_id=…)`. تکرار با `dedup_key=visit_created:{action.pk}` مهار می‌شود |
| F1 | قابلیت | نوع پیامک «ثبت نصب» (اقدام از نوع نصب) وجود نداشت | فرم‌های اقدام از نوع نصب هیچ تریگر پیامکی نداشتند | `SMSType.INSTALL_CREATED` + `notify_install_scheduled` + قالب قابل ویرایش در `/panel/sms/templates` + تریگر در هر دو ویوی ایجاد/ویرایش (مهاجرت ۰۰۸۱) |
> **F1 — قیدهای ایمنی مهاجرت ۰۰۸۱:** مهاجرت فقط `choices` را گسترش می‌دهد
> (`AlterField` = متادیتا، بدون تغییر داده) و **یک** ردیف تازه با
> `get_or_create` می‌سازد. هیچ `update`/`save` روی قالب‌های موجود ندارد؛ اگر قالب
> `install_created` از قبل باشد هم دست نمی‌خورد. قالب تازه:
> `is_enabled=True` (در پنل دیده شود) و **`auto_send=False`** ⇒ پیامک‌ها در صف
> «در انتظار تأیید» می‌مانند تا مدیر خودش تأیید کند. تست‌های نگهبان:
> `InstallTemplateMigrationTests` و `test_install_template_is_seeded_with_auto_send_off`
> در `tests/test_sms.py`.
> **B9 — جزئیات (۲۰۲۶-۱۰-۲۷):** «نصب» از قبل در `_SUPERSEDING_TYPES` (ابطال قرار
> دستی) و `_CYCLE_RESET_TYPES` (ریست چرخهٔ ۹ ماهه در اسکنر پیامک) بود، ولی فهرست
> کار روزانه آن را نمی‌دید. حالا:
>
> * `nasb + waite` با تاریخ از آغاز روز مورد نظر به بعد ⇒ ردیف پنهان می‌شود
> (همان قاعدهٔ `bazdid + waite`؛ «قرار امروز» تا پایان امروز معتبر است).
> نصبِ در انتظارِ **قدیمی‌تر** از آن روز، قرار فعال نیست و ردیف را باز نمی‌گذارد.
> * `nasb + finish` با تاریخ مؤثر از موعد به بعد ⇒ ردیف بسته می‌شود (همان قاعدهٔ
> `bazdid + finish`).
> * `last_visit_map` (تاریخ آخرین **بازدید**، ستون جدول، `last_bazdid_dt` و پیامک)
> عمداً تغییر نکرد؛ «نصب» فقط لنگر چرخه است، نه بازدید.
### ۹.۳ اثر روی دادهٔ واقعی (همان نسخهٔ پشتیبان)
| سنجه | قبل | بعد |
|---|---|---|
| ردیف‌های `?view=today&missed=1` | **۱۱۲** | **۴۲** |
| ردیف با قرار دستیِ معتبر و هم‌زمان در فهرست | ۳۸ | **۰** |
| ردیف با بازدیدِ بعد از موعد (بسته‌شده) | ۶۲ (۳۲ + ۲۴ + …) | **۰** |
| ردیف با بازدیدِ قدیمی‌تر از موعد (واقعاً نیازمند) | ۳۱ | ۳۱ |
| ردیف بدون هیچ بازدید | ۱۳ | ۱۳ |
نمونه‌های گزارش‌شده: `#236` و `#1431` دیگر در فهرست نیستند (بازدیدشان بعد از موعد
هماهنگی ثبت شده بود).
### ۹.۴ چرا این رفع‌ها چرخه را نمی‌شکنند
* `next_appointment_map` (منبع تصمیم اسکنر پیامک) دست نخورده است ⇒ **هیچ پیامکی
جابه‌جا نشد**؛ فقط نمایش فهرست کار درست شد.
* هیچ فیلد/معنای داده‌ای تغییر نکرد؛ `visit_hold_until` همچنان مشتق و بی‌اعتبار
به‌تنهایی است.
* هر رفع، «حذف از فهرست کار» است نه «حذف مشتری»: مشتریِ بدون قرار همچنان در
`?view=uncoordinated` (تعیین‌تکلیف‌نشده) دیده می‌شود.
* قاعدهٔ «عدد هشدار = تعداد ردیف فهرست» حفظ شد (شمارنده از همان تابع می‌آید).
* `followups_on_date` برای **روزهای گذشته** رفتار قبلی را نگه می‌دارد تا مرور
تقویم، سندِ کاریِ همان روز را بازنویسی نکند.
### ۹.۵ باقی‌مانده (تصمیم محصولی، عمداً تغییر نکرد)
* موعدِ هماهنگیِ `finish` همچنان «زمان تماس» است. اگر تعویق منقضی شود و بازدیدی
ثبت نشود، مشتری دوباره «نیازمند پیگیری» می‌شود. این عمدی است: «ناپدید شدن
بی‌صدای مشتریِ بی‌قرار» خطرناک‌تر از «یک ردیف اضافه» است. اگر کسب‌وکار تصمیم
بگیرد تماسِ انجام‌شده چرخه را ۹ ماه عقب بیندازد، تغییر فقط افزودن `hamahangi`
به `_CYCLE_RESET_TYPES` است — با اثر جانبی روی پیامک‌ها.
* `Customer.visit_hold_until` همچنان ستون مشتق است (بدون migration)؛ تنها
نویسنده‌اش باید `set_visit_hold` بماند.
### ۹.۶ بازخورد شفاف + تأیید کاربر برای «عقب‌بردن» تاریخ قرار
> پیاده‌سازی‌شده · بدون migration · منطق تصمیم‌گیری عوض نشده (قاعدهٔ «فقط رو به جلو» پابرجاست).
پیش از این، اگر تاریخ «تا تاریخ پیگیری نشود» از قرار فعلی عقب‌تر بود،
`set_visit_hold` مقدار `False` برمی‌گرداند و لایهٔ فراخوان آن را دور می‌ریخت:
نه نوشتنی، نه پیامی، نه نشانه‌ای. حالا زنجیره کامل و شفاف است:
| لایه | تغییر |
|---|---|
| `visit_policy.set_visit_hold(..., force=False)` | پارامتر تأیید صریح؛ پیش‌فرض همان قاعدهٔ «فقط رو به جلو» |
| `views/actions._apply_visit_hold(...)` | خروجی ساخت‌یافته: `{'ok', 'reason', 'current', 'target', 'force_allowed'}` — `reason='forward_only'` یعنی نوشته نشد |
| `views/actions._report_hold_result(...)` | `messages.warning` کوتاه: «تاریخ قرار ذخیره نشد؛ از قرار فعلی این مشتری (تاریخ) عقب‌تر است.» + مسیر انجام (تیک/ویرایش) |
| `customerNewAction` / `customerEditAction` | مقدار `force_hold` فرم و گزارش نتیجه به کاربر |
| `customerActionInc.html` | اعلان `holdBackwardNotice` + `force_hold` + تیک «تاریخ قرار را عقب ببر» (فقط در ویرایش) |
| `customerBaseNew.html` | پیام `warning` با رنگ هشدار (کهربایی) نمایش داده می‌شود |
### تجربهٔ کاربر
1. تاریخ عقب‌تر را انتخاب می‌کند؛ همان لحظه (نه فقط هنگام ثبت) اعلان کوتاه باز می‌شود:
«این تاریخ از قرار فعلی عقب‌تر است» / «قرار فعلی: ۱۴۰۵/۰۸/۰۲».
2. **در ویرایش**: تیک «تاریخ قرار را عقب ببر» ⇒ `force_hold=1` و ذخیره می‌شود.
بدون تیک، ثبت بلاک می‌شود و فوکوس روی همان اعلان می‌رود.
3. **در ایجاد وظیفهٔ تازه**: تیک وجود ندارد؛ به‌جای آن یک خط راهنما:
«این تاریخ ذخیره نمی‌شود. برای تغییر قرار، وظیفهٔ هماهنگی فعلی را ویرایش کنید.»
دلیل: در ایجاد، کاربر قصد اصلاح قرار موجود را ندارد و اجازه دادن می‌توانست
ناخواسته قولِ ثبت‌شدهٔ قبلی (شاید کارِ کارشناس دیگر) را کوتاه کند. خودِ وظیفه
ثبت می‌شود؛ فقط تاریخ قرار عقب نمی‌رود.
4. اگر جاوااسکریپت اجرا نشود یا کاربر مستقیم POST بزند، **سرور** هم مستقل بررسی
می‌کند: نه می‌نویسد، نه ساکت می‌ماند — پیام هشدار روی صفحهٔ اقدام نمایش داده می‌شود
(برای ایجاد، پیام «همان وظیفه را ویرایش کنید» را نشان می‌دهد).
5. همان روز ⇒ تغییر واقعی لازم نیست؛ هیچ پیامی هم ساخته نمی‌شود.
### تست‌ها (`HoldBackwardConfirmTests` — ۸ مورد)
| تست | چه چیزی را تضمین می‌کند |
|---|---|
| `test_earlier_date_is_not_saved_and_user_is_told` | نوشته نمی‌شود + پیام شامل «ذخیره نشد»، قرار فعلی و مسیر تأیید؛ پیام در HTML صفحه هم دیده می‌شود |
| `test_user_confirmation_writes_the_earlier_date` | با `force_hold` تاریخ عقب‌تر ذخیره می‌شود |
| `test_forward_date_needs_no_confirmation` | تاریخ جلوتر مثل قبل بدون پرسش ذخیره می‌شود |
| `test_same_day_is_silent` | همان روز ⇒ بدون پیام |
| `test_create_does_not_move_the_hold_backward` | در ایجاد، حتی با `force_hold=1` ستون عقب نمی‌رود، ولی خودِ وظیفه ثبت می‌شود + پیام «ویرایش کنید» |
| `test_apply_visit_hold_reports_reason` | قرارداد تابع: `reason='forward_only'` و `force_allowed=True` در ویرایش |
| `test_apply_visit_hold_ignores_force_on_create` | `force_hold` در ایجاد نادیده گرفته می‌شود |
| `test_modal_exposes_confirmation_controls` | قالب شامل `data-customer-hold`، `force_hold`، `hbForceRow`، `hbCreateHint` است |
علاوه بر آن، منطق جاوااسکریپت با اجرای همان کدِ قالب (نه کپی) در Node آزموده شد:
«عقب‌تر ⇒ هشدار»، «جلوتر/همان روز/بدون قرار/نوع غیرهماهنگی ⇒ بی‌هشدار».
---
## ۱۰. سناریوی «تعویق پیش از تاریخ کار» (۱۴۰۵/۰۸/۰۳ در برابر ۱۴۰۵/۰۸/۰۵)
> بازتولید: `scripts/coordination_hold_order_probe.py --customer 5124`
> قفل رفتاری: `tests/test_entezar_action_simulation.py::HoldBeforeWorkDateSimulationTests`
### ۱۰.۱ چه اتفاقی می‌افتد
| ورودی | مقدار ذخیره‌شده |
|---|---|
| نوع = هماهنگی · «تاریخ و ساعتی که کار باید انجام شود» = ۱۴۰۵/۰۸/۰۵ | `Action.actionDateTime` = ۲۰۲۶-۱۰-۲۷ |
| «تا تاریخ پیگیری نشود» = ۱۴۰۵/۰۸/۰۳ | `Customer.visit_hold_until` = ۲۰۲۶-۱۰-۲۵ ۲۳:۵۹:۵۹ |
هر دو تاریخ در آینده‌اند، پس `normalize_hold_until` قبول می‌کند:
**هیچ اعتبارسنجی‌ای «تعویقِ زودتر از تاریخ کار» را رد نمی‌کند** (نه در فرم، نه در سرور).
نتیجه: دو تاریخ متفاوت برای یک مشتری ⇒ منبع `manual` (۱۰-۲۵) + منبع `coordination` (۱۰-۲۷).
### ۱۰.۲ گاه‌شمار رفتار (شبیه‌سازی‌شده، هم با تیک و هم بدون تیک)
| روز | منابع قرار | برچسب | «نیازمند پیگیری امروز» | «عقب‌افتاده» | یادآوری SMS |
|---|---|---|---|---|---|
| ۱۰-۲۲ تا ۱۰-۲۴ | manual + coordination | **۲ قرار** | خیر | خیر | ارسال نمی‌شود |
| ۱۰-۲۵ (روزِ تعویق) | manual + coordination | ۲ قرار | **بله** ← ردیف کاری اضافه | خیر | ارسال نمی‌شود |
| ۱۰-۲۶ | coordination | ۱ قرار | خیر | خیر | ارسال نمی‌شود |
| ۱۰-۲۷ (تاریخ کار) | coordination | ۱ قرار | **بله** (روز قرار) | خیر | ارسال نمی‌شود |
| ۱۰-۲۸ به بعد | — | بدون قرار | خیر | **بله** | (موعد چرخه) |
مقایسه با حالت سالم (تعویق ۱۱-۰۱ ≥ تاریخ کار ۱۰-۲۷): از ۱۰-۲۸ به بعد
`manual` باقی می‌ماند، «۱ قرار» می‌شود و مشتری «عقب‌افتاده» نمی‌شود.
### ۱۰.۳ اثر تیک «انجام شد»
| مورد | با تیک | بدون تیک |
|---|---|---|
| `status` | `finish` | `waite` |
| `finishDateTime` | اگر خالی ⇒ **همین حالا** (نه ۱۰-۲۷) | `NULL` |
| `visit_hold_until` | همان ۱۰-۲۵ | همان ۱۰-۲۵ |
| پنهان‌سازی/«۲ قرار»/SMS | **یکسان** | **یکسان** |
| «لیست وظایف» و شمارندهٔ داشبورد (`status='waite'`) | خارج می‌شود | می‌ماند (وظیفهٔ ۱۰-۲۷) |
| صف «نظرسنجی» (`finish` و `nazarDT` خالی) | **وارد می‌شود** | وارد نمی‌شود |
| تشخیص «قرار دستی» | معتبر (همهٔ شرط‌ها) | معتبر |
یعنی تیک روی «قرار» اثری ندارد؛ فقط «وظیفه بسته شد» را اعلام می‌کند و در عوض
مشتری را به صف نظرسنجی می‌برد.
### ۱۰.۴ پیامدهای کسب‌وکاری این ورودی
1. **ردیف کاری تکراری**: در ۱۰-۲۵ (روز تعویق) مشتری در «نیازمند پیگیری» ظاهر می‌شود،
در حالی که قرارِ توافق‌شده ۱۰-۲۷ است ⇒ تماسِ بی‌مورد.
2. **برچسب گمراه‌کننده**: «۲ قرار» نشان می‌دهد دو توافق وجود دارد، ولی در واقع یک
توافق است با تعویقی که زودتر از موعدِ کار تمام می‌شود.
3. **سقوطِ یک‌روزه به «عقب‌افتاده»**: فردای تاریخ کار، تعویق قبلاً منقضی شده، پس
«بدون قرار» و «عقب‌افتاده» می‌شود.
4. **بدون ارسال پیامک در فاصلهٔ ۱۰-۲۵ تا ۱۰-۲۷**: چون `next_appointment_map` (که اسکنر
می‌خواند) هنوز منبع `coordination` را دارد؛ صفحه و پیامک واگرا نمی‌شوند.
### ۱۰.۵ توصیه
* برای یک توافق، **تاریخ تعویق = تاریخ کار** (یا دورتر) بگذارید؛ آن‌وقت «۱ قرار» می‌شود
و ردیف تکراری ساخته نمی‌شود.
* اگر می‌خواهید سیستم جلوی این ورودی را بگیرد، حداقل تغییر: در
`normalize_hold_until`/ویو، اگر `hold < actionDateTime` بود، **هشدار** بدهید
(نه خطا) — چون در برخی سناریوها («تعویق تا این تاریخ، بعدش خودم تماس می‌گیرم»)
ممکن است عمدی باشد.
---
## ۱۱. بازتولید و اعتبارسنجی
### ۱۱.۱ ممیزی روی دیتابیس واقعی (فقط خواندن)
```bash
.venv/Scripts/python.exe scripts/entezar_action_audit.py
.venv/Scripts/python.exe scripts/entezar_action_audit.py --samples 10
.venv/Scripts/python.exe scripts/entezar_action_audit.py --customer 236
.venv/Scripts/python.exe scripts/entezar_action_audit.py --db data9/<backup>.sqlite3
```
اسکریپت ابتدا کل فایل sqlite (به‌همراه `-wal`/`-shm`) را در پوشهٔ موقت کپی می‌کند و
همهٔ کوئری‌ها/توابع `visit_policy` را روی همان کپی اجرا می‌کند؛ دیتابیس اصلی دست‌نخورده
می‌ماند. خروجی شامل پنج بخش است (missed، today، برچسب‌ها، ستون قرار، هماهنگی‌ها) و با
`--customer` خط زمانی کامل یک مشتری.
### ۱۱.۲ شبیه‌سازی‌ها
```bash
.venv/Scripts/python.exe manage.py test tests.test_entezar_action_simulation -v 2
```
نتیجهٔ اجرا: **۵۵ تست، OK** (Django 3.2.25، SQLite حافظه‌ای). پوشش:
| کلاس | تعداد | چه چیزی را ثابت می‌کند |
|---|---|---|
| `ActionFormSimulationTests` | ۹ | نگاشت دو فیلد، پیش‌فرض ۱۴ روز، رد تاریخ گذشته/بیش از ۶ ماه، «فقط رو به جلو»، بی‌اثری روی بازدید |
| `EntezarDecisionSimulationTests` | ۸ | «۲ قرار» فقط برای دو روز متفاوت، «قرار دستی»، ابطال با بازدید، روزِ پیگیری، خروج با بازدید آینده |
| `AppointmentLifetimeSimulationTests` | ۴ | سند وصول/تماس قرار را باطل نمی‌کند؛ بازدید/نصب باطل می‌کند (B4) |
| `FollowupDaySimulationTests` | ۳ | بازدیدِ امروز ردیف امروز را می‌بندد؛ بازدید دیروز نه؛ مرور روز گذشته دست‌نخورده (B3) |
| `TaskListBoundaryTests` | ۲ | مرز ۰۰:۳۰/۲۳:۳۰ به وقت محلی؛ وظیفهٔ انجام‌شده در «امروز» نمی‌آید (B6) |
| `CacheTableWriteTests` | ۱ | عدد کش داشبورد = شمارندهٔ «تعیین‌تکلیف‌نشده» (B7) |
| `MissedViewSimulationTests` | ۹ | رفع B1/B2: بازدید `finish` و قرار دستی معتبر از فهرست خارج می‌کنند؛ قرارِ مرده نه |
| `SmsSimulationTests` | ۱۱ | قطع/بازگشت یادآوری دوره‌ای، بی‌اثری ستون تنها، `visit_created` با تاریخ، پاک‌شدن ستون با ثبت بازدید |
| `HoldBeforeWorkDateSimulationTests` | ۹ | سناریوی «تعویق پیش از تاریخ کار»: پذیرش، «۲ قرار»، ردیف تکراری، سکوت SMS، سقوط به missed، تفاوت تیک/بدون‌تیک |
> سناریوی ۱۴۰۵/۰۸/۰۳ در برابر ۱۴۰۵/۰۸/۰۵ در بخش ۱۰ به‌تفصیل آمده و با
> `scripts/coordination_hold_order_probe.py` قابل بازپخش است.
### ۱۱.۳ محدودیت‌ها / فرض‌ها
* همهٔ اعداد از **یک نسخهٔ پشتیبان** (۲۰۲۶-۱۰-۲۱) است؛ با دادهٔ فردا تغییر می‌کنند.
* ممیزی «تاریخ مؤثر» را با قاعدهٔ `effective_visit_dt` بازسازی می‌کند؛ اگر رکوردی
`actionDateTime` آینده داشته باشد، `finishDateTime` مبنا می‌شود.
* «بازدید انجام‌شده پس از هماهنگی» بر اساس `createdDT` (زمان ثبت) سنجیده شده، نه تاریخ
واقعی بازدید؛ چون `createdDT` تنها ستونی است که ترتیب ثبت را تضمین می‌کند.
* هیچ‌کدام از تست‌ها به «امروزِ واقعی» وابسته نیستند مگر از طریق `timezone.now()` در
لحظهٔ اجرا؛ برای همین در هر تاریخ دیگری هم باید سبز بمانند (تاریخ‌ها نسبی‌اند).
### ۱۱.۴ فایل‌های مرتبط
| فایل | نقش |
|---|---|
| `admin_panel/visit_policy.py` | تنها منبع حقیقت قرار/بازدید (`last_visit_map`، `appointment_sources_map`، `followups_on_date`، `overdue_followups`، `manual_holds_map`) |
| `admin_panel/views/actions.py` | ایجاد/ویرایش/حذف اقدام، `_apply_visit_hold`، لیست وظایف |
| `admin_panel/views/customers.py` | ویوی `entezar` و ساخت `appointment_rows` |
| `admin_panel/sms/scanner.py` | `scan_visit` (یادآوری دوره‌ای) |
| `admin_panel/sms/triggers.py` | `notify_visit_scheduled` (پیامک ثبت بازدید) |
| `admin_panel/templates/admin_panel/entezar.html` | برچسب‌های وضعیت و ستون‌های قرار |
| `admin_panel/templates/admin_panel/customerActionInc.html` | فرم و JS دو فیلد + تأیید «هماهنگی انجام شد» |
| `docs/visit_hold_entezar.md` | طراحیِ موردنظر (سند قبلی) — بخش‌هایی از آن در کد فعلی رعایت نشده (R2/R3) |
+73 -3
View File
@@ -43,13 +43,20 @@
>
> ۱. تاریخش از حالا جلوتر باشد،
> ۲. برای همان مشتری یک اقدام **هماهنگیِ فعال** (`waite` یا `finish`) وجود داشته باشد،
> ۳. **آخرین فعالیتِ مشتری همان هماهنگی باشد** — اگر بعد از آن، اقدام دیگری
> (به‌ویژه «بازدید») ثبت شده باشد، قرار منتفی است.
> ۳. **آخرین اقدامِ جایگزین‌کنندهٔ قرار، همان هماهنگی باشد** — اگر بعد از آن
> «بازدید» (`bazdid`) یا «نصب» (`nasb`) ثبت شده باشد، قرار منتفی است.
> اقدام‌های دیگر (وصول، تماس ورودی/خروجی، …) قرار را باطل **نمی‌کنند**.
شرط ۳ برای این است که «ثبت بازدید پس از هماهنگی» قرار را خودکار باطل کند.
بدون آن، قرار قدیمی تا تاریخش زنده می‌ماند و مشتری در فهرست «با قرار» می‌ماند،
هرچند بازدید جای هماهنگی را گرفته است.
> **اصلاح ۲۰۲۶-۱۰-۲۲:** قبلاً «هر اقدام غیرهماهنگی» قرار را باطل می‌کرد؛ نتیجه
> این بود که یک سند وصول یا یک تماس ثبت‌شده، قرارِ توافق‌شده را بی‌صدا از بین
> می‌برد و مشتری دوباره در فهرست تماس ظاهر می‌شد. حالا فقط `bazdid`/`nasb`
> (یعنی اقدام‌هایی که واقعاً جای قرار می‌نشینند) قرار را باطل می‌کنند
> (`visit_policy._SUPERSEDING_TYPES`).
نتیجهٔ این قاعده:
| رویداد | اثر روی قرار |
@@ -57,9 +64,16 @@
| حذف اقدام هماهنگی | قرار **خودکار بی‌اثر** می‌شود |
| لغو هماهنگی (`cancell`) | بی‌اثر می‌شود |
| ویرایش تاریخ هماهنگی | قرار با تاریخ جدید هم‌راستا می‌شود |
| **ثبت بازدید (یا هر اقدام دیگر) پس از هماهنگی** | قرار **خودکار بی‌اثر** می‌شود |
| **ثبت بازدید یا نصب پس از هماهنگی** | قرار **خودکار بی‌اثر** می‌شود |
| ثبت سایر اقدام‌ها (وصول، تماس…) | قرار دست‌نخورده می‌ماند |
| کهنه ماندن ستون | **هیچ اثر مخربی ندارد** |
> **تکمیل ۲۰۲۶-۱۰-۲۷:** «نصب» در فهرست‌های کار هم مثل «بازدید» عمل می‌کند:
> `nasb + waite` با تاریخ از امروز به بعد و `nasb + finish` از موعد به بعد، ردیف را
> از `?view=today` و `?view=today&missed=1` خارج می‌کند
> (`visit_policy._pending_appointment_ids` و `cycle_anchor_map`). این دو نوع همان
> `_CYCLE_RESET_TYPES` هستند که چرخهٔ ۹ ماهه و قرار دستی را هم بازنشانی می‌کنند.
به همین دلیل دیگر خطر «کش کهنه» وجود ندارد: منبع حقیقت همیشه جدول `Action` است.
---
@@ -319,6 +333,62 @@ visit_hold_until = max(مقدار فعلی، تاریخ جدید)
---
## ۶.۱ حذف یک اقدام (soft-delete)
دکمهٔ «حذف» فقط در صفحهٔ اقدام‌های مشتری (`/panel/customer/<id>/action`) نمایش
داده می‌شود و **هیچ رکوردی فیزیکی حذف نمی‌شود**؛ به‌جای آن:
| ستون | مقدار |
|---|---|
| `status` | `cancell` |
| `deleted_at` | زمان حذف |
| `deleted_by` | کاربر حذف‌کننده |
### چرا `cancell` و نه ستون `is_deleted`؟
همهٔ محاسبات (`last_visit_map`، `cycle_anchor_map`، `appointment_sources_map`،
`followups_on_date`، `overdue_followups`) از قبل فقط `waite`/`finish` را
می‌بینند؛ و `ActionList*` و `ActionCount` هم با `status='waite'` فیلتر می‌شوند.
پس همین یک مقدار، اقدام را از **همهٔ** خروجی‌ها خارج می‌کند — بدون Manager
سفارشی، بدون تغییر ایندکس پوشا و بدون دست‌زدن به ۳۱ کوئری.
### قاعدهٔ محصولی: اقدام دارای نظرسنجی حذف نمی‌شود
اگر `nazarDT` پر باشد (نظرسنجی ثبت‌شده)، حذف انجام **نمی‌شود**؛ چون هیچ
آرشیو/کپی‌ای وجود ندارد و گزارش رضایت‌سنجی برای همیشه تغییر می‌کرد. در UI
دکمه به حالت قفل‌شده با برچسب «نظرسنجی» درمی‌آید.
### پیامدهای جانبی (در همان تراکنش)
۱. `last_bazdid_dt` بازمحاسبه می‌شود (کش کهنه نمی‌ماند).
۲. قرار دستی اگر دیگر مرجعی نداشته باشد، خودکار بی‌اثر می‌شود.
۳. پیامک‌های **ارسال‌نشدهٔ** «بازدید ثبت شد» همان مشتری به `cancelled` می‌روند
(پیامک‌های ارسال‌شده دست نمی‌خورند).
### اثر روی مصرف‌کننده‌ها
| بخش | اثر |
|---|---|
| `/panel/entezar/` (همهٔ نماها) | اقدام کاملاً نادیده گرفته می‌شود |
| `/panel/ActionList/<u>/today` | از صف وظایف خارج می‌شود |
| `ActionCount` داشبورد | بلافاصله کم می‌شود |
| فهرست اقدام‌های مشتری | به‌طور پیش‌فرض پنهان (با `?status=cancell` قابل مشاهده برای حسابرسی) |
| `/panel/customer/<id>/nazar/` | پنهان |
| `/panel/ActionList/all` | پنهان |
| گزارش وصول (`finance.py`) | آخرین اقدام وصول دیگر آن را نمی‌بیند |
| داشبورد رضایت‌سنجی و گزارش روزانه | `deleted_at__isnull=True` |
| صف پیامک | `CANCELLED` در `DONE_STATUSES` ⇒ از صف بیرون می‌رود |
### امنیت
- فقط **POST** مجاز است؛ درخواست GET ریدایرکت می‌شود و چیزی را عوض نمی‌کند.
- `get_object_or_404(Action, id=..., customer=customer)` ⇒ حذف اقدام مشتری دیگر
ممکن نیست (۴۰۴).
- توکن CSRF در فرم اجباری است.
- تأیید دو‌مرحله‌ای پیش از حذف (نوع اقدام در متن تأیید نمایش داده می‌شود).
---
## ۷. سناریوهای تست‌شده
| # | سناریو | نتیجهٔ انتظار | وضعیت |
+64 -48
View File
@@ -1,6 +1,6 @@
{
"0": "views/sms.py",
"1": "run_all",
"1": "AutoSendTests",
"2": "django_db",
"3": "views/store.py",
"4": "ModelsPackageContractTests",
@@ -11,14 +11,14 @@
"9": "views/customers.py",
"10": "django_db_models_deletion",
"11": "PanelTestCase",
"12": "make_customer",
"12": "mark_primary",
"13": "EventTriggerTests",
"14": "views/finance.py",
"15": "django_contrib",
"15": "django_conf",
"16": "sms0098ws.py",
"17": "Admin Panel Customization",
"18": "User",
"19": "apply_timing",
"18": "views/actions.py",
"19": "timing_for",
"20": "InactiveCustomerHiddenTests",
"21": "normalize_mobile",
"22": "Login Template Forms",
@@ -26,12 +26,12 @@
"24": "factories.py",
"25": "Rip'n Fast. Fewer Tokens. Better Code.",
"26": "views/factors.py",
"27": "sms0098.py",
"27": "SMSResult",
"28": "make_user",
"29": "QueueReviewTests",
"30": "NoticeMechanismTests",
"31": "name_fix.py",
"32": "enqueue_sms",
"32": "scanner.py",
"33": "ZeroZeroNinetyEightWebServiceTests",
"34": "ScannerTests",
"35": "admin_panel/templates/admin_panel/baseHesabdari.html",
@@ -44,47 +44,47 @@
"42": "SMS Integration Docs",
"43": "enqueue.py",
"44": "django_shortcuts",
"45": "test_inventory.py",
"45": "stock_in",
"46": "admin_panel/templates/admin_panel/customerAction.html",
"47": "SmsReportTests",
"48": "write_tree",
"48": "generate_snapshot",
"49": "CustomerInfoCardUITests",
"50": "TestCase",
"50": "enqueue_sms",
"51": "Phone Cleanup Guide",
"52": "Customer",
"52": ".run_command",
"53": "project_tree.py",
"54": "Agent Operating Guidelines",
"55": "Command",
"56": "BehinSMSProvider",
"57": "activate_sms",
"57": "run_all",
"58": "Financial Data Objects",
"59": "Hybrid Migration Command",
"60": ".run_cmd",
"60": "PhoneNumber",
"61": "Resource Cleanup Handler",
"62": "SMS Timing Migration",
"63": "admin_panel/templates/admin_panel/bedehi.html",
"64": "customerIDFactorBackup.html",
"65": "bale/providers.py",
"66": "os",
"65": "VisitHoldTests",
"66": "reconciliation_tool.py",
"67": "Graphify Tooling Docs",
"68": "Command",
"69": "ZeroZeroNinetyEightLinkTests",
"70": "SMS Template Seeding",
"71": "admin_panel/entezar.html",
"72": "customerNewBackup.html",
"73": "django_conf",
"73": "SMSProvider",
"74": "0070_sms_types_seven.py",
"75": "Endpoint Smoke Tests",
"75": "ViewSmokeTests",
"76": "Timezone Migration History",
"77": "Command",
"78": "UrlContractTests",
"78": "TodayFollowupTests",
"79": "Brand Scrubbing Migration",
"80": "SMS Dedup Migration",
"81": "Installation Guide",
"82": "Project Honesty Contract",
"83": "Flow Control Migration",
"84": "Permission Label Migration",
"85": "PhoneNumber",
"85": "visit_policy.py",
"86": "Output Format Specs",
"87": "Performance Benchmarks",
"88": "SMS Integration Overview",
@@ -94,25 +94,25 @@
"92": "Infrastructure Services",
"93": "Agent Setup Guide",
"94": "First Use Guide",
"95": "ConsoleEncodingTests",
"96": "test_factors.py",
"95": "console.py",
"96": "test_sms.py",
"97": "تجمیع شماره‌های پیامک مشتریان",
"98": "Command",
"99": "Base Template",
"100": "test_sms.py",
"101": "Command",
"100": "test_customer_name_fix.py",
"101": "Customer",
"102": "WorkerTests",
"103": "manage.py",
"104": "افزودن پیشوند «آقای/خانم» به نام مشتریان",
"105": "Command",
"106": "QueueStatsTests",
"107": "signals.py",
"106": "make_customer",
"107": "admin_panel/apps.py",
"108": "SMS Domain Logic",
"109": "Customer Communication Partials",
"110": "SMS Template Management",
"111": "Documentation and Graphing",
"112": "Admin Migration Package",
"113": "recipients.py",
"113": "resolve_recipients",
"114": "Customer Action Templates",
"115": "Customer Feedback Components",
"116": "Customer Invoice Details",
@@ -152,7 +152,7 @@
"150": "Transfer Processing Endpoint",
"151": "Satisfaction Edit Endpoint",
"152": "Customer Deletion Endpoint",
"153": "HttpProviderRobustnessTests",
"153": "HTTPSMSProvider",
"154": "Django Permissions System",
"155": "Static Asset Management",
"156": "Data Cleanup Documentation",
@@ -163,10 +163,10 @@
"161": "Project Dependencies",
"162": "Test Suite Package",
"163": "Customer",
"164": "factory.py",
"164": "EntezarPageControlsTests",
"165": "0075_cheque_funding_before_due.py",
"166": "توقف و ادامه (Resume / Checkpoint)",
"167": "Command",
"167": "RecalcCommandTests",
"168": "0071_sms_settings_permission.py",
"169": "0056_auto_20260731_1824.py",
"170": "الگوریتم تصمیم (به همین ترتیب)",
@@ -174,36 +174,52 @@
"172": "0073_alter_action_actiontype_choices.py",
"173": "stop.sh",
"174": "0074_alter_action_status_choices.py",
"175": "remove_query_param",
"175": "at",
"176": "اجرای محلی (توسعه)",
"177": ".test_deleted_visit_reminder_rearms_next_period_only",
"177": "apply_timing",
"178": "اجرا روی سرور Production",
"179": "اجرای محلی (توسعه)",
"180": "ادامهٔ job پس از crash / قطع برق",
"181": "اجرا روی سرور Production",
"182": "wsgi.py",
"182": "os",
"183": "0005_auto_20220121_2054.py",
"184": "0009_remove_storeroom_user.py",
"184": "test_visit_policy.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",
"186": "ActionSoftDeleteTests",
"187": "last_visit_map",
"188": "CoordinationDoneConfirmTests",
"189": "BaseTemplateStructureTests",
"190": "0028_action_grade.py",
"191": "0032_article_paystatus.py",
"192": "0033_article_nahvepardakht.py",
"191": "validate_action_payload",
"192": "NextAppointmentTests",
"193": "0034_alter_article_sarresid.py",
"194": "0035_alter_article_sarresid.py",
"195": "0040_customer_laghv_bazdid.py",
"194": "django_contrib_auth_models",
"195": "entezar",
"196": "0041_auto_20240216_1607.py",
"197": "0048_alter_productinstore_options.py",
"198": "0053_storeroom_owners.py",
"199": "0055_action_nextnazardt.py",
"197": "Action",
"198": "بخش دوم — امکانات جدید رابط کاربری صفحهٔ انتظار",
"199": "بازمحاسبهٔ «آخرین بازدید» مشتریان",
"200": "0062_sms_link_provider.py",
"201": "0063_alter_smsqueue_status.py",
"202": "0068_auto_20261010_1305.py",
"201": "TestCase",
"202": "newFactor",
"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"
"205": "«قرار بعدی» بازدید و صفحهٔ انتظار — توضیح کامل عملکرد",
"206": "SMSDeliveryCommandTests",
"207": "ActionModalTemplateTests",
"208": "customer_balance",
"209": "VisitDecisionTests",
"210": "EntezarScannerConsistencyTests",
"211": "EntezarLayoutTests",
"212": "signals.py",
"213": "۴. فرم ثبت اقدام (UI)",
"214": "۱.۱ نمای «نیازمند پیگیری امروز» (`?view=today`) — **پیش‌فرض صفحه**",
"215": "cleanup_visit_dedup.py",
"216": "۶.۱ حذف یک اقدام (soft-delete)",
"217": "active_deferral",
"218": "index",
"219": "0025_action_actiontype.py",
"220": "0027_alter_action_nazar.py",
"221": "0029_alter_article_options.py",
"222": "0030_auto_20220414_1047.py"
}
+1 -1
View File
@@ -1 +1 @@
{"0": "1dfdd2cf317807f3", "1": "17cefe4501ef5349", "2": "b498606dcac72b73", "3": "bd6e67d4a5f28a70", "4": "28cdf3baa198be4d", "5": "fc9b619d52672e4e", "6": "50f8029c1ec04918", "7": "97d81e46229f8a49", "8": "313943d53fc2bd9f", "9": "627a6196750653eb", "10": "b61d115e59860e03", "11": "9e67a33b989286ec", "12": "e88164ad40862751", "13": "3b56f304a0c6c939", "14": "6dce744bd0131ce7", "15": "e41d0c7172d175f1", "16": "7d8afbd7fea7cf7b", "17": "9eec9953ee3ca67f", "18": "d947d20b351370eb", "19": "d26d5b0aeca2417c", "20": "a8997e767700eb26", "21": "80296933c90021fc", "22": "9701f1591d8fda6e", "23": "8f0f51ff611eb425", "24": "2fdec3396c6901a3", "25": "75866dc8db907971", "26": "bcc559e489b72153", "27": "9b6b55a8d8dcb4c4", "28": "35722e3c74f50804", "29": "e0f6e325bac28363", "30": "bf1e337abf82f763", "31": "e4282689f6251d51", "32": "e5bd8cd35998e36a", "33": "3746e81c1256465f", "34": "817f04c7b50c9b76", "35": "0928f3aa4e597117", "36": "4e9567703b727de7", "37": "d36df3c85181f806", "38": "bc695fcee2050806", "39": "7b572aa5bf8298b3", "40": "4cc746f3f10c71a6", "41": "59b1c6760dbaf1b9", "42": "bbd8a9066e48dada", "43": "b6e84eea788411f9", "44": "4b603c000aa79db9", "45": "8cda0f1879c40078", "46": "f38c1f1dc0ead50f", "47": "93e8d94e7c998882", "48": "9d46bbe4641a8543", "49": "df2dbdd967da3eaf", "50": "28183579704b35fd", "51": "afc6d855b9fc1b76", "52": "5e7f77f5e6dac074", "53": "e97ba10a1e6a2df3", "54": "34401beb9f60ebfd", "55": "ffa54e3d7035005e", "56": "330644cc56a0e06c", "57": "9e17a2dab584e04c", "58": "90146abe85d9419f", "59": "88f880da5a61f9dc", "60": "356d13eb0dd615ee", "61": "dbfbf8557361f5ea", "62": "6e90b41fbd82c265", "63": "6af26e59b0e8c354", "64": "8ae1cb991df55ba5", "65": "7b932cfef527d130", "66": "3de85332d944dae4", "67": "0f632fc57e9feeb0", "68": "92d7e4efe25770db", "69": "2937001282a8a8b6", "70": "df7ea8ce3fa52be3", "71": "fd47ef3914adf11e", "72": "9b3249f4a4e6fbfe", "73": "39cd7adefdc0d3ce", "74": "a534e43d03671a7c", "75": "5290f7a7558a9c3e", "76": "a19c211702401a5b", "77": "35aa0fc2a0626f70", "78": "996c5e480613f739", "79": "da683f642f8c61ac", "80": "40313433c93b7054", "81": "81af0a28b68936d9", "82": "25287261c3d1c44b", "83": "bda9be183e0f933e", "84": "63526d01888c4f22", "85": "ad4f4a79134b4346", "86": "694a38d63caf7217", "87": "f220eaa4fac26e3e", "88": "d78c8e21a45613e9", "89": "e5ca736ea1d10113", "90": "03f8896e9b60ca96", "91": "5b7e9b89423f4164", "92": "4bfa64a1cd8c6a47", "93": "057eae5a523d2004", "94": "5de74655cbc56e06", "95": "7767ac1c95366fb0", "96": "f36a8effec98632a", "97": "de6816239e70e6e8", "98": "7508d3e84c588617", "99": "15f6a40009e539bb", "100": "f4ecac15615b820e", "101": "4b7f81f0e7e4c044", "102": "7c85c7a24cf3cbed", "103": "3ec6277e63307488", "104": "756a405f723988ce", "105": "d55977ec0e3ae213", "106": "64fb409201f034b8", "107": "19d9362d6aa40786", "108": "b738c5aefe939cff", "109": "d2001d7d283134ac", "110": "6b8e6100893a6b6a", "111": "66ed76491a412a72", "112": "e67668b62ee6c62c", "113": "6bb5ee4a70642036", "114": "68aee39cd24db354", "115": "054401d27ffaa4b2", "116": "dace81c1995fb321", "117": "19e301aeace95bb2", "118": "d6fd0cec4634a0b0", "119": "0230d355a6dabb54", "120": "5cebbb5528321795", "121": "536fe28d24c361f5", "122": "b08bdc2712cb0f12", "123": "6ce8444f504022cc", "124": "8021f52559633383", "125": "d8e34a3b3eb43ee0", "126": "171c34c74a95481b", "127": "73e9eca6f2ca1c9b", "128": "dcc2230ae751c29a", "129": "2a617b932f5d92b2", "130": "d55cb638532fdfeb", "131": "06cae4389208269f", "132": "e9c0a7be255e9b26", "133": "455b4ca39e55ecb0", "134": "8105c55538c34081", "135": "74304670b54c018f", "136": "a27f876eabb044b2", "137": "73f74dd7a40776d6", "138": "29d433d164b99f89", "139": "3f44aa48aa05d597", "140": "ca8a7efba0a3e579", "141": "5cc770af9d184d77", "142": "2d31fa723128b164", "143": "be720fc0071f0c3d", "144": "9d814c2520329846", "145": "f97d1a5c3a066598", "146": "14f84a6e4cd311d6", "147": "3a592d27b1f4ccc9", "148": "0bf6f9d0aeab4fd8", "149": "8b491bb3ff0649dd", "150": "638812cee15d1b9b", "151": "3dd16d68928a479c", "152": "03a0ee43e18f7934", "153": "b672f7977ddfa52e", "154": "435331945d6aad17", "155": "968e1a135dc062b7", "156": "d1a987a548e55a76", "157": "a9bf69b3d550abef", "158": "b039e1c69c8fb7b9", "159": "60029bf0948fc02a", "160": "72dad91d66d471ce", "161": "353a9d09eaae47ea", "162": "43bbf35b96423d46", "163": "4faac8c35fa8b703", "164": "76d6488c0976adbc", "165": "652eabd3db52041c", "166": "bca6f6760a942d10", "167": "ce80aac5f4c439da", "168": "1abe2e7b0bcbeca9", "169": "cfbcf8cef78c5757", "170": "c9f25e20d3a7d5d8", "171": "7e818e8715c414bd", "172": "8d68a258e8ec738b", "173": "827420b645a55301", "174": "19e1beef5d40e7ac", "175": "d6451fb8a1e2d3ca", "176": "adac855b05ea55ee", "177": "95a53382a8e217be", "178": "c67bd11d1973d6ac", "179": "63c999649e523e73", "180": "56f7f3573c34f651", "181": "f8f5346df7d0a239", "182": "9c5614d4fee3efc0", "183": "6886ad7a5751faca", "184": "a1a1f3c1cdff72cf", "185": "32f7ccd6bd4068c7", "186": "1d89c741fb1f9b27", "187": "a800ecffc01fc9d5", "188": "f97753a176d20dae", "189"Line truncated
{"0": "0b739897e89c7b41", "1": "c845994d0f12d034", "2": "8c7679f720857bf1", "3": "9653c364b823290d", "4": "ea39680823c06133", "5": "1c7a6b6536eca5f4", "6": "6b788d0721a913f7", "7": "97d81e46229f8a49", "8": "313943d53fc2bd9f", "9": "da38f92f51e93c79", "10": "0153b3a4e04dd75a", "11": "41d17fe9560c47f6", "12": "d6d2ce2d7d595ba2", "13": "e799ebae9c509cb3", "14": "48d0d033c0d2964f", "15": "58e9c0768a2c889d", "16": "e4b58f9f9e36d64a", "17": "9eec9953ee3ca67f", "18": "1b6aefd19ce25cbe", "19": "a7ba24b2eb2edbbe", "20": "a8997e767700eb26", "21": "0020ce7d1908ad7a", "22": "9701f1591d8fda6e", "23": "7cd46ce4c8fb3ca4", "24": "2ffddab549a70d4b", "25": "90da597ad2a7919f", "26": "c2574e654e1d0322", "27": "994c5e2378052e94", "28": "9a4550f6e7e78e08", "29": "ff4922906a507758", "30": "e4f88099ea3ccfa4", "31": "4d4f780fb4e7f8ea", "32": "293ee6609d9f939d", "33": "0564a8d4ebd069cc", "34": "d92b5a81599860af", "35": "0928f3aa4e597117", "36": "b584145a0a6af86a", "37": "d36df3c85181f806", "38": "bc695fcee2050806", "39": "b5fb0ddc2a912cb0", "40": "ef568e093f9cb1b3", "41": "18750a5fb3366fa5", "42": "bbd8a9066e48dada", "43": "0822c3c7bdd37717", "44": "159d7e82897e35fc", "45": "a7e9b25451da94c4", "46": "f38c1f1dc0ead50f", "47": "9004ecf9c7067aa0", "48": "67d2b4075e544a13", "49": "df2dbdd967da3eaf", "50": "8a2fd7414c9ee9e6", "51": "afc6d855b9fc1b76", "52": "5b43fbf8fcf8716d", "53": "e97ba10a1e6a2df3", "54": "34401beb9f60ebfd", "55": "ffa54e3d7035005e", "56": "330644cc56a0e06c", "57": "e2cb064067d950bd", "58": "90146abe85d9419f", "59": "88f880da5a61f9dc", "60": "306fc8a49c104841", "61": "dbfbf8557361f5ea", "62": "6e90b41fbd82c265", "63": "6af26e59b0e8c354", "64": "8ae1cb991df55ba5", "65": "870e90323abfd449", "66": "8f5a6aa62c618b5f", "67": "0f632fc57e9feeb0", "68": "92d7e4efe25770db", "69": "edda1f9fc3ddc6f9", "70": "df7ea8ce3fa52be3", "71": "fd47ef3914adf11e", "72": "9b3249f4a4e6fbfe", "73": "12a25396ede4f1e9", "74": "a534e43d03671a7c", "75": "0d14f943dc48c339", "76": "a19c211702401a5b", "77": "35aa0fc2a0626f70", "78": "fed2ae936b50d86d", "79": "da683f642f8c61ac", "80": "40313433c93b7054", "81": "81af0a28b68936d9", "82": "25287261c3d1c44b", "83": "bda9be183e0f933e", "84": "63526d01888c4f22", "85": "0bf99e0a63147db6", "86": "694a38d63caf7217", "87": "f220eaa4fac26e3e", "88": "d78c8e21a45613e9", "89": "e5ca736ea1d10113", "90": "03f8896e9b60ca96", "91": "4d4f5e897228ae79", "92": "4bfa64a1cd8c6a47", "93": "057eae5a523d2004", "94": "5de74655cbc56e06", "95": "b4f836f96422aa82", "96": "8d8fa0ac595a0ab7", "97": "19b821f40f591523", "98": "7508d3e84c588617", "99": "15f6a40009e539bb", "100": "634340879ffb956e", "101": "e752861d6bd6141e", "102": "9bb0051cf12f369d", "103": "85ecb40ed558ed43", "104": "756a405f723988ce", "105": "d55977ec0e3ae213", "106": "5970abbcf384c31d", "107": "06b5a3c2baa12fdb", "108": "b738c5aefe939cff", "109": "d2001d7d283134ac", "110": "6b8e6100893a6b6a", "111": "66ed76491a412a72", "112": "e67668b62ee6c62c", "113": "38b87928585aa997", "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": "1b4ee66550701d94", "154": "435331945d6aad17", "155": "968e1a135dc062b7", "156": "d1a987a548e55a76", "157": "a9bf69b3d550abef", "158": "b039e1c69c8fb7b9", "159": "60029bf0948fc02a", "160": "72dad91d66d471ce", "161": "353a9d09eaae47ea", "162": "43bbf35b96423d46", "163": "4faac8c35fa8b703", "164": "4af634c3c14ff03b", "165": "652eabd3db52041c", "166": "bca6f6760a942d10", "167": "29cb511ef6daa0c1", "168": "1abe2e7b0bcbeca9", "169": "cfbcf8cef78c5757", "170": "c9f25e20d3a7d5d8", "171": "7e818e8715c414bd", "172": "8d68a258e8ec738b", "173": "827420b645a55301", "174": "19e1beef5d40e7ac", "175": "43e1b3409f1becc6", "176": "adac855b05ea55ee", "177": "237c0746be1bce16", "178": "c67bd11d1973d6ac", "179": "63c999649e523e73", "180": "56f7f3573c34f651", "181": "f8f5346df7d0a239", "182": "43b977f2b2e6524e", "183": "6886ad7a5751faca", "184": "bfad11052c54a1cc", "185": "32f7ccd6bd4068c7", "186": "3f88bb4be9a0294a", "187": "f3756e86a6b2bee6", "188": "5bb9e4f75573b97c", "189"Line truncated
+261 -173
View File
@@ -1,23 +1,23 @@
# Graph Report - khatibi (2026-10-20)
# Graph Report - khatibi (2026-10-22)
## Corpus Check
- 197 files · ~287,853 words
- 204 files · ~314,556 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
- 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)
- 2509 nodes · 4876 edges · 223 communities (104 shown, 119 thin omitted)
- Extraction: 95% EXTRACTED · 5% INFERRED · 0% AMBIGUOUS · INFERRED: 234 edges (avg confidence: 0.91)
- Token cost: 0 input · 0 output
## Graph Freshness
- Built from commit: `1fd27d04`
- Built from commit: `99c25a82`
- Run `git rev-parse HEAD` and compare to check if the graph is stale.
- Run `graphify update .` after code changes (no API cost).
## Community Hubs (Navigation)
- views/sms.py
- run_all
- AutoSendTests
- django_db
- views/store.py
- ModelsPackageContractTests
@@ -27,13 +27,14 @@
- views/customers.py
- django_db_models_deletion
- PanelTestCase
- make_customer
- mark_primary
- EventTriggerTests
- views/finance.py
- django_conf
- sms0098ws.py
- Admin Panel Customization
- User
- apply_timing
- views/actions.py
- timing_for
- InactiveCustomerHiddenTests
- normalize_mobile
- Login Template Forms
@@ -41,12 +42,12 @@
- factories.py
- Rip'n Fast. Fewer Tokens. Better Code.
- views/factors.py
- sms0098.py
- SMSResult
- make_user
- QueueReviewTests
- NoticeMechanismTests
- name_fix.py
- enqueue_sms
- scanner.py
- ZeroZeroNinetyEightWebServiceTests
- ScannerTests
- admin_panel/templates/admin_panel/baseHesabdari.html
@@ -59,47 +60,47 @@
- SMS Integration Docs
- enqueue.py
- django_shortcuts
- test_inventory.py
- stock_in
- admin_panel/templates/admin_panel/customerAction.html
- SmsReportTests
- write_tree
- generate_snapshot
- CustomerInfoCardUITests
- TestCase
- enqueue_sms
- Phone Cleanup Guide
- Customer
- .run_command
- project_tree.py
- Agent Operating Guidelines
- Command
- BehinSMSProvider
- activate_sms
- run_all
- Financial Data Objects
- Hybrid Migration Command
- .run_cmd
- PhoneNumber
- Resource Cleanup Handler
- SMS Timing Migration
- admin_panel/templates/admin_panel/bedehi.html
- customerIDFactorBackup.html
- bale/providers.py
- os
- VisitHoldTests
- reconciliation_tool.py
- Graphify Tooling Docs
- Command
- ZeroZeroNinetyEightLinkTests
- SMS Template Seeding
- admin_panel/entezar.html
- customerNewBackup.html
- django_conf
- SMSProvider
- 0070_sms_types_seven.py
- Endpoint Smoke Tests
- ViewSmokeTests
- Timezone Migration History
- Command
- UrlContractTests
- TodayFollowupTests
- Brand Scrubbing Migration
- SMS Dedup Migration
- Installation Guide
- Project Honesty Contract
- Flow Control Migration
- Permission Label Migration
- PhoneNumber
- visit_policy.py
- Output Format Specs
- Performance Benchmarks
- SMS Integration Overview
@@ -109,23 +110,23 @@
- Infrastructure Services
- Agent Setup Guide
- First Use Guide
- ConsoleEncodingTests
- test_factors.py
- console.py
- test_sms.py
- تجمیع شماره‌های پیامک مشتریان
- Command
- Base Template
- test_sms.py
- Command
- test_customer_name_fix.py
- Customer
- WorkerTests
- manage.py
- افزودن پیشوند «آقای/خانم» به نام مشتریان
- Command
- QueueStatsTests
- signals.py
- make_customer
- admin_panel/apps.py
- Customer Communication Partials
- SMS Template Management
- Documentation and Graphing
- recipients.py
- resolve_recipients
- Customer Action Templates
- Customer Feedback Components
- Customer Invoice Details
@@ -155,14 +156,14 @@
- Store Transfer Interface
- Store Directory Template
- Currency Formatting Utility
- HttpProviderRobustnessTests
- HTTPSMSProvider
- Data Cleanup Documentation
- Project Dependencies
- Customer
- factory.py
- EntezarPageControlsTests
- 0075_cheque_funding_before_due.py
- توقف و ادامه (Resume / Checkpoint)
- Command
- RecalcCommandTests
- 0071_sms_settings_permission.py
- 0056_auto_20260731_1824.py
- الگوریتم تصمیم (به همین ترتیب)
@@ -170,59 +171,78 @@
- 0073_alter_action_actiontype_choices.py
- stop.sh
- 0074_alter_action_status_choices.py
- remove_query_param
- at
- اجرای محلی (توسعه)
- .test_deleted_visit_reminder_rearms_next_period_only
- apply_timing
- اجرا روی سرور Production
- اجرای محلی (توسعه)
- ادامهٔ job پس از crash / قطع برق
- اجرا روی سرور Production
- os
- 0005_auto_20220121_2054.py
- 0009_remove_storeroom_user.py
- test_visit_policy.py
- 0014_auto_20220123_1348.py
- 0015_auto_20220124_0858.py
- 0017_factoriteam_garantyesh.py
- 0022_article_typepy.py
- 0026_auto_20220209_1924.py
- ActionSoftDeleteTests
- last_visit_map
- CoordinationDoneConfirmTests
- BaseTemplateStructureTests
- 0028_action_grade.py
- 0032_article_paystatus.py
- 0033_article_nahvepardakht.py
- validate_action_payload
- NextAppointmentTests
- 0034_alter_article_sarresid.py
- 0035_alter_article_sarresid.py
- 0040_customer_laghv_bazdid.py
- django_contrib_auth_models
- entezar
- 0041_auto_20240216_1607.py
- 0048_alter_productinstore_options.py
- 0053_storeroom_owners.py
- 0055_action_nextnazardt.py
- Action
- بخش دوم — امکانات جدید رابط کاربری صفحهٔ انتظار
- بازمحاسبهٔ «آخرین بازدید» مشتریان
- 0062_sms_link_provider.py
- 0063_alter_smsqueue_status.py
- 0068_auto_20261010_1305.py
- TestCase
- newFactor
- 0072_customernamefixjob.py
- 0077_alter_smstemplate_text_balance_help.py
- «قرار بعدی» بازدید و صفحهٔ انتظار — توضیح کامل عملکرد
- SMSDeliveryCommandTests
- ActionModalTemplateTests
- customer_balance
- VisitDecisionTests
- EntezarScannerConsistencyTests
- EntezarLayoutTests
- signals.py
- ۴. فرم ثبت اقدام (UI)
- ۱.۱ نمای «نیازمند پیگیری امروز» (`?view=today`) — **پیش‌فرض صفحه**
- cleanup_visit_dedup.py
- ۶.۱ حذف یک اقدام (soft-delete)
- active_deferral
- index
- 0025_action_actiontype.py
- 0027_alter_action_nazar.py
- 0029_alter_article_options.py
- 0030_auto_20220414_1047.py
## God Nodes (most connected - your core abstractions)
1. `make_customer()` - 89 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
1. `make_customer()` - 111 edges
2. `as_aware()` - 50 edges
3. `make_user()` - 42 edges
4. `at()` - 42 edges
5. `VisitHoldTests` - 41 edges
6. `TodayFollowupTests` - 38 edges
7. `Customer` - 37 edges
8. `PhoneNumber` - 30 edges
9. `make_visit()` - 29 edges
10. `EntezarPageControlsTests` - 28 edges
## Surprising Connections (you probably didn't know these)
- `نکته‌های مهم` --references--> `Action` [INFERRED]
recalc_last_bazdid.md → admin_panel/models/actions.py
- `مدل وضعیت job` --references--> `CustomerNameFixJob` [INFERRED]
fix_customer_names.md → admin_panel/models/system.py
- `چرا؟` --references--> `CustomerNameFixJob` [INFERRED]
fix_customer_names.md → admin_panel/models/system.py
- `سریع‌ترین مسیر روی Production (اگر همین یک دستور را می‌خواهی)` --references--> `CustomerNameFixJob` [INFERRED]
normalize_customer_sms_phones.md → admin_panel/models/system.py
- `تست‌ها` --references--> `resolve_recipients()` [INFERRED]
normalize_customer_sms_phones.md → admin_panel/sms/recipients.py
- `۷. سناریوهای تست‌شده` --references--> `next_appointment_map()` [INFERRED]
docs/visit_hold_entezar.md → admin_panel/visit_policy.py
- `ایمنی ویرایش رکوردهای قدیمی` --references--> `manual_holds_map()` [INFERRED]
docs/visit_hold_entezar.md → admin_panel/visit_policy.py
- `هشدار «پیگیری‌های عقب‌افتاده»` --references--> `overdue_followups()` [INFERRED]
docs/visit_hold_entezar.md → admin_panel/visit_policy.py
## Import Cycles
- None detected.
@@ -255,15 +275,15 @@
- **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 (207 total, 120 thin omitted)
## Communities (223 total, 119 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)
Nodes (19): _approve(), customer_sms_history(), _customer_url(), _notice(), parse_jalali_date(), parse_scan_since(), _remember_keys(), resolve_recipients_test() (+11 more)
### Community 2 - "django_db"
Cohesion: 0.05
Nodes (20): Migration, Migration, Migration, Migration, Migration, Migration, Migration, Migration (+12 more)
Cohesion: 0.04
Nodes (28): Migration, Migration, Migration, Migration, Migration, Migration, Migration, Migration (+20 more)
### Community 3 - "views/store.py"
Cohesion: 0.05
@@ -273,105 +293,101 @@ Nodes (32): Product, Meta, ProductCategory, ProductInStore, Meta, MovementType,
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.19
Nodes (4): 12. Supported languages and formats, Languages, SettingsPanelTests, TestMessageSettingsTests
### Community 7 - "models/sms.py"
Cohesion: 0.06
Nodes (15): CustomerSMSSetting, Meta, SMSDedup, Meta, SMSLog, Meta, SMSProviderConfig, Meta (+7 more)
### Community 9 - "views/customers.py"
Cohesion: 0.10
Nodes (18): normalize_primary_values(), customer_toggle_map(), notify_welcome(), customer(), customerDel(), customerEdit(), customerFactor(), customerFactors() (+10 more)
Nodes (15): customer_toggle_map(), notify_welcome(), customer(), customerDel(), customerEdit(), customerFactor(), customerFactors(), customerNew() (+7 more)
### Community 10 - "django_db_models_deletion"
Cohesion: 0.05
Nodes (19): Migration, Migration, Migration, Migration, Migration, Migration, Migration, Migration (+11 more)
Cohesion: 0.07
Nodes (13): Migration, Migration, Migration, Migration, Migration, Migration, Migration, Migration (+5 more)
### Community 11 - "PanelTestCase"
Cohesion: 0.15
Nodes (3): EmptyQueueTests, PanelTestCase, SentQueueProtectionTests
Cohesion: 0.18
Nodes (3): DeletedStaysDeletedTests, PanelTestCase, SentQueueProtectionTests
### Community 12 - "make_customer"
Cohesion: 0.10
Nodes (7): resolve_recipients(), تست‌ها, make_customer(), DeletedStaysDeletedTests, mark_primary(), PrimaryNumberPanelTests, RecipientTests
### Community 12 - "mark_primary"
Cohesion: 0.14
Nodes (3): mark_primary(), RecipientTests, WelcomeTriggerTests
### Community 14 - "views/finance.py"
Cohesion: 0.20
Nodes (18): Article, Meta, addSarresid(), bedehi(), bedehiKhadamat(), bedehiNasb(), financial_dashboard(), hesabdari() (+10 more)
Cohesion: 0.17
Nodes (20): Article, Meta, notify_payment_thanks(), customerHesabdariAdd(), addSarresid(), bedehi(), bedehiKhadamat(), bedehiNasb() (+12 more)
### Community 15 - "django_conf"
Cohesion: 0.08
Nodes (11): Migration, Migration, Migration, Migration, Migration, Migration, Migration, Migration (+3 more)
### Community 16 - "sms0098ws.py"
Cohesion: 0.11
Nodes (5): to_local(), classify_delivery(), DeliveryCode, describe_send_code(), _as_text()
Cohesion: 0.13
Nodes (5): classify_delivery(), DeliveryCode, describe_send_code(), _as_text(), ZeroZeroNinetyEightWebServiceProvider
### 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.07
Nodes (34): User, Action, ActionType, Status, VisitQueue, Factor, Meta, StoreRoom (+26 more)
### Community 18 - "views/actions.py"
Cohesion: 0.10
Nodes (25): User, notify_visit_scheduled(), _action_payload_from_request(), ActionList(), ActionListAll(), ActionListToday(), ActionListUser(), ActionListUserToday() (+17 more)
### Community 19 - "apply_timing"
### Community 19 - "timing_for"
Cohesion: 0.12
Nodes (7): apply_timing(), current_timing_value(), timing_for(), sms_template_edit(), sms_templates(), _templates_redirect(), TimingsTests
Nodes (7): current_timing_value(), timing_for(), _sample_context(), sms_template_edit(), sms_templates(), _templates_redirect(), TimingsTests
### Community 20 - "InactiveCustomerHiddenTests"
Cohesion: 0.12
Nodes (3): CustomerCrudTests, CustomerDeleteGuardTests, InactiveCustomerHiddenTests
### 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)
Cohesion: 0.09
Nodes (13): customer_phone_entries(), _iter_sources(), normalize_primary_values(), PhoneEntry, primary_phones(), save_primary_normalized(), stored_primary_normalized(), to_display() (+5 more)
### Community 22 - "Login Template Forms"
Cohesion: 0.11
Nodes (17): Django CSRF Token, Django Custom Tags, Django Static Files, Django URL: admin_panel:newStore, Django URL: admin_panel:operations, Django URL: admin_panel:p_login, Django URL: admin_panel:store, Form Field: password (+9 more)
### Community 23 - "cheque_inquiry.py"
Cohesion: 0.17
Cohesion: 0.19
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.31
Cohesion: 0.20
Nodes (5): ProductCategory, make_category(), make_product(), make_store(), _next()
### Community 25 - "Rip'n Fast. Fewer Tokens. Better Code."
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)
Nodes (19): 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, Languages, Licence (+11 more)
### Community 26 - "views/factors.py"
Cohesion: 0.12
Nodes (10): FactorIteam, notify_factor_survey(), notify_payment_thanks(), FactorDel(), newFactor(), newFactor2(), InventoryException, _parse_jalali_date() (+2 more)
Cohesion: 0.18
Nodes (6): notify_factor_survey(), newFactor2(), InventoryException, _parse_jalali_date(), _parse_timestamp(), _safe_int()
### Community 27 - "sms0098.py"
Cohesion: 0.20
Nodes (3): _configured_codes(), _response_code(), ZeroZeroNinetyEightSMSProvider
### Community 27 - "SMSResult"
Cohesion: 0.18
Nodes (5): SMSResult, _configured_codes(), _response_code(), ZeroZeroNinetyEightSMSProvider, _FailingProvider
### Community 30 - "NoticeMechanismTests"
Cohesion: 0.17
Nodes (3): dashBoxData(), resolve(), NoticeMechanismTests
### 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.12
Nodes (4): SMSDeliveryResult, ZeroZeroNinetyEightWebServiceProvider, SMSDeliveryCommandTests, ZeroZeroNinetyEightWebServiceTests
Nodes (8): build_name_index(), DatasetError, load_name_index(), NameIndex, normalize_name_key(), validate_dataset(), NameIndexTests, sha256_of()
### Community 32 - "scanner.py"
Cohesion: 0.12
Nodes (12): _aware(), _Budget, scan_cheque_funding(), scan_nesiye_due(), scan_visit(), _template(), cycle_anchor_dt(), cycle_anchor_map() (+4 more)
### Community 35 - "admin_panel/templates/admin_panel/baseHesabdari.html"
Cohesion: 0.13
Nodes (14): URL: admin_panel:ActionListUserToday, URL: admin_panel:bedehi, URL: admin_panel:entezar, URL: admin_panel:hesabdari, URL: admin_panel:hesabdari_residnashone, URL: admin_panel:index, URL: admin_panel:p_logout, URL: admin_panel:rezayat (+6 more)
### Community 36 - "Reference guide"
Cohesion: 0.14
Nodes (14): 10. Quality evaluation, 11. Agent integration, 13. Performance, 14. Known limits, 15. Documentation, 16. Improvement, 17. License, 1. Purpose and function (+6 more)
Cohesion: 0.13
Nodes (15): 10. Quality evaluation, 11. Agent integration, 12. Supported languages and formats, 13. Performance, 14. Known limits, 15. Documentation, 16. Improvement, 17. License (+7 more)
### Community 37 - "Factor Creation UI"
Cohesion: 0.15
@@ -382,27 +398,23 @@ 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.25
Nodes (4): AnnouncementRejected, place_announcement_call(), place_voip_call(), VoipCallResult
Cohesion: 0.13
Nodes (8): AnnouncementRejected, place_announcement_call(), BaleBotProvider, ConsoleBaleProvider, get_bale_provider(), NotificationResult, place_voip_call(), VoipCallResult
### Community 40 - "CustomerSmsHistoryTests"
Cohesion: 0.15
Nodes (3): customer_phones(), toJalaliDT(), CustomerSmsHistoryTests
Cohesion: 0.09
Nodes (5): customer_phones(), row_for(), toJalaliDT(), CustomerSmsHistoryTests, QueueStatsTests
### Community 42 - "SMS Integration Docs"
Cohesion: 0.15
Nodes (12): **جدول کدهای خطای لینک ارتباطی**, **جدول کدهای خطای وب‌سرویس و وضعیت‌های دلیوری**, **راهنمای استفاده از Web Service**, **راهنمای استفاده از لینک ارتباطی (URL GET)**, **راهنمای استفاده از لینک ارتباطی و وب‌سرویس سامانه پیامک (0098vip.com و 0098sms.com)**, **فهرست مطالب**, **مقدمات اتصال**, **نمونه کدهای پیاده‌سازی** (+4 more)
### Community 43 - "enqueue.py"
Cohesion: 0.13
Nodes (6): customer_balance(), format_amount(), format_jalali(), render_template(), _SafeDict, _sample_context()
Cohesion: 0.21
Nodes (4): format_amount(), format_jalali(), render_template(), _SafeDict
### Community 44 - "django_shortcuts"
Cohesion: 0.13
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 - "test_inventory.py"
Cohesion: 0.11
### Community 45 - "stock_in"
Cohesion: 0.12
Nodes (11): Factor, FactorIteam, Product, ProductInStore, StoreRoom, VisitQueue, current_stock(), stock_in() (+3 more)
### Community 46 - "admin_panel/templates/admin_panel/customerAction.html"
@@ -413,21 +425,21 @@ Nodes (9): admin_panel/templates/admin_panel/customerActionInc.html, admin_panel
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 48 - "generate_snapshot"
Cohesion: 0.32
Nodes (3): Counts, format_header(), generate_snapshot()
### Community 50 - "TestCase"
Cohesion: 0.20
Nodes (3): is_sms_enabled(), EnqueueIdempotencyTests, RuleTests
### Community 50 - "enqueue_sms"
Cohesion: 0.21
Nodes (4): enqueue_sms(), is_sms_enabled(), EnqueueIdempotencyTests, RuleTests
### Community 51 - "Phone Cleanup Guide"
Cohesion: 0.17
Nodes (11): اجرا روی سرور اصلی (production), اجرای دستور, اصلاح دستی شماره‌های قدیمی مشتریان, راه جایگزین: مهاجرت (migration), قاعدهٔ پاک‌سازی, مشاهدهٔ نتیجه, نکته‌ها, چرا؟ (+3 more)
### Community 52 - "Customer"
Cohesion: 0.10
Nodes (8): Customer, ApplyTests, CommandTestMixin, DatasetFixtureMixin, DryRunTests, ResilienceTests, flaky(), sha256_of()
### Community 52 - ".run_command"
Cohesion: 0.13
Nodes (5): ApplyTests, CommandTestMixin, DryRunTests, ResilienceTests, flaky()
### Community 53 - "project_tree.py"
Cohesion: 0.23
@@ -437,13 +449,17 @@ 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 57 - "run_all"
Cohesion: 0.25
Nodes (3): run_all(), activate_sms(), ColdStartTests
### 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.10
Nodes (6): ApplyTests, BatchAndCheckpointTests, flaky(), CommandTestCase, DryRunTests, make_phone()
### Community 60 - "PhoneNumber"
Cohesion: 0.11
Nodes (8): PhoneNumber, BatchAndCheckpointTests, flaky(), CommandTestCase, DryRunTests, IntegrationAndEfficiencyTests, make_phone(), OverflowTests
### Community 62 - "SMS Timing Migration"
Cohesion: 0.22
@@ -457,13 +473,9 @@ Nodes (8): Articl Model, Customer Model, articls, toJalali, admin_panel:bedehi,
Cohesion: 0.20
Nodes (7): Client-side script in customerIDFactorBackup.html, URL: admin_panel:customerEdit, Factor Data Model, Product Data Model, Product Category Data Model, User Data Model, URL: /panel/productMojodi/
### Community 65 - "bale/providers.py"
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 66 - "reconciliation_tool.py"
Cohesion: 0.19
Nodes (4): remove_query_param(), check_financial_discrepancy(), run_360_audit(), run_audit()
### Community 67 - "Graphify Tooling Docs"
Cohesion: 0.22
@@ -477,9 +489,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 - "django_conf"
### Community 73 - "SMSProvider"
Cohesion: 0.13
Nodes (7): SMSProvider, SMSResult, webotp_message(), console_write(), ConsoleSMSProvider, HTTPSMSProvider, _FailingProvider
Nodes (4): SMSProvider, get_provider_class(), get_sms_provider(), reset_sms_provider()
### Community 76 - "Timezone Migration History"
Cohesion: 0.29
@@ -493,9 +505,9 @@ 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 85 - "visit_policy.py"
Cohesion: 0.08
Nodes (20): _action_appointments(), ActionDeleteError, appointment_sources_map(), cancel_pending_sms_for_action(), coordination_holds_map(), effective_visit_dt(), followups_on_date(), _future_actions() (+12 more)
### Community 86 - "Output Format Specs"
Cohesion: 0.50
@@ -518,8 +530,8 @@ Cohesion: 0.67
Nodes (3): createCS click handler, openRow click handler, Customer Modals UI
### Community 91 - "decide_prefix"
Cohesion: 0.18
Nodes (4): decide_prefix(), Decision, NameIndex, DecidePrefixTests
Cohesion: 0.14
Nodes (4): decide_prefix(), Decision, DatasetFixtureMixin, DecidePrefixTests
### Community 92 - "Infrastructure Services"
Cohesion: 0.67
@@ -533,46 +545,58 @@ 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 - "console.py"
Cohesion: 0.15
Nodes (4): webotp_message(), console_write(), ConsoleSMSProvider, ConsoleEncodingTests
### Community 97 - "تجمیع شماره‌های پیامک مشتریان"
Cohesion: 0.14
Nodes (13): الگوریتم (برای هر مشتری), بازگردانی (اگر نتیجه را نخواستی), بررسی صحت داده (SQL), تجمیع شماره‌های پیامک مشتریان, تعریف کلیدهای گزارش, خطاها و اقدام درست, عملکرد و بار روی دیتابیس, نمونه‌ها (+5 more)
Cohesion: 0.12
Nodes (15): الگوریتم (برای هر مشتری), بازگردانی (اگر نتیجه را نخواستی), بررسی صحت داده (SQL), تجمیع شماره‌های پیامک مشتریان, تست‌ها, تعریف کلیدهای گزارش, خطاها و اقدام درست, عملکرد و بار روی دیتابیس (+7 more)
### Community 99 - "Base Template"
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_sms.py"
Cohesion: 0.10
### Community 100 - "test_customer_name_fix.py"
Cohesion: 0.11
Nodes (3): digit_string(), is_landline(), PureHelperTests
### Community 101 - "Customer"
Cohesion: 0.23
Nodes (3): Customer, Command, ActionDeleteUITests
### Community 103 - "manage.py"
Cohesion: 0.22
Cohesion: 0.24
Nodes (5): entrypoint.sh script, main(), run.sh script, start_service(), usage()
### Community 104 - "افزودن پیشوند «آقای/خانم» به نام مشتریان"
Cohesion: 0.15
Nodes (12): SQLite، قفل دیتابیس و بار روی سایت, افزودن پیشوند «آقای/خانم» به نام مشتریان, بررسی صحت داده (SQL، پیش از/پس از اجرا), بکاپ/بازیابی خودکار نیست, بکاپ پیش از اجرای واقعی, تست‌ها, تعریف کلیدهای آماری, خطاهای رایج و اقدام درست (+4 more)
### Community 107 - "signals.py"
Cohesion: 0.12
Nodes (4): AdminConfig, AdminPanelConfig, make_std_streams_unicode_safe(), configure_sqlite_connection()
### Community 106 - "make_customer"
Cohesion: 0.09
Nodes (4): make_customer(), EmptyQueueTests, ApplyTests, PrimaryNumberPanelTests
### Community 113 - "recipients.py"
Cohesion: 0.33
Nodes (3): primary_phones(), primary_phones(), ⚠️ هشدار معنای داده (مهم‌ترین نکتهٔ این سند)
### Community 107 - "admin_panel/apps.py"
Cohesion: 0.18
Nodes (3): AdminConfig, AdminPanelConfig, make_std_streams_unicode_safe()
### Community 113 - "resolve_recipients"
Cohesion: 0.16
Nodes (5): resolved_recipients(), primary_phones(), resolve_recipients(), قالبِ ذخیره‌شده: `+989xxxxxxxxx`, قواعد اعتبارسنجی و قالب شماره
### Community 163 - "Customer"
Cohesion: 0.29
Nodes (3): Customer, Meta, PhoneNumber
### Community 164 - "factory.py"
Cohesion: 0.29
Nodes (3): get_provider_class(), get_sms_provider(), reset_sms_provider()
### Community 166 - "توقف و ادامه (Resume / Checkpoint)"
Cohesion: 0.40
Nodes (5): اتمیک بودن, توقف و ادامه (Resume / Checkpoint), سلسله‌مراتب تصمیم نقطهٔ شروع, فایل وضعیت, گزینه‌های ادامه
### Community 167 - "RecalcCommandTests"
Cohesion: 0.10
Nodes (5): Command, action_snapshot(), plan_command(), RecalcCommandTests, run_command()
### Community 170 - "الگوریتم تصمیم (به همین ترتیب)"
Cohesion: 0.50
Nodes (4): الگوریتم تصمیم (به همین ترتیب), برخورد با نام‌های تکراری و متناقض, نرمال‌سازی (فقط برای کلید جست‌وجو), نمونه‌های رفتاری
@@ -585,6 +609,10 @@ Nodes (4): اگر وسط کار قطع شد, بررسی نتیجه, سریع‌
Cohesion: 0.73
Nodes (5): is_our_process(), say(), stop.sh script, stop_pid(), windows_cleanup()
### Community 175 - "at"
Cohesion: 0.17
Nodes (6): last_visit_dt(), at(), EffectiveVisitDateTests, HamahangiIsolationTests, make_visit(), RefreshLastBazdidTests
### Community 176 - "اجرای محلی (توسعه)"
Cohesion: 0.50
Nodes (4): اجرای محلی (توسعه), ۱) شبیه‌سازی — همیشه قدم اول, ۲) تنظیم سرعت batch, ۳) اجرای واقعی
@@ -605,30 +633,90 @@ Nodes (3): ادامهٔ job پس از crash / قطع برق, دستورهای د
Cohesion: 0.67
Nodes (3): اجرا روی سرور Production, ترتیب دقیق, نکته‌ها برای Production
### Community 182 - "os"
Cohesion: 0.18
Nodes (3): normalized_path(), write_tree(), visit()
### Community 184 - "test_visit_policy.py"
Cohesion: 0.16
Nodes (6): as_aware(), visit_cycle_index(), due_date(), visit_due_date(), NextAppointmentEquivalenceTests, VisitCadenceTests
### Community 187 - "last_visit_map"
Cohesion: 0.13
Nodes (5): _bucket(), last_visit_map(), _last_visit_map_python(), _visit_rows(), LastVisitMapEquivalenceTests
### Community 194 - "django_contrib_auth_models"
Cohesion: 0.15
Nodes (5): VisitQueue, StoreRoom, Meta, StoreRoomOwner, UserProfile
### Community 195 - "entezar"
Cohesion: 0.19
Nodes (9): entezar(), _apply_search(), _followup_ids(), _followup_link(), _qs(), _sort_value(), _source_date(), visit_period_days() (+1 more)
### Community 197 - "Action"
Cohesion: 0.17
Nodes (9): Action, ActionType, Meta, Status, رفتار دادهٔ موجود, قاعدهٔ طلایی: ستون، «مرجع» نیست, ۲. راه‌حل: ستون «قرار بعدی», ۴. مرتب‌سازی (ستون‌ها و دراپ‌داون) (+1 more)
### Community 198 - "بخش دوم — امکانات جدید رابط کاربری صفحهٔ انتظار"
Cohesion: 0.17
Nodes (11): بخش دوم — امکانات جدید رابط کاربری صفحهٔ انتظار, تفکیک نوع قرار — در دراپ‌داون، نه روی کارت, ۱. نوار آمار (بالای صفحه), ۱۰. پارامترهای URL, ۲. فیلترها (نوار زیر کارت‌ها), ۳. تفکیک مدت تأخیر (کلیک‌پذیر), ۵. جست‌وجو, ۵. چیدمان ستون‌ها (بازطراحی) (+3 more)
### Community 199 - "بازمحاسبهٔ «آخرین بازدید» مشتریان"
Cohesion: 0.18
Nodes (10): اجرا روی production, بازمحاسبهٔ «آخرین بازدید» مشتریان, بررسی صحت داده (SQL، پیش از اجرا), دستور, دقیقاً چه می‌کند؟, نمونهٔ خروجی, نکته‌های مهم, گزینه‌ها (+2 more)
### Community 202 - "newFactor"
Cohesion: 0.22
Nodes (5): Factor, Meta, FactorIteam, FactorDel(), newFactor()
### Community 205 - "«قرار بعدی» بازدید و صفحهٔ انتظار — توضیح کامل عملکرد"
Cohesion: 0.20
Nodes (9): ستون‌های جدول, شمارندهٔ داشبورد, فیلترها (قابل بوکمارک و ترکیب با جست‌وجو), «قرار بعدی» بازدید و صفحهٔ انتظار — توضیح کامل عملکرد, ۱. صورت مسئله, ۳. سه منبع «قرار بعدی», ۵. صفحهٔ انتظار: چهار گروه و فیلترها, ۷. سناریوهای تست‌شده (+1 more)
### Community 210 - "EntezarScannerConsistencyTests"
Cohesion: 0.29
Nodes (3): active_deferrals(), EntezarScannerConsistencyTests, is_stale()
### Community 213 - "۴. فرم ثبت اقدام (UI)"
Cohesion: 0.29
Nodes (7): ایمنی ویرایش رکوردهای قدیمی, بارگذاری مقدار ذخیره‌شده در حالت ویرایش, قاعدهٔ «تمدید مجاز، کوتاه‌کردن ممنوع» (max), قواعد اعتبارسنجی (سرور), ۴. فرم ثبت اقدام (UI), ۴.۲ هشدار زمان‌بندی و پیش‌فرض «انجام شده» (فقط هماهنگی), ۴.۳ راهنمای بازشونده (فقط «هماهنگی»)
### Community 214 - "۱.۱ نمای «نیازمند پیگیری امروز» (`?view=today`) — **پیش‌فرض صفحه**"
Cohesion: 0.29
Nodes (7): این فهرست از کجا می‌آید؟, تفاوت با فهرست «عقب‌افتاده», تقویم شمسی (گذشته و آینده), ستون «تأخیر» در این نما نمایش داده نمی‌شود, قاعدهٔ قطعی: «بازدید» اینجا نمی‌آید, هشدار «پیگیری‌های عقب‌افتاده», ۱.۱ نمای «نیازمند پیگیری امروز» (`?view=today`) — **پیش‌فرض صفحه**
### Community 216 - "۶.۱ حذف یک اقدام (soft-delete)"
Cohesion: 0.40
Nodes (5): اثر روی مصرف‌کننده‌ها, امنیت, قاعدهٔ محصولی: اقدام دارای نظرسنجی حذف نمی‌شود, پیامدهای جانبی (در همان تراکنش), ۶.۱ حذف یک اقدام (soft-delete)
### Community 218 - "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
## 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; 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.
These have ≤1 connection - possible missing edges or undocumented components. (Counts symbols only; 1048 node(s) total have ≤1 connection when file, concept and rationale nodes are included.)
- **119 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`, `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._
- **Why does `make_customer()` connect `make_customer` to `AutoSendTests`, `PhoneCleanupTests`, `PanelTestCase`, `mark_primary`, `EventTriggerTests`, `InactiveCustomerHiddenTests`, `factories.py`, `make_user`, `QueueReviewTests`, `NoticeMechanismTests`, `ScannerTests`, `EntezarPageControlsTests`, `CustomerSmsHistoryTests`, `stock_in`, `at`, `CustomerInfoCardUITests`, `enqueue_sms`, `apply_timing`, `test_visit_policy.py`, `run_all`, `ActionSoftDeleteTests`, `last_visit_map`, `PhoneNumber`, `CoordinationDoneConfirmTests`, `NextAppointmentTests`, `VisitHoldTests`, `TestCase`, `TodayFollowupTests`, `ActionModalTemplateTests`, `VisitDecisionTests`, `EntezarScannerConsistencyTests`, `EntezarLayoutTests`, `decide_prefix`, `console.py`, `test_sms.py`, `test_customer_name_fix.py`, `Customer`, `WorkerTests`, `resolve_recipients`?**
_High betweenness centrality (0.105) - 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.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._
_Cohesion score 0.11711711711711711 - nodes in this community are weakly interconnected._
- **Why does `Customer` connect `Customer` to `AutoSendTests`, `PhoneCleanupTests`, `mark_primary`, `InactiveCustomerHiddenTests`, `ScannerTests`, `EntezarPageControlsTests`, `RecalcCommandTests`, `stock_in`, `at`, `.run_command`, `Command`, `test_visit_policy.py`, `ActionSoftDeleteTests`, `PhoneNumber`, `NextAppointmentTests`, `VisitHoldTests`, `reconciliation_tool.py`, `Command`, `TodayFollowupTests`, `VisitDecisionTests`, `EntezarScannerConsistencyTests`, `EntezarLayoutTests`, `make_customer`?**
_High betweenness centrality (0.040) - this node is a cross-community bridge._
- **Should `django_db` be split into smaller, more focused modules?**
_Cohesion score 0.03508771929824561 - nodes in this community are weakly interconnected._
### Low-confidence Hints
_AMBIGUOUS edges — the extractor was unsure. Verify before acting on these._
+4 -4
View File
@@ -63,12 +63,12 @@
</div>
<div id="legend"></div>
</div>
<div id="stats">1991 nodes &middot; 3632 edges &middot; 207 communities</div>
<div id="stats">2509 nodes &middot; 4876 edges &middot; 223 communities</div>
</div>
<script>
const RAW_NODES = [{"id": "tests_test_sms_autosendtests", "label": "AutoSendTests", "color": {"background": "#F28E2B", "border": "#F28E2B", "highlight": {"background": "#ffffff", "border": "#F28E2B"}}, "size": 13.7, "font": {"size": 0, "color": "#ffffff"}, "title": "AutoSendTests", "community": 1, "community_name": "run_all", "source_file": "tests/test_sms.py", "file_type": "code", "degree": 12}, {"id": "admin_panel_migrations_0001_initial_migration", "label": "Migration", "color": {"background": "#4E79A7", "border": "#4E79A7", "highlight": {"background": "#ffffff", "border": "#4E79A7"}}, "size": 10.3, "font": {"size": 0, "color": "#ffffff"}, "title": "Migration", "community": 10, "community_name": "django_db_models_deletion", "source_file": "admin_panel/migrations/0001_initial.py", "file_type": "code", "degree": 1}, {"id": "admin_panel_migrations_0007_productinstore_factor_migration", "label": "Migration", "color": {"background": "#4E79A7", "border": "#4E79A7", "highlight": {"background": "#ffffff", "border": "#4E79A7"}}, "size": 10.3, "font": {"size": 0, "color": "#ffffff"}, "title": "Migration", "community": 10, "community_name": "django_db_models_deletion", "source_file": "admin_panel/migrations/0007_productinstore_factor.py", "file_type": "code", "degree": 1}, {"id": "admin_panel_migrations_0008_storeroom_user_migration", "label": "Migration", "color": {"background": "#4E79A7", "border": "#4E79A7", "highlight": {"background": "#ffffff", "border": "#4E79A7"}}, "size": 10.3, "font": {"size": 0, "color": "#ffffff"}, "title": "Migration", "community": 10, "community_name": "django_db_models_deletion", "source_file": "admin_panel/migrations/0008_storeroom_user.py", "file_type": "code", "degree": 1}, {"id": "admin_panel_migrations_0011_storeroom_user_migration", "label": "Migration", "color": {"background": "#4E79A7", "border": "#4E79A7", "highlight": {"background": "#ffffff", "border": "#4E79A7"}}, "size": 10.3, "font": {"size": 0, "color": "#ffffff"}, "title": "Migration", "community": 10, "community_name": "django_db_models_deletion", "source_file": "admin_panel/migrations/0011_storeroom_user.py", "file_type": "code", "degree": 1}, {"id": "admin_panel_migrations_0012_visitqueue_user_migration", "label": "Migration", "color": {"background": "#4E79A7", "border": "#4E79A7", "highlight": {"background": "#ffffff", "border": "#4E79A7"}}, "size": 10.3, "font": {"size": 0, "color": "#ffffff"}, "title": "Migration", "community": 10, "community_name": "django_db_models_deletion", "source_file": "admin_panel/migrations/0012_visitqueue_user.py", "file_type": "code", "degree": 1}, {"id": "admin_panel_migrations_0016_factor_user_migration", "label": "Migration", "color": {"background": "#4E79A7", "border": "#4E79A7", "highlight": {"background": "#ffffff", "border": "#4E79A7"}}, "size": 10.3, "font": {"size": 0, "color": "#ffffff"}, "title": "Migration", "community": 10, "community_name": "django_db_models_deletion", "source_file": "admin_panel/migrations/0016_factor_user.py", "file_type": "code", "degree": 1}, {"id": "admin_panel_migrations_0018_alter_factoriteam_product_migration", "label": "Migration", "color": {"background": "#4E79A7", "border": "#4E79A7", "highlight": {"background": "#ffffff", "border": "#4E79A7"}}, "size": 10.3, "font": {"size": 0, "color": "#ffffff"}, "title": "Migration", "community": 10, "community_name": "django_db_models_deletion", "source_file": "admin_panel/migrations/0018_alter_factoriteam_product.py", "file_type": "code", "degree": 1}, {"id": "admin_panel_migrations_0019_article_articletype_migration", "label": "Migration", "color": {"background": "#4E79A7", "border": "#4E79A7", "highlight": {"background": "#ffffff", "border": "#4E79A7"}}, "size": 10.3, "font": {"size": 0, "color": "#ffffff"}, "title": "Migration", "community": 10, "community_name": "django_db_models_deletion", "source_file": "admin_panel/migrations/0019_article_articletype.py", "file_type": "code", "degree": 1}, {"id": "admin_panel_migrations_0024_action_migration", "label": "Migration", "color": {"background": "#4E79A7", "border": "#4E79A7", "highlight": {"background": "#ffffff", "border": "#4E79A7"}}, "size": 10.3, "font": {"size": 0, "color": "#ffffff"}, "title": "Migration", "community": 10, "community_name": "django_db_models_deletion", "source_file": "admin_panel/migrations/0024_action.py", "file_type": "code", "degree": 1}, {"id": "admin_panel_migrations_0036_sarresid_migration", "label": "Migration", "color": {"background": "#4E79A7", "border": "#4E79A7", "highlight": {"background": "#ffffff", "border": "#4E79A7"}}, "size": 10.3, "font": {"size": 0, "color": "#ffffff"}, "title": "Migration", "community": 10, "community_name": "django_db_models_deletion", "source_file": "admin_panel/migrations/0036_sarresid.py", "file_type": "code", "degree": 1}, {"id": "admin_panel_migrations_0038_auto_20240213_1735_migration", "label": "Migration", "color": {"background": "#4E79A7", "border": "#4E79A7", "highlight": {"backgrounLine truncated
const RAW_EDGES = [{"from": "tests_test_sms", "to": "tests_test_sms_autosendtests", "label": "contains", "title": "contains [EXTRACTED]", "dashes": false, "width": 2, "color": {"opacity": 0.7}, "confidence": "EXTRACTED"}, {"from": "tests_test_sms_autosendtests", "to": "tests_test_sms_paneltestcase", "label": "inherits", "title": "inherits [EXTRACTED]", "dashes": false, "width": 2, "color": {"opacity": 0.7}, "confidence": "EXTRACTED"}, {"from": "tests_test_sms_autosendtests", "to": "tests_test_sms_autosendtests_overdue_visit_customer", "label": "method", "title": "method [EXTRACTED]", "dashes": false, "width": 2, "color": {"opacity": 0.7}, "confidence": "EXTRACTED"}, {"from": "tests_test_sms_autosendtests", "to": "tests_test_sms_autosendtests_test_default_is_pending_so_manager_must_approve", "label": "method", "title": "method [EXTRACTED]", "dashes": false, "width": 2, "color": {"opacity": 0.7}, "confidence": "EXTRACTED"}, {"from": "tests_test_sms_autosendtests", "to": "tests_test_sms_autosendtests_test_auto_send_enqueues_approved_without_manager", "label": "method", "title": "method [EXTRACTED]", "dashes": false, "width": 2, "color": {"opacity": 0.7}, "confidence": "EXTRACTED"}, {"from": "tests_test_sms_autosendtests", "to": "tests_test_sms_autosendtests_test_auto_send_does_not_bypass_opt_out_or_disabled", "label": "method", "title": "method [EXTRACTED]", "dashes": false, "width": 2, "color": {"opacity": 0.7}, "confidence": "EXTRACTED"}, {"from": "tests_test_sms_autosendtests", "to": "tests_test_sms_autosendtests_test_welcome_auto_send_skips_approval_on_customer_creation", "label": "method", "title": "method [EXTRACTED]", "dashes": false, "width": 2, "color": {"opacity": 0.7}, "confidence": "EXTRACTED"}, {"from": "tests_test_sms_autosendtests", "to": "tests_test_sms_autosendtests_test_template_edit_persists_auto_send", "label": "method", "title": "method [EXTRACTED]", "dashes": false, "width": 2, "color": {"opacity": 0.7}, "confidence": "EXTRACTED"}, {"from": "tests_test_sms_autosendtests", "to": "tests_test_sms_autosendtests_test_templates_page_shows_auto_send_switch", "label": "method", "title": "method [EXTRACTED]", "dashes": false, "width": 2, "color": {"opacity": 0.7}, "confidence": "EXTRACTED"}, {"from": "tests_test_sms_rationale_1183", "to": "tests_test_sms_autosendtests", "label": "rationale_for", "title": "rationale_for [EXTRACTED]", "dashes": false, "width": 2, "color": {"opacity": 0.7}, "confidence": "EXTRACTED"}, {"from": "tests_test_sms_autosendtests", "to": "admin_models_phonenumber", "label": "uses", "title": "uses [INFERRED]", "dashes": true, "width": 1, "color": {"opacity": 0.35}, "confidence": "INFERRED"}, {"from": "tests_test_sms_autosendtests", "to": "admin_models_customer", "label": "uses", "title": "uses [INFERRED]", "dashes": true, "width": 1, "color": {"opacity": 0.35}, "confidence": "INFERRED"}, {"from": "admin_panel_migrations_0001_initial", "to": "admin_panel_migrations_0001_initial_migration", "label": "contains", "title": "contains [EXTRACTED]", "dashes": false, "width": 2, "color": {"opacity": 0.7}, "confidence": "EXTRACTED"}, {"from": "admin_panel_migrations_0007_productinstore_factor", "to": "admin_panel_migrations_0007_productinstore_factor_migration", "label": "contains", "title": "contains [EXTRACTED]", "dashes": false, "width": 2, "color": {"opacity": 0.7}, "confidence": "EXTRACTED"}, {"from": "admin_panel_migrations_0008_storeroom_user", "to": "admin_panel_migrations_0008_storeroom_user_migration", "label": "contains", "title": "contains [EXTRACTED]", "dashes": false, "width": 2, "color": {"opacity": 0.7}, "confidence": "EXTRACTED"}, {"from": "admin_panel_migrations_0011_storeroom_user", "to": "admin_panel_migrations_0011_storeroom_user_migration", "label": "contains", "title": "contains [EXTRACTED]", "dashes": false, "width": 2, "color": {"opacity": 0.7}, "confidence": "EXTRACTED"}, {"from": "admin_panel_migrations_0012_visitqueue_user", "to": "admin_panel_migrations_0012_visitqueue_user_migration", "label": "contains", "title": "contains [EXTRACTED]", "dashes": false, "width": 2, "color": {"opacity": 0.7}, "confidence": "EXTRACTED"}, {"from": "admin_panel_migrations_0016_factor_user", "to": "admin_panel_migrations_0016_factor_user_migration", "label": "contains", "title": "contains [EXTRACTED]", "dashes": false, "width": 2, "color": {"opacity": 0.7}, "confidence": "EXTRACTED"}, {"from": "admin_panel_migrations_0018_alter_factoriteam_product", "to": "admin_panel_migrations_0018_alter_factoriteam_product_migration", "label": "contains", "title": "contains [EXTRACTED]", "dashes": false, "width": 2, "color": {"opacity": 0.7}, "confidence": "EXTRACTED"}, {"from": "admin_panel_migrations_0019_article_articletype", "to": "admin_panel_migrations_0019_article_articletype_migration", "label": "contains", "title": "contains [EXTRACTED]", "dashes": false, "width": 2, "color": {"opacity": 0.7}, "confidence": "EXTRACTED"}, {"from": "admin_panel_migrations_0024_action", "to": "adminLine truncated
const LEGEND = [{"cid": 0, "color": "#4E79A7", "label": "views/sms.py", "count": 36}, {"cid": 1, "color": "#F28E2B", "label": "run_all", "count": 15}, {"cid": 2, "color": "#E15759", "label": "django_db", "count": 41}, {"cid": 3, "color": "#76B7B2", "label": "views/store.py", "count": 73}, {"cid": 4, "color": "#59A14F", "label": "ModelsPackageContractTests", "count": 8}, {"cid": 5, "color": "#EDC948", "label": "PhoneCleanupTests", "count": 31}, {"cid": 6, "color": "#B07AA1", "label": ".load", "count": 13}, {"cid": 7, "color": "#FF9DA7", "label": "models/sms.py", "count": 32}, {"cid": 8, "color": "#9C755F", "label": "django_db_models", "count": 27}, {"cid": 9, "color": "#BAB0AC", "label": "views/customers.py", "count": 32}, {"cid": 10, "color": "#4E79A7", "label": "django_db_models_deletion", "count": 39}, {"cid": 11, "color": "#F28E2B", "label": "PanelTestCase", "count": 16}, {"cid": 12, "color": "#E15759", "label": "make_customer", "count": 37}, {"cid": 13, "color": "#76B7B2", "label": "EventTriggerTests", "count": 22}, {"cid": 14, "color": "#59A14F", "label": "views/finance.py", "count": 24}, {"cid": 15, "color": "#EDC948", "label": "django_contrib", "count": 7}, {"cid": 16, "color": "#B07AA1", "label": "sms0098ws.py", "count": 23}, {"cid": 17, "color": "#FF9DA7", "label": "Admin Panel Customization", "count": 22}, {"cid": 18, "color": "#9C755F", "label": "User", "count": 49}, {"cid": 19, "color": "#BAB0AC", "label": "apply_timing", "count": 22}, {"cid": 20, "color": "#4E79A7", "label": "InactiveCustomerHiddenTests", "count": 16}, {"cid": 21, "color": "#F28E2B", "label": "normalize_mobile", "count": 31}, {"cid": 22, "color": "#E15759", "label": "Login Template Forms", "count": 19}, {"cid": 23, "color": "#76B7B2", "label": "cheque_inquiry.py", "count": 20}, {"cid": 24, "color": "#59A14F", "label": "factories.py", "count": 13}, {"cid": 25, "color": "#EDC948", "label": "Rip&#x27;n Fast. Fewer Tokens. Better Code.", "count": 18}, {"cid": 26, "color": "#B07AA1", "label": "views/factors.py", "count": 29}, {"cid": 27, "color": "#FF9DA7", "label": "sms0098.py", "count": 14}, {"cid": 28, "color": "#9C755F", "label": "make_user", "count": 23}, {"cid": 29, "color": "#BAB0AC", "label": "QueueReviewTests", "count": 9}, {"cid": 30, "color": "#4E79A7", "label": "NoticeMechanismTests", "count": 16}, {"cid": 31, "color": "#F28E2B", "label": "name_fix.py", "count": 24}, {"cid": 32, "color": "#E15759", "label": "enqueue_sms", "count": 18}, {"cid": 33, "color": "#76B7B2", "label": "ZeroZeroNinetyEightWebServiceTests", "count": 35}, {"cid": 34, "color": "#59A14F", "label": "ScannerTests", "count": 23}, {"cid": 35, "color": "#EDC948", "label": "admin_panel/templates/admin_panel/baseHesabdari.html", "count": 15}, {"cid": 36, "color": "#B07AA1", "label": "Reference guide", "count": 14}, {"cid": 37, "color": "#FF9DA7", "label": "Factor Creation UI", "count": 14}, {"cid": 38, "color": "#9C755F", "label": "hesabdariResidNashode.html", "count": 14}, {"cid": 39, "color": "#BAB0AC", "label": "requests", "count": 11}, {"cid": 40, "color": "#4E79A7", "label": "CustomerSmsHistoryTests", "count": 24}, {"cid": 41, "color": "#F28E2B", "label": "TemplatesPageUITests", "count": 14}, {"cid": 42, "color": "#E15759", "label": "SMS Integration Docs", "count": 13}, {"cid": 43, "color": "#76B7B2", "label": "enqueue.py", "count": 20}, {"cid": 44, "color": "#59A14F", "label": "django_shortcuts", "count": 15}, {"cid": 45, "color": "#EDC948", "label": "test_inventory.py", "count": 30}, {"cid": 46, "color": "#B07AA1", "label": "admin_panel/templates/admin_panel/customerAction.html", "count": 13}, {"cid": 47, "color": "#FF9DA7", "label": "SmsReportTests", "count": 39}, {"cid": 48, "color": "#9C755F", "label": "write_tree", "count": 14}, {"cid": 49, "color": "#BAB0AC", "label": "CustomerInfoCardUITests", "count": 13}, {"cid": 50, "color": "#4E79A7", "label": "TestCase", "count": 10}, {"cid": 51, "color": "#F28E2B", "label": "Phone Cleanup Guide", "count": 12}, {"cid": 52, "color": "#E15759", "label": "Customer", "count": 47}, {"cid": 53, "color": "#76B7B2", "label": "project_tree.py", "count": 13}, {"cid": 54, "color": "#59A14F", "label": "Agent Operating Guidelines", "count": 11}, {"cid": 55, "color": "#EDC948", "label": "Command", "count": 28}, {"cid": 56, "color": "#B07AA1", "label": "BehinSMSProvider", "count": 8}, {"cid": 57, "color": "#FF9DA7", "label": "activate_sms", "count": 15}, {"cid": 58, "color": "#9C755F", "label": "Financial Data Objects", "count": 10}, {"cid": 59, "color": "#BAB0AC", "label": "Hybrid Migration Command", "count": 10}, {"cid": 60, "color": "#4E79A7", "label": ".run_cmd", "count": 37}, {"cid": 61, "color": "#F28E2B", "label": "Resource Cleanup Handler", "count": 10}, {"cid": 62, "color": "#E15759", "label": "SMS Timing Migration", "count": 10}, {"cid": 63, "color": "#76B7B2", "label": "admin_panel/templates/admin_panel/bedehi.html", "count": 10}, {"cid": 64, "color": "#59A14F", "label": "customerIDFactorBackup.html", "count":Line truncated
const RAW_NODES = [{"id": "tests_test_sms_autosendtests", "label": "AutoSendTests", "color": {"background": "#F28E2B", "border": "#F28E2B", "highlight": {"background": "#ffffff", "border": "#F28E2B"}}, "size": 13.2, "font": {"size": 0, "color": "#ffffff"}, "title": "AutoSendTests", "community": 1, "community_name": "AutoSendTests", "source_file": "tests/test_sms.py", "file_type": "code", "degree": 12}, {"id": "admin_panel_migrations_0001_initial_migration", "label": "Migration", "color": {"background": "#4E79A7", "border": "#4E79A7", "highlight": {"background": "#ffffff", "border": "#4E79A7"}}, "size": 10.3, "font": {"size": 0, "color": "#ffffff"}, "title": "Migration", "community": 10, "community_name": "django_db_models_deletion", "source_file": "admin_panel/migrations/0001_initial.py", "file_type": "code", "degree": 1}, {"id": "admin_panel_migrations_0007_productinstore_factor_migration", "label": "Migration", "color": {"background": "#4E79A7", "border": "#4E79A7", "highlight": {"background": "#ffffff", "border": "#4E79A7"}}, "size": 10.3, "font": {"size": 0, "color": "#ffffff"}, "title": "Migration", "community": 10, "community_name": "django_db_models_deletion", "source_file": "admin_panel/migrations/0007_productinstore_factor.py", "file_type": "code", "degree": 1}, {"id": "admin_panel_migrations_0008_storeroom_user_migration", "label": "Migration", "color": {"background": "#4E79A7", "border": "#4E79A7", "highlight": {"background": "#ffffff", "border": "#4E79A7"}}, "size": 10.3, "font": {"size": 0, "color": "#ffffff"}, "title": "Migration", "community": 10, "community_name": "django_db_models_deletion", "source_file": "admin_panel/migrations/0008_storeroom_user.py", "file_type": "code", "degree": 1}, {"id": "admin_panel_migrations_0011_storeroom_user_migration", "label": "Migration", "color": {"background": "#4E79A7", "border": "#4E79A7", "highlight": {"background": "#ffffff", "border": "#4E79A7"}}, "size": 10.3, "font": {"size": 0, "color": "#ffffff"}, "title": "Migration", "community": 10, "community_name": "django_db_models_deletion", "source_file": "admin_panel/migrations/0011_storeroom_user.py", "file_type": "code", "degree": 1}, {"id": "admin_panel_migrations_0018_alter_factoriteam_product_migration", "label": "Migration", "color": {"background": "#4E79A7", "border": "#4E79A7", "highlight": {"background": "#ffffff", "border": "#4E79A7"}}, "size": 10.3, "font": {"size": 0, "color": "#ffffff"}, "title": "Migration", "community": 10, "community_name": "django_db_models_deletion", "source_file": "admin_panel/migrations/0018_alter_factoriteam_product.py", "file_type": "code", "degree": 1}, {"id": "admin_panel_migrations_0019_article_articletype_migration", "label": "Migration", "color": {"background": "#4E79A7", "border": "#4E79A7", "highlight": {"background": "#ffffff", "border": "#4E79A7"}}, "size": 10.3, "font": {"size": 0, "color": "#ffffff"}, "title": "Migration", "community": 10, "community_name": "django_db_models_deletion", "source_file": "admin_panel/migrations/0019_article_articletype.py", "file_type": "code", "degree": 1}, {"id": "admin_panel_migrations_0036_sarresid_migration", "label": "Migration", "color": {"background": "#4E79A7", "border": "#4E79A7", "highlight": {"background": "#ffffff", "border": "#4E79A7"}}, "size": 10.3, "font": {"size": 0, "color": "#ffffff"}, "title": "Migration", "community": 10, "community_name": "django_db_models_deletion", "source_file": "admin_panel/migrations/0036_sarresid.py", "file_type": "code", "degree": 1}, {"id": "admin_panel_migrations_0038_auto_20240213_1735_migration", "label": "Migration", "color": {"background": "#4E79A7", "border": "#4E79A7", "highlight": {"background": "#ffffff", "border": "#4E79A7"}}, "size": 10.3, "font": {"size": 0, "color": "#ffffff"}, "title": "Migration", "community": 10, "community_name": "django_db_models_deletion", "source_file": "admin_panel/migrations/0038_auto_20240213_1735.py", "file_type": "code", "degree": 1}, {"id": "admin_panel_migrations_0042_article_user_migration", "label": "Migration", "color": {"background": "#4E79A7", "border": "#4E79A7", "highlight": {"background": "#ffffff", "border": "#4E79A7"}}, "size": 10.3, "font": {"size": 0, "color": "#ffffff"}, "title": "Migration", "community": 10, "community_name": "django_db_models_deletion", "source_file": "admin_panel/migrations/0042_article_user.py", "file_type": "code", "degree": 1}, {"id": "admin_panel_migrations_0046_auto_20260624_1818_migration", "label": "Migration", "color": {"background": "#4E79A7", "border": "#4E79A7", "highlight": {"background": "#ffffff", "border": "#4E79A7"}}, "size": 10.3, "font": {"size": 0, "color": "#ffffff"}, "title": "Migration", "community": 10, "community_name": "django_db_models_deletion", "source_file": "admin_panel/migrations/0046_auto_20260624_1818.py", "file_type": "code", "degree": 1}, {"id": "admin_panel_migrations_0054_auto_20260715_2250_migration", "label": "Migration", "color": {"background": "#4E79A7", "borderLine truncated
const RAW_EDGES = [{"from": "tests_test_sms", "to": "tests_test_sms_autosendtests", "label": "contains", "title": "contains [EXTRACTED]", "dashes": false, "width": 2, "color": {"opacity": 0.7}, "confidence": "EXTRACTED"}, {"from": "tests_test_sms_autosendtests", "to": "tests_test_sms_paneltestcase", "label": "inherits", "title": "inherits [EXTRACTED]", "dashes": false, "width": 2, "color": {"opacity": 0.7}, "confidence": "EXTRACTED"}, {"from": "tests_test_sms_autosendtests", "to": "tests_test_sms_autosendtests_overdue_visit_customer", "label": "method", "title": "method [EXTRACTED]", "dashes": false, "width": 2, "color": {"opacity": 0.7}, "confidence": "EXTRACTED"}, {"from": "tests_test_sms_autosendtests", "to": "tests_test_sms_autosendtests_test_default_is_pending_so_manager_must_approve", "label": "method", "title": "method [EXTRACTED]", "dashes": false, "width": 2, "color": {"opacity": 0.7}, "confidence": "EXTRACTED"}, {"from": "tests_test_sms_autosendtests", "to": "tests_test_sms_autosendtests_test_auto_send_enqueues_approved_without_manager", "label": "method", "title": "method [EXTRACTED]", "dashes": false, "width": 2, "color": {"opacity": 0.7}, "confidence": "EXTRACTED"}, {"from": "tests_test_sms_autosendtests", "to": "tests_test_sms_autosendtests_test_auto_send_does_not_bypass_opt_out_or_disabled", "label": "method", "title": "method [EXTRACTED]", "dashes": false, "width": 2, "color": {"opacity": 0.7}, "confidence": "EXTRACTED"}, {"from": "tests_test_sms_autosendtests", "to": "tests_test_sms_autosendtests_test_welcome_auto_send_skips_approval_on_customer_creation", "label": "method", "title": "method [EXTRACTED]", "dashes": false, "width": 2, "color": {"opacity": 0.7}, "confidence": "EXTRACTED"}, {"from": "tests_test_sms_autosendtests", "to": "tests_test_sms_autosendtests_test_template_edit_persists_auto_send", "label": "method", "title": "method [EXTRACTED]", "dashes": false, "width": 2, "color": {"opacity": 0.7}, "confidence": "EXTRACTED"}, {"from": "tests_test_sms_autosendtests", "to": "tests_test_sms_autosendtests_test_templates_page_shows_auto_send_switch", "label": "method", "title": "method [EXTRACTED]", "dashes": false, "width": 2, "color": {"opacity": 0.7}, "confidence": "EXTRACTED"}, {"from": "tests_test_sms_rationale_1246", "to": "tests_test_sms_autosendtests", "label": "rationale_for", "title": "rationale_for [EXTRACTED]", "dashes": false, "width": 2, "color": {"opacity": 0.7}, "confidence": "EXTRACTED"}, {"from": "tests_test_sms_autosendtests", "to": "admin_models_phonenumber", "label": "uses", "title": "uses [INFERRED]", "dashes": true, "width": 1, "color": {"opacity": 0.35}, "confidence": "INFERRED"}, {"from": "tests_test_sms_autosendtests", "to": "admin_models_customer", "label": "uses", "title": "uses [INFERRED]", "dashes": true, "width": 1, "color": {"opacity": 0.35}, "confidence": "INFERRED"}, {"from": "admin_panel_migrations_0001_initial", "to": "admin_panel_migrations_0001_initial_migration", "label": "contains", "title": "contains [EXTRACTED]", "dashes": false, "width": 2, "color": {"opacity": 0.7}, "confidence": "EXTRACTED"}, {"from": "admin_panel_migrations_0007_productinstore_factor", "to": "admin_panel_migrations_0007_productinstore_factor_migration", "label": "contains", "title": "contains [EXTRACTED]", "dashes": false, "width": 2, "color": {"opacity": 0.7}, "confidence": "EXTRACTED"}, {"from": "admin_panel_migrations_0008_storeroom_user", "to": "admin_panel_migrations_0008_storeroom_user_migration", "label": "contains", "title": "contains [EXTRACTED]", "dashes": false, "width": 2, "color": {"opacity": 0.7}, "confidence": "EXTRACTED"}, {"from": "admin_panel_migrations_0011_storeroom_user", "to": "admin_panel_migrations_0011_storeroom_user_migration", "label": "contains", "title": "contains [EXTRACTED]", "dashes": false, "width": 2, "color": {"opacity": 0.7}, "confidence": "EXTRACTED"}, {"from": "admin_panel_migrations_0018_alter_factoriteam_product", "to": "admin_panel_migrations_0018_alter_factoriteam_product_migration", "label": "contains", "title": "contains [EXTRACTED]", "dashes": false, "width": 2, "color": {"opacity": 0.7}, "confidence": "EXTRACTED"}, {"from": "admin_panel_migrations_0019_article_articletype", "to": "admin_panel_migrations_0019_article_articletype_migration", "label": "contains", "title": "contains [EXTRACTED]", "dashes": false, "width": 2, "color": {"opacity": 0.7}, "confidence": "EXTRACTED"}, {"from": "admin_panel_migrations_0036_sarresid", "to": "admin_panel_migrations_0036_sarresid_migration", "label": "contains", "title": "contains [EXTRACTED]", "dashes": false, "width": 2, "color": {"opacity": 0.7}, "confidence": "EXTRACTED"}, {"from": "admin_panel_migrations_0038_auto_20240213_1735", "to": "admin_panel_migrations_0038_auto_20240213_1735_migration", "label": "contains", "title": "contains [EXTRACTED]", "dashes": false, "width": 2, "color": {"opacity": 0.7}, "confidence": "EXTRACTED"}, {"from": "admin_panel_migrations_0042_article_user", "to": Line truncated
const LEGEND = [{"cid": 0, "color": "#4E79A7", "label": "views/sms.py", "count": 37}, {"cid": 1, "color": "#F28E2B", "label": "AutoSendTests", "count": 9}, {"cid": 2, "color": "#E15759", "label": "django_db", "count": 57}, {"cid": 3, "color": "#76B7B2", "label": "views/store.py", "count": 72}, {"cid": 4, "color": "#59A14F", "label": "ModelsPackageContractTests", "count": 15}, {"cid": 5, "color": "#EDC948", "label": "PhoneCleanupTests", "count": 35}, {"cid": 6, "color": "#B07AA1", "label": ".load", "count": 12}, {"cid": 7, "color": "#FF9DA7", "label": "models/sms.py", "count": 32}, {"cid": 8, "color": "#9C755F", "label": "django_db_models", "count": 27}, {"cid": 9, "color": "#BAB0AC", "label": "views/customers.py", "count": 29}, {"cid": 10, "color": "#4E79A7", "label": "django_db_models_deletion", "count": 27}, {"cid": 11, "color": "#F28E2B", "label": "PanelTestCase", "count": 14}, {"cid": 12, "color": "#E15759", "label": "mark_primary", "count": 16}, {"cid": 13, "color": "#76B7B2", "label": "EventTriggerTests", "count": 21}, {"cid": 14, "color": "#59A14F", "label": "views/finance.py", "count": 27}, {"cid": 15, "color": "#EDC948", "label": "django_conf", "count": 28}, {"cid": 16, "color": "#B07AA1", "label": "sms0098ws.py", "count": 22}, {"cid": 17, "color": "#FF9DA7", "label": "Admin Panel Customization", "count": 22}, {"cid": 18, "color": "#9C755F", "label": "views/actions.py", "count": 39}, {"cid": 19, "color": "#BAB0AC", "label": "timing_for", "count": 20}, {"cid": 20, "color": "#4E79A7", "label": "InactiveCustomerHiddenTests", "count": 16}, {"cid": 21, "color": "#F28E2B", "label": "normalize_mobile", "count": 35}, {"cid": 22, "color": "#E15759", "label": "Login Template Forms", "count": 19}, {"cid": 23, "color": "#76B7B2", "label": "cheque_inquiry.py", "count": 19}, {"cid": 24, "color": "#59A14F", "label": "factories.py", "count": 19}, {"cid": 25, "color": "#EDC948", "label": "Rip&#x27;n Fast. Fewer Tokens. Better Code.", "count": 19}, {"cid": 26, "color": "#B07AA1", "label": "views/factors.py", "count": 21}, {"cid": 27, "color": "#FF9DA7", "label": "SMSResult", "count": 18}, {"cid": 28, "color": "#9C755F", "label": "make_user", "count": 23}, {"cid": 29, "color": "#BAB0AC", "label": "QueueReviewTests", "count": 9}, {"cid": 30, "color": "#4E79A7", "label": "NoticeMechanismTests", "count": 16}, {"cid": 31, "color": "#F28E2B", "label": "name_fix.py", "count": 27}, {"cid": 32, "color": "#E15759", "label": "scanner.py", "count": 28}, {"cid": 33, "color": "#76B7B2", "label": "ZeroZeroNinetyEightWebServiceTests", "count": 16}, {"cid": 34, "color": "#59A14F", "label": "ScannerTests", "count": 22}, {"cid": 35, "color": "#EDC948", "label": "admin_panel/templates/admin_panel/baseHesabdari.html", "count": 15}, {"cid": 36, "color": "#B07AA1", "label": "Reference guide", "count": 15}, {"cid": 37, "color": "#FF9DA7", "label": "Factor Creation UI", "count": 14}, {"cid": 38, "color": "#9C755F", "label": "hesabdariResidNashode.html", "count": 14}, {"cid": 39, "color": "#BAB0AC", "label": "requests", "count": 22}, {"cid": 40, "color": "#4E79A7", "label": "CustomerSmsHistoryTests", "count": 35}, {"cid": 41, "color": "#F28E2B", "label": "TemplatesPageUITests", "count": 14}, {"cid": 42, "color": "#E15759", "label": "SMS Integration Docs", "count": 13}, {"cid": 43, "color": "#76B7B2", "label": "enqueue.py", "count": 13}, {"cid": 44, "color": "#59A14F", "label": "django_shortcuts", "count": 12}, {"cid": 45, "color": "#EDC948", "label": "stock_in", "count": 27}, {"cid": 46, "color": "#B07AA1", "label": "admin_panel/templates/admin_panel/customerAction.html", "count": 13}, {"cid": 47, "color": "#FF9DA7", "label": "SmsReportTests", "count": 39}, {"cid": 48, "color": "#9C755F", "label": "generate_snapshot", "count": 8}, {"cid": 49, "color": "#BAB0AC", "label": "CustomerInfoCardUITests", "count": 13}, {"cid": 50, "color": "#4E79A7", "label": "enqueue_sms", "count": 12}, {"cid": 51, "color": "#F28E2B", "label": "Phone Cleanup Guide", "count": 12}, {"cid": 52, "color": "#E15759", "label": ".run_command", "count": 39}, {"cid": 53, "color": "#76B7B2", "label": "project_tree.py", "count": 13}, {"cid": 54, "color": "#59A14F", "label": "Agent Operating Guidelines", "count": 11}, {"cid": 55, "color": "#EDC948", "label": "Command", "count": 28}, {"cid": 56, "color": "#B07AA1", "label": "BehinSMSProvider", "count": 8}, {"cid": 57, "color": "#FF9DA7", "label": "run_all", "count": 16}, {"cid": 58, "color": "#9C755F", "label": "Financial Data Objects", "count": 10}, {"cid": 59, "color": "#BAB0AC", "label": "Hybrid Migration Command", "count": 10}, {"cid": 60, "color": "#4E79A7", "label": "PhoneNumber", "count": 34}, {"cid": 61, "color": "#F28E2B", "label": "Resource Cleanup Handler", "count": 10}, {"cid": 62, "color": "#E15759", "label": "SMS Timing Migration", "count": 10}, {"cid": 63, "color": "#76B7B2", "label": "admin_panel/templates/admin_panel/bedehi.html", "count": 10}, {"cid": 64, "color": "#59A14F", "label": "customerIDFactorBackup.htLine truncated
// HTML-escape helper — prevents XSS when injecting graph data into innerHTML
function esc(s) {
+28130 -7627
View File
File diff suppressed because it is too large. Load diff
+113 -71
View File
@@ -102,10 +102,10 @@
"semantic_hash": "a93adc1bf01ae69281bc8736f254f7cc"
},
"admin_panel/management/commands/send_daily_report.py": {
"mtime": 1787309060.0288901,
"seen": 1791552045.91651,
"ast_hash": "acb098dbb2d4f61659f7d291b8ababbb",
"semantic_hash": "acb098dbb2d4f61659f7d291b8ababbb"
"mtime": 1792586144.6687672,
"seen": 1792649353.9243636,
"ast_hash": "e188d3f8d5a903e9e02801a1b96cfd12",
"semantic_hash": ""
},
"admin_panel/management/commands/sqlite_db_backup.py": {
"mtime": 1782242044.4963484,
@@ -468,10 +468,10 @@
"semantic_hash": "d41d8cd98f00b204e9800998ecf8427e"
},
"admin_panel/templatetags/custom_tags.py": {
"mtime": 1791641617.9080276,
"seen": 1791702801.3156137,
"ast_hash": "849cd0b0dc695c9b16de2f5af51d1166",
"semantic_hash": "849cd0b0dc695c9b16de2f5af51d1166"
"mtime": 1792520052.030276,
"seen": 1792649353.9359002,
"ast_hash": "588a8144d71eaa0895965910d8270746",
"semantic_hash": ""
},
"admin_panel/templatetags/remove_q_p.py": {
"mtime": 1683307703.0,
@@ -486,9 +486,9 @@
"semantic_hash": "e08f0582500f6562bf0a931ef9503b39"
},
"admin_panel/urls.py": {
"mtime": 1791717588.9114156,
"seen": 1791720362.5829337,
"ast_hash": "5b14236a71f471fee84008cb051566b9",
"mtime": 1792585999.6085472,
"seen": 1792649353.9359002,
"ast_hash": "2b32b37ee1cb3d66df4f77ec71c18202",
"semantic_hash": ""
},
"check_finance.py": {
@@ -528,10 +528,10 @@
"semantic_hash": "73a68e0d30f37dc4fbdad316c02b5fc4"
},
"khatibi/settings.py": {
"mtime": 1791564345.9580202,
"seen": 1791702801.3183815,
"ast_hash": "b3f5a8c5895df16b918fc132147d8859",
"semantic_hash": "b3f5a8c5895df16b918fc132147d8859"
"mtime": 1792648856.7794306,
"seen": 1792649353.9429948,
"ast_hash": "1e701cd7ce0da81c3b7b06905e081a3b",
"semantic_hash": ""
},
"khatibi/urls.py": {
"mtime": 1650778147.0,
@@ -576,16 +576,16 @@
"semantic_hash": "4ba55e04b37e585d96f0d628c695d228"
},
"admin_panel/templates/admin_panel/base.html": {
"mtime": 1791719559.991003,
"seen": 1791720362.5829337,
"ast_hash": "2a59c1f5b13d05f75c5dd56b20c228ce",
"mtime": 1792579166.964455,
"seen": 1792649353.9462168,
"ast_hash": "cb15bf2ddc33194ff213da2df5d560ba",
"semantic_hash": ""
},
"admin_panel/templates/admin_panel/baseHesabdari.html": {
"mtime": 1703841590.0,
"seen": 1791553187.9024515,
"ast_hash": "47b9ea8bc69fc1731d0dbe267f1ed433",
"semantic_hash": "47b9ea8bc69fc1731d0dbe267f1ed433"
"mtime": 1792578927.2750068,
"seen": 1792649353.9462168,
"ast_hash": "7cf4f6d71fdaef9c03fd883be7592ee9",
"semantic_hash": ""
},
"admin_panel/templates/admin_panel/baseStore.html": {
"mtime": 1783083554.0144844,
@@ -606,16 +606,16 @@
"semantic_hash": "9f714287989d56c19bc1fc28a08cd89d"
},
"admin_panel/templates/admin_panel/customerAction.html": {
"mtime": 1784913239.8701046,
"seen": 1791553187.9024515,
"ast_hash": "bb1af41ef956a5e2f30b1266c70a30ee",
"semantic_hash": "bb1af41ef956a5e2f30b1266c70a30ee"
"mtime": 1792586046.4354496,
"seen": 1792649353.946735,
"ast_hash": "640f55bfa16cbfa34769900cc60ac813",
"semantic_hash": ""
},
"admin_panel/templates/admin_panel/customerActionInc.html": {
"mtime": 1784913846.5075295,
"seen": 1791552251.327148,
"ast_hash": "3232cbf82efe94504f69735791ca5cf4",
"semantic_hash": "3232cbf82efe94504f69735791ca5cf4"
"mtime": 1792587674.6419835,
"seen": 1792649353.946735,
"ast_hash": "31d5d41b4dffc8d860eeb67eeac1269c",
"semantic_hash": ""
},
"admin_panel/templates/admin_panel/customerBase.html": {
"mtime": 1791712775.7233438,
@@ -732,10 +732,10 @@
"semantic_hash": "c3912ead765eb7b5ada5634b70ebc644"
},
"admin_panel/templates/admin_panel/entezar.html": {
"mtime": 1708088349.0,
"seen": 1791553187.904458,
"ast_hash": "808fd766dd0d0abec11bfcccd57dcee2",
"semantic_hash": "808fd766dd0d0abec11bfcccd57dcee2"
"mtime": 1792582443.9341311,
"seen": 1792649353.9499876,
"ast_hash": "48f3f9627a23892e1af9c44cd0ccc47e",
"semantic_hash": ""
},
"admin_panel/templates/admin_panel/entezarBK.html": {
"mtime": 1650778147.0,
@@ -882,16 +882,16 @@
"semantic_hash": "7cd6b8258d07f9d1a4f668a95b1c9c58"
},
"admin_panel/models/actions.py": {
"mtime": 1792153042.089988,
"seen": 1792154233.652624,
"ast_hash": "5b305ef4bef23a1e8b518282ef1b318e",
"mtime": 1792585889.4524539,
"seen": 1792649353.9338899,
"ast_hash": "e8e9b985b43e33cce542ebdf0a3089d0",
"semantic_hash": ""
},
"admin_panel/models/customers.py": {
"mtime": 1791621708.4566169,
"seen": 1791702801.313386,
"ast_hash": "b70fa9395bb5cacb452b84cf0a794efe",
"semantic_hash": "b70fa9395bb5cacb452b84cf0a794efe"
"mtime": 1792519478.2847924,
"seen": 1792649353.9338899,
"ast_hash": "fe636b23d2edbbc5f53e389dc31349b5",
"semantic_hash": ""
},
"admin_panel/models/factors.py": {
"mtime": 1791557664.8430376,
@@ -942,9 +942,9 @@
"semantic_hash": "4412599acacb9e21ffa7a2d2748d9484"
},
"admin_panel/views/actions.py": {
"mtime": 1791708825.7957535,
"seen": 1791710591.5772536,
"ast_hash": "e0f571ab730d0c1de7a17c8e908f161d",
"mtime": 1792586094.5941224,
"seen": 1792649353.9394238,
"ast_hash": "3f97823898fe7664006d2504e5674c10",
"semantic_hash": ""
},
"admin_panel/views/auth.py": {
@@ -960,9 +960,9 @@
"semantic_hash": "364f4190f934261a082c189d8f44d29c"
},
"admin_panel/views/customers.py": {
"mtime": 1792159253.0096061,
"seen": 1792493391.7541184,
"ast_hash": "a4371b66153ff8afa68452aa35026d84",
"mtime": 1792582506.8527822,
"seen": 1792649353.9394238,
"ast_hash": "c7732ee79f069c4afde6f257988f347a",
"semantic_hash": ""
},
"admin_panel/views/factors.py": {
@@ -972,9 +972,9 @@
"semantic_hash": ""
},
"admin_panel/views/finance.py": {
"mtime": 1791713964.8948607,
"seen": 1791714098.633808,
"ast_hash": "d199e23a302ef9eee93c9abf459bc511",
"mtime": 1792586136.7521703,
"seen": 1792649353.9399738,
"ast_hash": "4ff50b8360753656a3ee111e4625177c",
"semantic_hash": ""
},
"admin_panel/views/store.py": {
@@ -1026,15 +1026,15 @@
"semantic_hash": "55662ec48c0ef4b683a87b94808b91b0"
},
"tests/test_urls_contract.py": {
"mtime": 1791718031.9609113,
"seen": 1791720362.5829337,
"ast_hash": "e02361bf950d2549018c1b19fad366bb",
"mtime": 1792586443.293034,
"seen": 1792649353.9446063,
"ast_hash": "d6e6748f0f07110252db17bac5c39990",
"semantic_hash": ""
},
"tests/test_view_smoke.py": {
"mtime": 1791718041.2553785,
"seen": 1791720362.5829337,
"ast_hash": "527f0f3fcffdddd54cf8db80ed21ce2b",
"mtime": 1792579100.7355275,
"seen": 1792649353.9446063,
"ast_hash": "19aa0fd08551d4bcc78d7a8afa9244de",
"semantic_hash": ""
},
"AGENTS.md": {
@@ -1146,9 +1146,9 @@
"semantic_hash": ""
},
"admin_panel/sms/scanner.py": {
"mtime": 1792157807.5642593,
"seen": 1792493391.7505708,
"ast_hash": "f03e830b2c7a3344fb2ed18acfeff919",
"mtime": 1792505981.4115465,
"seen": 1792649353.9359002,
"ast_hash": "3955e128c54b7abe80c417762e5db71a",
"semantic_hash": ""
},
"admin_panel/sms/timing.py": {
@@ -1158,9 +1158,9 @@
"semantic_hash": ""
},
"admin_panel/views/sms.py": {
"mtime": 1792159340.4347801,
"seen": 1792493391.7541184,
"ast_hash": "9aa47348420d891e99ca67401c677898",
"mtime": 1792586159.7053664,
"seen": 1792649353.9399738,
"ast_hash": "3ec750b036718c52e9bf16f8c6279c2b",
"semantic_hash": ""
},
"integrations/__init__.py": {
@@ -1260,9 +1260,9 @@
"semantic_hash": "d181c7e91c9fc824e45ee36afe852884"
},
"tests/test_sms.py": {
"mtime": 1792159900.5047233,
"seen": 1792493391.763808,
"ast_hash": "e1b1259ac6fd3522b73f30f4b8af04f8",
"mtime": 1792502301.0777557,
"seen": 1792649353.9446063,
"ast_hash": "3a1a97a8b09f6dc4e49b664f88d7773f",
"semantic_hash": ""
},
"admin_panel/templates/admin_panel/_customer_sms.html": {
@@ -1428,9 +1428,9 @@
"semantic_hash": ""
},
"admin_panel/management/commands/recalc_last_bazdid.py": {
"mtime": 1791716465.206951,
"seen": 1791720362.5829337,
"ast_hash": "72df3ebb464b770d9ca91ddc2ca32404",
"mtime": 1792499351.323715,
"seen": 1792649353.9243636,
"ast_hash": "7a6bc5270e1a61924101a46475902d9a",
"semantic_hash": ""
},
"admin_panel/migrations/0071_sms_settings_permission.py": {
@@ -1482,9 +1482,9 @@
"semantic_hash": ""
},
"tests/test_customer_name_fix.py": {
"mtime": 1792044607.9337437,
"seen": 1792143069.5209367,
"ast_hash": "9be8b6950f0393905fece6a5ec39d3fe",
"mtime": 1792520434.1397984,
"seen": 1792649353.944064,
"ast_hash": "09365bdda5e0a588028cc337b380f09d",
"semantic_hash": ""
},
"tests/test_sms_phone_normalize.py": {
@@ -1540,5 +1540,47 @@
"seen": 1792493391.7481978,
"ast_hash": "60c36802c8ec0752be3effd8cbc63e68",
"semantic_hash": ""
},
"admin_panel/management/commands/cleanup_visit_dedup.py": {
"mtime": 1792502665.8561392,
"seen": 1792649353.922272,
"ast_hash": "76d9611639bb1da68a9861db9d44518d",
"semantic_hash": ""
},
"admin_panel/migrations/0078_action_visit_covering_index.py": {
"mtime": 1792507867.8950336,
"seen": 1792649353.9338899,
"ast_hash": "e8dec5026122931759a139b8d3405d07",
"semantic_hash": ""
},
"admin_panel/migrations/0079_customer_visit_hold.py": {
"mtime": 1792519672.2225547,
"seen": 1792649353.9338899,
"ast_hash": "a857d48dbb7cc395dc3b2a3cbcb1968a",
"semantic_hash": ""
},
"admin_panel/migrations/0080_action_soft_delete.py": {
"mtime": 1792585898.3564234,
"seen": 1792649353.9338899,
"ast_hash": "a2270def509f83cfb982c1b3f98b576e",
"semantic_hash": ""
},
"admin_panel/visit_policy.py": {
"mtime": 1792585952.436753,
"seen": 1792649353.9399738,
"ast_hash": "91324d6f9f5f1e6a1904e718c7a13276",
"semantic_hash": ""
},
"tests/test_visit_policy.py": {
"mtime": 1792587539.4103932,
"seen": 1792649353.9446063,
"ast_hash": "03d0edf01f9a31f61525a609a2c516de",
"semantic_hash": ""
},
"docs/visit_hold_entezar.md": {
"mtime": 1792586615.9454145,
"seen": 1792649353.9525056,
"ast_hash": "7d81ac202f843e25caac8745ff7d6c20",
"semantic_hash": ""
}
}
+2 -2
View File
@@ -78,11 +78,11 @@ WSGI_APPLICATION = 'khatibi.wsgi.application'
DATABASES = {
'default': {
'ENGINE': 'django.db.backends.sqlite3',
'NAME': BASE_DIR / 'data9/khatibi92669266_backups_db_backup_20261020_223011.sqlite3',
'NAME': BASE_DIR / 'data9/khatibi92669266_backups_db_backup_20261021_223009.sqlite3',
}
}
#
# Password validation
# https://docs.djangoproject.com/en/3.2/ref/settings/#auth-password-validators
+225
View File
@@ -0,0 +1,225 @@
#!/usr/bin/env python
"""شبیه‌سازیِ «تعویقِ کوتاه‌تر از تاریخ انجامِ هماهنگی» روی دیتابیس واقعی (کپی).
سناریوی گزارش‌شده: در فرم «ایجاد وظیفه» نوع = هماهنگی،
«تاریخ و ساعتی که کار باید انجام شود» = ۱۴۰۵/۰۸/۰۵ و
«تا تاریخ پیگیری نشود» = ۱۴۰۵/۰۸/۰۳ (یعنی قرار **قبل‌تر** از تاریخ کار).
این اسکریپت روی یک **کپی موقت** از دیتابیس، یک مشتری آزمایشی می‌سازد و رفتار
سیستم را روز‌به‌روز چاپ می‌کند: منابع قرار، فهرست «نیازمند پیگیری»، «عقب‌افتاده»،
و اینکه اسکنر پیامک در هر روز چه می‌کند. دیتابیس اصلی دست‌نخورده می‌ماند.
اجرا::
.venv/Scripts/python.exe scripts/coordination_hold_order_probe.py
.venv/Scripts/python.exe scripts/coordination_hold_order_probe.py --customer 5124
"""
import argparse
import datetime
import os
import shutil
import sys
import tempfile
BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
if BASE_DIR not in sys.path:
sys.path.insert(0, BASE_DIR)
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'khatibi.settings')
def _copy_db(source_path, target_dir):
target = os.path.join(target_dir, 'probe.sqlite3')
shutil.copy2(source_path, target)
for suffix in ('-wal', '-shm'):
side = source_path + suffix
if os.path.exists(side):
shutil.copy2(side, target + suffix)
return target
def _day(offset):
return datetime.datetime.combine(
datetime.date.today() + datetime.timedelta(days=offset), datetime.time(12, 0),
)
def run_variant(label, *, done, hold_offset, action_offset):
"""یک سناریو را می‌سازد و رفتار روز‌به‌روز را چاپ می‌کند."""
from django.utils import timezone
from admin_panel.models import (Action, Customer, PhoneNumber, SMSProviderConfig,
SMSQueue)
from admin_panel.sms.scanner import scan_visit
from admin_panel.visit_policy import (appointment_sources_map, end_of_local_day,
followups_on_date, last_visit_map,
manual_holds_map, next_appointment_map,
overdue_followups, set_visit_hold)
tz = timezone.get_current_timezone()
now = timezone.now()
today = now.astimezone(tz).date()
customer = Customer.objects.create(
name_family='مشتری آزمایشی — %s' % label, address='آزمایش',
)
Customer.objects.filter(pk=customer.pk).update(
installDate=today - datetime.timedelta(days=400))
PhoneNumber.objects.create(customer=customer, phone_number='09120000777')
customer.refresh_from_db()
action_dt = timezone.make_aware(_day(action_offset))
hold_raw = timezone.make_aware(_day(hold_offset)) if hold_offset is not None else None
action = Action.objects.create(
customer=customer, actionType=Action.ActionType.HAMAHANGI,
status=Action.Status.FINISH if done else Action.Status.WAITE,
actionDateTime=action_dt,
finishDateTime=now if done else None,
desc='probe', result='probe' if done else None,
)
# همان مسیری که ویو طی می‌کند (شامل پیش‌فرض ۱۴ روز و «فقط رو به جلو»).
from admin_panel.views.actions import _apply_visit_hold
_apply_visit_hold(
customer,
{'actionType': 'hamahangi', 'holdRaw': hold_raw, 'status': action.status},
status=action.status, is_new=True,
)
customer.refresh_from_db()
config = SMSProviderConfig.load()
config.scan_since = now - datetime.timedelta(days=600)
config.allow_backlog = True
config.backlog_max = 50
config.max_per_run = 200
config.save()
print('\n' + '=' * 100)
print('سناریو %s' % label)
print(' تیک «انجام شد»: %s → status=%s' % ('خورده' if done else 'نخورده', action.status))
print(' تاریخ کار (actionDateTime): %s' % action.actionDateTime.astimezone(tz))
print(' تا تاریخ پیگیری نشود: %s' % (
customer.visit_hold_until.astimezone(tz) if customer.visit_hold_until
else 'خالی (هیچ تعویقی ثبت نشد)'))
# اثرهای جانبی «تیک انجام شد»: لیست وظایف، شمارندهٔ داشبورد، نظرسنجی.
print(' در «لیست وظایف باز» (ActionList/داشبورد): %s' % (
'بله' if action.status == Action.Status.WAITE else 'خیر'))
survey = (action.status == Action.Status.FINISH
and action.actionType != 'vosol'
and action.nazarDT is None
and action.createdDT >= now - datetime.timedelta(days=60)
and (action.finishDateTime is None
or action.finishDateTime >= now - datetime.timedelta(days=30)))
print(' در صف «نظرسنجی»: %s' % ('بله' if survey else 'خیر'))
# اسکن کامل دیتابیس گران است؛ تصمیمِ اسکنر برای همین مشتری بازسازی می‌شود:
# scanner.py:232 if customer.id in appointments: continue
# scanner.py:240 trigger = visit_latest_due(anchor, now=now)
from admin_panel.visit_policy import cycle_anchor_map, visit_latest_due
def scanner_decision(moment):
appointments = next_appointment_map(now=moment)
if customer.pk in appointments:
return 'رد (قرار بعدی دارد)'
anchors = cycle_anchor_map(now=moment)
anchor = anchors.get(customer.pk)
if anchor is None:
return 'رد (لنگر ندارد)'
trigger = visit_latest_due(anchor, now=moment)
if trigger is None or trigger.date() > moment.astimezone(tz).date():
return 'رد (موعد نرسیده)'
return 'ارسال یادآوری دوره‌ای'
for day_offset in range(0, action_offset + 3):
moment = timezone.make_aware(_day(day_offset))
sources = appointment_sources_map(now=moment)
nexts = next_appointment_map(now=moment)
holds = manual_holds_map(now=moment)
entries = dict(sources.get(customer.pk) or {})
day = moment.astimezone(tz).date()
follow = followups_on_date(day, now=moment)
overdue = overdue_followups(now=moment, target_date=day)
visited = last_visit_map(now=moment, customer_ids=[customer.pk])
badge_days = {value.astimezone(tz).date() for value in entries.values()}
scan_cell = scanner_decision(moment)
print(' %s | منابع=%-34s برچسب=%d قرار | قرار بعدی=%-11s | پیگیری=%s'
' | عقب‌افتاده=%s | قرار دستی=%s | %s' % (
day, '+'.join(sorted(entries)) or '—', len(badge_days),
(nexts.get(customer.pk).astimezone(tz).date()
if nexts.get(customer.pk) else '—'),
'بله' if customer.pk in follow else 'خیر',
'بله' if customer.pk in overdue else 'خیر',
'بله' if customer.pk in holds else 'خیر',
scan_cell,
))
_ = visited
SMSQueue.objects.filter(customer=customer).delete()
Action.objects.filter(customer=customer).delete()
def show_customer(customer_id):
"""وضعیت فعلی یک مشتری واقعی + خط زمانی اقدام‌هایش (فقط خواندن)."""
from django.utils import timezone
from admin_panel.models import Action, Customer
from admin_panel.visit_policy import (appointment_sources_map, last_visit_map,
manual_holds_map, next_appointment_map)
tz = timezone.get_current_timezone()
customer = Customer.objects.filter(pk=customer_id).first()
print('\n' + '=' * 100)
print('وضعیت مشتری #%s' % customer_id)
if customer is None:
print(' مشتری پیدا نشد.')
return
print(' نام: %s | فعال: %s | لغو بازدید: %s' % (
customer.name_family, customer.is_active, customer.laghv_bazdid))
print(' visit_hold_until: %s' % customer.visit_hold_until)
sources = appointment_sources_map()
print(' منابع قرار: %s' % {
k: str(v.astimezone(tz)) for k, v in (sources.get(customer.pk) or {}).items()})
print(' قرار بعدی: %s' % next_appointment_map().get(customer.pk))
print(' قرار دستی معتبر: %s' % (customer.pk in manual_holds_map()))
print(' آخرین بازدید: %s' % last_visit_map().get(customer.pk))
print(' اقدام‌ها (۱۰ مورد آخر):')
for act in Action.objects.filter(customer_id=customer_id).order_by('-createdDT')[:10]:
print(' #%-6s %-10s %-7s action=%-19s finish=%-19s created=%s' % (
act.pk, act.actionType, act.status,
act.actionDateTime, act.finishDateTime, act.createdDT))
def main():
parser = argparse.ArgumentParser(description='شبیه‌سازی تعویقِ قبل از تاریخ کار')
parser.add_argument('--customer', type=int, default=None, help='شناسهٔ مشتری واقعی')
parser.add_argument('--skip-variants', action='store_true')
args = parser.parse_args()
import django
django.setup()
from django.conf import settings
from django.db import connections
source_db = str(settings.DATABASES['default']['NAME'])
if not os.path.isabs(source_db):
source_db = os.path.join(BASE_DIR, source_db)
tmp_dir = tempfile.mkdtemp(prefix='hold_probe_')
settings.DATABASES['default']['NAME'] = _copy_db(source_db, tmp_dir)
connections.close_all()
print('دیتابیسِ کپیِ موقت: %s' % settings.DATABASES['default']['NAME'])
if not args.skip_variants:
run_variant('بدون تیک «انجام شد»', done=False, hold_offset=3, action_offset=5)
run_variant('با تیک «انجام شد»', done=True, hold_offset=3, action_offset=5)
run_variant('تعویق بعد از تاریخ کار (سالم)', done=True, hold_offset=10,
action_offset=5)
run_variant('تعویق خالی + تیک (پیش‌فرض ۱۴ روز)', done=True, hold_offset=None,
action_offset=5)
run_variant('تعویق خالی + بدون تیک', done=False, hold_offset=None,
action_offset=5)
if args.customer:
show_customer(args.customer)
shutil.rmtree(tmp_dir, ignore_errors=True)
if __name__ == '__main__':
main()
+370
View File
@@ -0,0 +1,370 @@
#!/usr/bin/env python
"""ممیزیِ فقط-خواندنیِ رفتار «قرار / پیگیری» روی دیتابیس واقعی.
این اسکریپت هیچ نوشتنی روی دیتابیسِ ورودی انجام نمی‌دهد: ابتدا یک **کپی موقت**
از فایل sqlite (به‌همراه ``-wal``/``-shm``) می‌سازد و همهٔ کوئری‌ها و توابع
``admin_panel.visit_policy`` را روی همان کپی اجرا می‌کند.
اجرا::
.venv/Scripts/python.exe scripts/entezar_action_audit.py
.venv/Scripts/python.exe scripts/entezar_action_audit.py --db data9/xxx.sqlite3
.venv/Scripts/python.exe scripts/entezar_action_audit.py --samples 10
"""
import argparse
import os
import shutil
import sys
import tempfile
from collections import Counter
BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
if BASE_DIR not in sys.path:
sys.path.insert(0, BASE_DIR)
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'khatibi.settings')
def _copy_db(source_path, target_dir):
"""کپی امنِ dbfile + فایل‌های WAL در پوشهٔ موقت؛ مسیر کپی برگردانده می‌شود."""
target = os.path.join(target_dir, 'audit_copy.sqlite3')
shutil.copy2(source_path, target)
for suffix in ('-wal', '-shm'):
side = source_path + suffix
if os.path.exists(side):
shutil.copy2(side, target + suffix)
return target
def _aware(value, tz):
from django.utils import timezone
if value is None:
return None
if timezone.is_naive(value):
return timezone.make_aware(value, tz)
return value
def _local_date(value, tz):
value = _aware(value, tz)
return None if value is None else value.astimezone(tz).date()
def _pct(part, whole):
return 0.0 if not whole else round(100.0 * part / whole, 1)
def main():
parser = argparse.ArgumentParser(description='ممیزی فقط-خواندنی صفحهٔ انتظار')
parser.add_argument('--db', default=None, help='مسیر دیتابیس (پیش‌فرض: settings)')
parser.add_argument('--samples', type=int, default=5, help='تعداد نمونه در خروجی')
parser.add_argument('--customer', type=int, default=None,
help='شناسهٔ مشتری برای نمایش خط زمانی کامل اقدام‌ها')
args = parser.parse_args()
import django
django.setup()
from django.conf import settings
from django.utils import timezone
from admin_panel.models import Action, Customer
from admin_panel.visit_policy import (SOURCE_COORDINATION, SOURCE_MANUAL,
appointment_sources_map,
followups_on_date, last_visit_map,
overdue_followups)
source_db = args.db or str(settings.DATABASES['default']['NAME'])
if not os.path.isabs(source_db):
source_db = os.path.join(BASE_DIR, source_db)
if not os.path.exists(source_db):
raise SystemExit('دیتابیس پیدا نشد: %s' % source_db)
tmp_dir = tempfile.mkdtemp(prefix='entezar_audit_')
settings.DATABASES['default']['NAME'] = _copy_db(source_db, tmp_dir)
from django.db import connections
connections.close_all()
print('دیتابیسِ کپیِ موقت: %s' % settings.DATABASES['default']['NAME'])
now = timezone.now()
tz = timezone.get_current_timezone()
today = now.astimezone(tz).date()
# ---------------- داده‌های پایه ----------------
customers = Customer.objects.all().values_list(
'id', 'name_family', 'is_active', 'laghv_bazdid', 'visit_hold_until', 'installDate',
)
customer_rows = {row[0]: row for row in customers}
coord_rows = list(
Action.objects.filter(
actionType=Action.ActionType.HAMAHANGI,
status__in=(Action.Status.WAITE, Action.Status.FINISH),
deleted_at__isnull=True,
).values_list('customer_id', 'id', 'actionDateTime', 'finishDateTime',
'createdDT', 'status')
)
visit_rows = list(
Action.objects.filter(
actionType=Action.ActionType.BAZDID,
status=Action.Status.FINISH,
).values_list('customer_id', 'id', 'actionDateTime', 'finishDateTime', 'createdDT')
)
pending_rows = list(
Action.objects.filter(
actionType=Action.ActionType.BAZDID,
status=Action.Status.WAITE,
).values_list('customer_id', 'id', 'actionDateTime', 'finishDateTime', 'createdDT')
)
print('مشتریان: %d | هماهنگی فعال: %d | بازدید انجام‌شده: %d | بازدید در انتظار: %d'
% (len(customer_rows), len(coord_rows), len(visit_rows), len(pending_rows)))
visits = last_visit_map(now=now)
sources = appointment_sources_map(now=now)
overdue = overdue_followups(now=now, target_date=today)
today_followups = followups_on_date(today, now=now)
last_coord = {}
for cid, aid, adt, fdt, created, status in coord_rows:
current = last_coord.get(cid)
created_aware = _aware(created, tz)
if current is None or created_aware > current['created']:
last_coord[cid] = {
'id': aid, 'actionDateTime': adt, 'finishDateTime': fdt,
'created': created_aware, 'status': status,
}
visits_by_customer = {}
for row in visit_rows:
visits_by_customer.setdefault(row[0], []).append(row)
def effective(dt, fdt):
"""قاعدهٔ ``effective_visit_dt``: actionDateTime اگر گذشته باشد، وگرنه finish."""
for value in (_aware(dt, tz), _aware(fdt, tz)):
if value is not None and value <= now:
return value
return None
def analyse(cid):
"""تشخیص علت حضور در «نیازمند پیگیری»."""
coord = last_coord.get(cid)
done = visits_by_customer.get(cid) or []
fact = {
'coord_day': _local_date(coord['actionDateTime'], tz) if coord else None,
'coord_created': coord['created'] if coord else None,
'coord_status': coord['status'] if coord else None,
'visits_total': len(done),
'visit_after_coord_created': False,
'visit_dt_after_coord_day': False,
'last_visit': visits.get(cid),
'last_visit_day': _local_date(visits.get(cid), tz),
}
if coord is not None:
for _cid, _aid, adt, fdt, created in done:
if _aware(created, tz) > coord['created']:
fact['visit_after_coord_created'] = True
eff = effective(adt, fdt)
if eff is not None and fact['coord_day'] is not None \
and eff.astimezone(tz).date() > fact['coord_day']:
fact['visit_dt_after_coord_day'] = True
return fact
# ---------------- ۱) نمای missed ----------------
print('\n=== ۱) ?view=today&missed=1 (پیگیری‌های عقب‌افتاده) ===')
missed_ids = [cid for cid in overdue if cid in customer_rows
and customer_rows[cid][2] and not customer_rows[cid][3]
and customer_rows[cid][1]]
print('تعداد ردیف: %d' % len(missed_ids))
reasons = Counter()
samples = []
facts = {}
for cid in missed_ids:
fact = facts[cid] = analyse(cid)
if fact['visits_total'] == 0:
reasons['بدون هیچ بازدید انجام‌شده'] += 1
elif fact['visit_after_coord_created']:
reasons['بازدید انجام‌شده پس از ثبت هماهنگی'] += 1
elif fact['visit_dt_after_coord_day']:
reasons['تاریخ بازدید بعد از موعد هماهنگی است'] += 1
else:
reasons['بازدید انجام‌شده اما قدیمی‌تر از هماهنگی'] += 1
if len(samples) < args.samples and fact['visits_total']:
samples.append((cid, customer_rows[cid][1], fact))
for label, count in reasons.most_common():
print(' - %-60s %6d (%s%%)' % (label, count, _pct(count, len(missed_ids))))
with_visit = sum(1 for f in facts.values() if f['visits_total'])
after_coord = sum(1 for f in facts.values() if f['visit_after_coord_created'])
last_visit_after = sum(
1 for f in facts.values()
if f['last_visit_day'] and f['coord_day'] and f['last_visit_day'] > f['coord_day']
)
print(' => دارای حداقل یک بازدید انجام‌شده: %d (%s%%)'
% (with_visit, _pct(with_visit, len(missed_ids))))
print(' => بازدید پس از ثبت هماهنگی: %d (%s%%)'
% (after_coord, _pct(after_coord, len(missed_ids))))
print(' => آخرین بازدید بعد از موعدِ هماهنگی: %d (%s%%)'
% (last_visit_after, _pct(last_visit_after, len(missed_ids))))
statuses = Counter(f['coord_status'] for f in facts.values())
print(' => وضعیت آخرین هماهنگی: %s' % dict(statuses))
finish_without_hold = sum(
1 for cid in missed_ids
if facts[cid]['coord_status'] == Action.Status.FINISH
and not (customer_rows[cid][4] and customer_rows[cid][4] > now)
)
print(' => هماهنگیِ finish بدون قرار دستیِ فعال: %d' % finish_without_hold)
pending_old = sum(1 for cid in missed_ids
if any(row[0] == cid for row in pending_rows))
print(' => دارای بازدید در انتظارِ قدیمی (قرار منقضی): %d' % pending_old)
holds_in_missed = sum(1 for cid in missed_ids
if customer_rows[cid][4] and customer_rows[cid][4] > now)
print(' => دارای «قرار دستی» آینده و باز هم در missed: %d' % holds_in_missed)
valid_holds_in_missed = sum(
1 for cid in missed_ids if SOURCE_MANUAL in (sources.get(cid) or {}))
print(' => دارای «قرار دستی» **معتبر** و باز هم در missed: %d'
% valid_holds_in_missed)
for cid, name, fact in samples:
print(' نمونه #%s %s | موعد هماهنگی: %s | وضعیت هماهنگی: %s | آخرین بازدید: %s'
% (cid, name, fact['coord_day'], fact['coord_status'], fact['last_visit_day']))
# ---------------- ۲) نمای today ----------------
print('\n=== ۲) ?view=today (نیازمند پیگیری امروز) ===')
eligible_today = [cid for cid in today_followups if cid in customer_rows
and customer_rows[cid][2] and not customer_rows[cid][3]
and customer_rows[cid][1]]
print('تعداد ردیف: %d' % len(eligible_today))
combo = Counter()
for cid in eligible_today:
entry = dict(sources.get(cid) or {})
entry.update(today_followups.get(cid) or {})
combo[tuple(sorted(entry))] += 1
print('ترکیب منابعِ «قرار» در این نما:')
for keys, count in combo.most_common():
print(' - %-45s %6d' % ('+'.join(keys) or 'بدون منبع', count))
today_facts = [analyse(cid) for cid in eligible_today]
done_today = sum(1 for f in today_facts if f['visits_total'])
done_after = sum(1 for f in today_facts if f['visit_after_coord_created'])
def followup_day(cid):
entry = today_followups.get(cid) or {}
return _local_date(min(entry.values()), tz) if entry else None
last_after = sum(
1 for cid, f in zip(eligible_today, today_facts)
if f['last_visit_day'] and followup_day(cid)
and f['last_visit_day'] >= followup_day(cid)
)
print(' => دارای بازدید انجام‌شده: %d (%s%%)'
% (done_today, _pct(done_today, len(eligible_today))))
print(' => بازدید پس از ثبت هماهنگی: %d (%s%%)'
% (done_after, _pct(done_after, len(eligible_today))))
print(' => آخرین بازدید در/بعد از همان روزِ موعد: %d' % last_after)
for cid, fact in list(zip(eligible_today, today_facts))[:args.samples or 0]:
print(' #%-6s %-28s موعد=%-12s آخرین بازدید=%-12s منابع=%s'
% (cid, customer_rows[cid][1], followup_day(cid),
fact['last_visit_day'], sorted(today_followups.get(cid) or {})))
# تفکیک موعدهای امروز: هماهنگیِ «انجام‌شده» (زمان تماس) یا قرار دستی؟
today_coord_status = Counter()
today_with_hold = 0
for cid in eligible_today:
if SOURCE_COORDINATION in (today_followups.get(cid) or {}):
coord = last_coord.get(cid)
today_coord_status[coord['status'] if coord else '?'] += 1
if SOURCE_MANUAL in (sources.get(cid) or {}):
today_with_hold += 1
print(' => وضعیت آخرین اقدام هماهنگی (ردیف‌های دارای منبع هماهنگی): %s'
% dict(today_coord_status))
print(' => از این ردیف‌ها، دارای قرار دستیِ آینده (یعنی بعداً هم می‌آید): %d'
% today_with_hold)
# ---------------- ۳) برچسب «N قرار» ----------------
print('\n=== ۳) برچسب وضعیت «N قرار» (source_count) ===')
source_count = Counter()
for cid, entry in sources.items():
if cid in customer_rows and customer_rows[cid][2]:
source_count[len(entry)] += 1
for n in sorted(source_count):
print(' %d منبع: %6d مشتری' % (n, source_count[n]))
pairs = Counter()
same_day = 0
different_day = 0
for cid, entry in sources.items():
if len(entry) > 1:
pairs['+'.join(sorted(entry))] += 1
if SOURCE_MANUAL in entry and SOURCE_COORDINATION in entry:
if _local_date(entry[SOURCE_MANUAL], tz) == _local_date(
entry[SOURCE_COORDINATION], tz):
same_day += 1
else:
different_day += 1
print(' از ترکیب manual+coordination: هم‌روز=%d | مختلف=%d'
% (same_day, different_day))
print(' ترکیب مشتریان با بیش از یک منبع:')
for keys, count in pairs.most_common():
print(' - %-45s %6d' % (keys, count))
for cid in [cid for cid, e in sources.items() if len(e) > 1][:args.samples or 0]:
print(' نمونه #%s %s: %s'
% (cid, customer_rows[cid][1],
{k: str(_local_date(v, tz)) for k, v in sources[cid].items()}))
# ---------------- ۴) ستون قرار دستی ----------------
print('\n=== ۴) ستون visit_hold_until («تا تاریخ پیگیری نشود») ===')
holds = [(cid, _aware(row[4], tz)) for cid, row in customer_rows.items() if row[4]]
future = [(cid, until) for cid, until in holds if until > now]
past = [(cid, until) for cid, until in holds if until <= now]
valid_future = sum(
1 for cid, _ in future
if SOURCE_MANUAL in (sources.get(cid) or {})
)
print('پرشده: %d | آینده: %d | گذشته (کهنه): %d | آینده و معتبر: %d'
% (len(holds), len(future), len(past), valid_future))
print(' => از %d قرارِ آینده، %d مورد بی‌اعتبار است (آخرین فعالیت هماهنگی نیست).'
% (len(future), len(future) - valid_future))
invalid_reason = Counter()
for cid, _until in future:
if cid not in last_coord:
invalid_reason['بدون اقدام هماهنگی فعال'] += 1
elif cid in visits_by_customer and any(
_aware(row[4], tz) > last_coord[cid]['created']
for row in visits_by_customer[cid]):
invalid_reason['بازدید ثبت‌شده بعد از هماهنگی'] += 1
elif SOURCE_MANUAL not in (sources.get(cid) or {}):
invalid_reason['سایر اقدام‌های بعد از هماهنگی'] += 1
print(' دلایل بی‌اعتباری: %s' % dict(invalid_reason))
# ---------------- ۵) هماهنگی ----------------
print('\n=== ۵) اقدام‌های هماهنگی ===')
print('وضعیت: %s' % dict(Counter(row[5] for row in coord_rows)))
with_hold_field = sum(1 for cid in last_coord
if customer_rows.get(cid) and customer_rows[cid][4])
print('مشتری با هماهنگی فعال و ستونِ قرار پرشده: %d' % with_hold_field)
# ---------------- ۶) خط زمانی یک مشتری ----------------
if args.customer:
cid = args.customer
row = customer_rows.get(cid)
print('\n=== ۶) خط زمانی مشتری #%s ===' % cid)
if row is None:
print(' مشتری پیدا نشد.')
else:
print(' نام: %s | فعال: %s | لغو بازدید: %s | قرار دستی: %s'
% (row[1], row[2], row[3], row[4]))
print(' در missed: %s | در today: %s | منابع: %s'
% (cid in overdue, cid in today_followups, sources.get(cid)))
for act in Action.objects.filter(customer_id=cid).order_by('createdDT'):
print(' #%-6s %-10s %-7s action=%-19s finish=%-19s created=%-19s deleted=%s'
% (act.pk, act.actionType, act.status,
_aware(act.actionDateTime, tz), _aware(act.finishDateTime, tz),
_aware(act.createdDT, tz), act.deleted_at is not None))
shutil.rmtree(tmp_dir, ignore_errors=True)
if __name__ == '__main__':
main()
+286
View File
@@ -0,0 +1,286 @@
#!/usr/bin/env python
"""صحت‌سنجیِ دو نقص گزارش‌شده دربارهٔ ``set_visit_hold`` (روی کپی دیتابیس).
سناریوها روی یک **کپی موقت** از دیتابیس اجرا می‌شوند؛ دیتابیس اصلی دست‌نخورده
می‌ماند و هیچ کد تولیدی‌ای تغییر نمی‌کند — این فقط ابزار اثبات/رد ادعاهاست.
اجرا::
.venv/Scripts/python.exe scripts/hold_write_verification.py
"""
import argparse
import datetime
import os
import shutil
import sys
import tempfile
BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
if BASE_DIR not in sys.path:
sys.path.insert(0, BASE_DIR)
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'khatibi.settings')
def _copy_db(source_path, target_dir):
target = os.path.join(target_dir, 'verify.sqlite3')
shutil.copy2(source_path, target)
for suffix in ('-wal', '-shm'):
side = source_path + suffix
if os.path.exists(side):
shutil.copy2(side, target + suffix)
return target
def _new_customer(name):
from django.utils import timezone
from admin_panel.models import Customer
today = timezone.localdate()
customer = Customer.objects.create(name_family=name, address='verify')
Customer.objects.filter(pk=customer.pk).update(
installDate=today - datetime.timedelta(days=400))
customer.refresh_from_db()
return customer
def _coordination(customer, *, days, status='finish'):
from django.utils import timezone
from admin_panel.models import Action
when = timezone.now() + datetime.timedelta(days=days)
return Action.objects.create(
customer=customer, actionType=Action.ActionType.HAMAHANGI, status=status,
actionDateTime=when,
finishDateTime=timezone.now() if status == 'finish' else None,
desc='verify',
)
def _show(label, customer):
from django.utils import timezone
from admin_panel.visit_policy import manual_holds_map, appointment_sources_map
tz = timezone.get_current_timezone()
print(' %-44s column=%-18s معتبر=%-5s منابع=%s' % (
label,
customer.visit_hold_until.astimezone(tz).strftime('%Y-%m-%d')
if customer.visit_hold_until else 'None',
'بله' if customer.pk in manual_holds_map() else 'خیر',
sorted((appointment_sources_map().get(customer.pk) or {}).keys()) or '—',
))
def claim_1_silent_failure():
"""نقص ۲ — آیا `_apply_visit_hold` نتیجه را نادیده می‌گیرد؟"""
from django.utils import timezone
from admin_panel.views.actions import _apply_visit_hold
from admin_panel.visit_policy import set_visit_hold
from admin_panel.models import Action
print('\n' + '=' * 92)
print('ادعای ۱ (نقص ۲): نتیجهٔ set_visit_hold در لایهٔ فراخوان دور ریخته می‌شود')
now = timezone.now()
customer = _new_customer('نقص۲')
set_visit_hold(customer, now + datetime.timedelta(days=20))
far = customer.visit_hold_until
_show('پس از ثبت قرار ۲۰ روزه', customer)
written = set_visit_hold(customer, now + datetime.timedelta(days=5))
print(' set_visit_hold(5 روز) ⇒ %s (مقدار بولی واقعی)' % written)
customer.refresh_from_db()
print(' ستون تغییر کرد؟ %s' % ('بله' if customer.visit_hold_until != far else 'خیر'))
result = _apply_visit_hold(
customer,
{'actionType': Action.ActionType.HAMAHANGI,
'holdRaw': now + datetime.timedelta(days=5),
'status': Action.Status.FINISH},
status=Action.Status.FINISH, is_new=False,
)
print(' _apply_visit_hold ⇒ %s' % result)
silent = result.get('reason') != 'forward_only'
print(' نتیجه: نقص ۲ %s' % ('**هنوز وجود دارد** (نتیجه گزارش نمی‌شود)'
if silent else '**رفع شد** (reason=forward_only گزارش می‌شود)'))
def claim_2_no_success_message():
"""آیا کاربر بازخورد می‌بیند؟ (پس از رفع: پیام هشدار با دلیل و تاریخ‌ها)"""
import inspect
from admin_panel.views import actions as views
print('\n' + '=' * 92)
print('بازخورد کاربر در مسیر ویرایش/ایجاد')
edit_src = inspect.getsource(views.customerEditAction)
create_src = inspect.getsource(views.customerNewAction)
helper_src = inspect.getsource(views._report_hold_result)
print(' customerEditAction نتیجهٔ قرار را گزارش می‌کند؟ %s'
% ('_report_hold_result' in edit_src))
print(' customerNewAction نتیجهٔ قرار را گزارش می‌کند؟ %s'
% ('_report_hold_result' in create_src))
print(' پیام هشدار شامل دلیل و تاریخ‌ها است؟ %s'
% ('ذخیره نشد' in helper_src))
print(' پیام موفقیتِ گمراه‌کننده وجود دارد؟ %s'
% ('messages.success' in edit_src or 'messages.success' in create_src))
def claim_3_stale_column_lock():
"""نقص ۳ — قفل شدن ستون بعد از حذف اقدام منبع."""
from django.utils import timezone
from admin_panel.visit_policy import (manual_holds_map, set_visit_hold,
soft_delete_action)
print('\n' + '=' * 92)
print('ادعای ۳ (نقص ۳): حذف اقدام منبع ستون را پاک نمی‌کند و نوشتن را قفل می‌کند')
now = timezone.now()
customer = _new_customer('نقص۳')
action = _coordination(customer, days=60)
set_visit_hold(customer, now + datetime.timedelta(days=60))
customer.refresh_from_db()
_show('قرار ۶۰ روزه ثبت شد', customer)
soft_delete_action(action)
action.refresh_from_db()
customer.refresh_from_db()
print(' اقدام منبع ⇒ status=%s deleted_at=%s' % (action.status, action.deleted_at))
_show('بعد از حذف (ستون کهنه ولی پر)', customer)
print(' در manual_holds_map هست؟ %s' % (customer.pk in manual_holds_map()))
ok = set_visit_hold(customer, now + datetime.timedelta(days=10))
customer.refresh_from_db()
print(' set_visit_hold(10 روز) ⇒ %s' % ok)
_show('پس از تلاش برای تاریخ نزدیک‌تر', customer)
print(' نتیجه: قفل %s' % ('تأیید می‌شود' if ok is False else 'رد می‌شود'))
def claim_4_escape_hatch():
"""آیا مسیر عادیِ رهاسازی وجود دارد؟ (ادعا: هیچ)"""
from django.utils import timezone
from admin_panel.models import Action
from admin_panel.views.actions import _apply_visit_hold
from admin_panel.visit_policy import set_visit_hold, soft_delete_action
print('\n' + '=' * 92)
print('ادعای ۴: «هیچ مسیر عادی‌ای برای رهاسازی وجود ندارد»')
now = timezone.now()
customer = _new_customer('رهاسازی')
action = _coordination(customer, days=60)
set_visit_hold(customer, now + datetime.timedelta(days=60))
soft_delete_action(action)
customer.refresh_from_db()
_show('قفل‌شده پس از حذف اقدام', customer)
# ثبت یک اقدام **غیرهماهنگی** جدید ⇒ is_new=True ⇒ clear_visit_hold
cleared = _apply_visit_hold(
customer,
{'actionType': Action.ActionType.OUT_CALL, 'holdRaw': None,
'status': Action.Status.WAITE},
status=Action.Status.WAITE, is_new=True,
)
customer.refresh_from_db()
print(' ثبت «تماس خروجی» جدید ⇒ clear_visit_hold: %s | ستون=%s'
% (cleared.get('reason') or cleared.get('ok'), customer.visit_hold_until))
ok = set_visit_hold(customer, now + datetime.timedelta(days=10))
customer.refresh_from_db()
_show('قرار ۱۰ روزه پس از رهاسازی', customer)
print(' ⇒ %s' % ('ادعا **رد** می‌شود: مسیر وجود دارد (ولی غیرشهودی و داده‌آلوده)'
if ok else 'ادعا تأیید می‌شود'))
def claim_5_edit_to_visit():
"""مسیری که در گزارش نیامده: ویرایش هماهنگی → «بازدید» ستون را پاک نمی‌کند."""
from django.utils import timezone
from admin_panel.models import Action
from admin_panel.views.actions import _apply_visit_hold
from admin_panel.visit_policy import set_visit_hold
print('\n' + '=' * 92)
print('یافتهٔ اضافی: ویرایش هماهنگی → «بازدید» ⇒ ستون می‌ماند ولی اعتبارش می‌رود')
now = timezone.now()
customer = _new_customer('ویرایش-به-بازدید')
action = _coordination(customer, days=60)
set_visit_hold(customer, now + datetime.timedelta(days=60))
customer.refresh_from_db()
action.actionType = Action.ActionType.BAZDID
action.status = Action.Status.FINISH
action.finishDateTime = now
action.save()
_apply_visit_hold(
customer,
{'actionType': Action.ActionType.BAZDID, 'holdRaw': None,
'status': Action.Status.FINISH},
status=Action.Status.FINISH, is_new=False,
)
customer.refresh_from_db()
_show('پس از ویرایش به بازدید', customer)
ok = set_visit_hold(customer, now + datetime.timedelta(days=7))
print(' set_visit_hold(7 روز) ⇒ %s' % ok)
print(' ⇒ %s' % ('قفل، حتی با ستونِ بی‌اعتبار — شدت نقص ۳ بیشتر از تخمین گزارش است'
if ok is False else 'قفل نشد'))
def real_db_stats():
"""آمار واقعی: چند مشتری ستون پرشدهٔ «بی‌اعتبار» دارند؟"""
from django.utils import timezone
from admin_panel.models import Action, Customer
from admin_panel.visit_policy import manual_holds_map
print('\n' + '=' * 92)
print('آمار دیتابیس واقعی (روی کپی)')
tz = timezone.get_current_timezone()
now = timezone.now()
future = list(Customer.objects.filter(visit_hold_until__gt=now))
valid = set(manual_holds_map())
dead = [c for c in future if c.pk not in valid]
print(' ستون پرشده و آینده: %d | معتبر: %d | بی‌اعتبار (قفل‌کننده): %d'
% (len(future), len(valid), len(dead)))
for c in dead[:10]:
last = Action.objects.filter(
customer=c, actionType=Action.ActionType.HAMAHANGI,
).order_by('-createdDT').first()
print(' id=%-6s idCode=%-10s hold=%-11s آخرین هماهنگی=%s (%s)' % (
c.id, c.idCode,
c.visit_hold_until.astimezone(tz).date(),
last.status if last else '—',
last.createdDT.date() if last else '—'))
print(' هماهنگی حذف/لغوشده با تاریخ آینده (کاندید قفل آینده): %d'
% Action.objects.filter(
actionType=Action.ActionType.HAMAHANGI, status=Action.Status.CANCELL,
actionDateTime__gt=now).count())
def main():
parser = argparse.ArgumentParser(description='صحت‌سنجی نقص‌های set_visit_hold')
parser.add_argument('--skip-stats', action='store_true')
args = parser.parse_args()
import django
django.setup()
from django.conf import settings
from django.db import connections
source_db = str(settings.DATABASES['default']['NAME'])
if not os.path.isabs(source_db):
source_db = os.path.join(BASE_DIR, source_db)
tmp_dir = tempfile.mkdtemp(prefix='hold_verify_')
settings.DATABASES['default']['NAME'] = _copy_db(source_db, tmp_dir)
connections.close_all()
print('دیتابیسِ کپیِ موقت: %s' % settings.DATABASES['default']['NAME'])
claim_1_silent_failure()
claim_2_no_success_message()
claim_3_stale_column_lock()
claim_4_escape_hatch()
claim_5_edit_to_visit()
if not args.skip_stats:
real_db_stats()
shutil.rmtree(tmp_dir, ignore_errors=True)
if __name__ == '__main__':
main()
File diff suppressed because it is too large. Load diff
+416 -4
View File
@@ -2177,14 +2177,14 @@ class EventTriggerTests(PanelTestCase):
mark_primary(customer, '09120000000')
return customer
# ---------- قرارداد: دقیقاً ۷ نوع ----------
# ---------- قرارداد: دقیقاً ۸ نوع ----------
def test_exactly_seven_sms_types(self):
self.assertEqual(len(SMSType.choices), 7)
def test_exactly_eight_sms_types(self):
self.assertEqual(len(SMSType.choices), 8)
self.assertEqual(
{code for code, _label in SMSType.choices},
{'welcome', 'survey', 'cheque_registration', 'visit',
'visit_created', 'payment_thanks', 'nesiye_due'},
'visit_created', 'install_created', 'payment_thanks', 'nesiye_due'},
)
def test_removed_types_have_no_template(self):
@@ -2198,6 +2198,15 @@ class EventTriggerTests(PanelTestCase):
seeded = set(SMSTemplate.objects.values_list('sms_type', flat=True))
self.assertEqual(seeded, {code for code, _label in SMSType.choices})
def test_install_template_is_seeded_with_auto_send_off(self):
"""قالب «ثبت نصب» فعال است ولی «ارسال خودکار» آن خاموش (نیازمند تأیید)."""
template = SMSTemplate.objects.get(sms_type=SMSType.INSTALL_CREATED)
self.assertTrue(template.is_enabled)
self.assertFalse(template.auto_send)
self.assertEqual(template.offset_days, 0)
self.assertIn('{name}', template.text)
self.assertIn('{date}', template.text)
# ---------- نظرسنجی + تشکر از پرداخت نقدی روی فاکتور ----------
def _post_factor(self, customer, payments):
@@ -2284,6 +2293,244 @@ class EventTriggerTests(PanelTestCase):
SMSQueue.objects.filter(sms_type=SMSType.VISIT_CREATED).exists(),
)
# ---------- تبدیل نوع اقدام در «ویرایش» ----------
# باگ گزارش‌شده: تریگر «بازدید ثبت شد» فقط در ویوی ایجاد صدا زده می‌شد، پس
# تبدیل یک اقدام «هماهنگی» به «بازدید» هیچ پیامکی نمی‌فرستاد.
def _visit_sms(self, customer):
"""همهٔ ردیف‌های «ثبت بازدید» (هر وضعیتی) — برای شمارش کل."""
return SMSQueue.objects.filter(customer=customer,
sms_type=SMSType.VISIT_CREATED)
def _pending_visit_sms(self, customer):
"""فقط ردیف‌هایی که worker ممکن است بفرستد (در انتظار/تأییدشده)."""
return self._visit_sms(customer).filter(
status__in=(SMSQueue.Status.PENDING, SMSQueue.Status.APPROVED),
)
def _edit_action(self, customer, action_type, *, is_done=True):
"""ویرایش اولین اقدام مشتری با نوع جدید (از مسیر واقعی ویو)."""
from admin_panel.models import Action
action = Action.objects.filter(customer=customer).order_by('id').first()
url = reverse('admin_panel:customerEditAction',
kwargs={'c_id': customer.id, 'action_id': action.id})
payload = {
'actionType': action_type,
'dateTimeAnjam': '1700000000000',
'desc': 'ویرایش', 'userID': self.user.id,
'FinishDateTime': '1700000000000',
}
if is_done:
payload['is_done'] = 'on'
response = self.client.post(
url, payload,
HTTP_REFERER=reverse('admin_panel:customerAction',
kwargs={'c_id': customer.id}),
)
action.refresh_from_db()
return response, action
def test_editing_coordination_to_bazdid_enqueues_visit_created(self):
"""هماهنگی → بازدید در ویرایش ⇒ پیامک «ثبت بازدید» باید ساخته شود."""
customer = self._customer_with_phone()
self._post_action(customer, 'hamahangi')
self.assertEqual(self._visit_sms(customer).count(), 0)
response, action = self._edit_action(customer, 'bazdid')
self.assertEqual(response.status_code, 302)
self.assertEqual(action.actionType, 'bazdid')
self.assertEqual(self._visit_sms(customer).count(), 1)
# کلید یکتاسازی همان رکورد اقدام است (پس ویرایش دوباره تکرار نمی‌کند).
self.assertEqual(self._visit_sms(customer).first().dedup_key,
'visit_created:%d' % action.id)
def test_editing_to_bazdid_twice_does_not_duplicate_sms(self):
"""ویرایش دوبارهٔ همان رکورد نباید پیامک دوم بسازد (dedup روی action)."""
customer = self._customer_with_phone()
self._post_action(customer, 'hamahangi')
self._edit_action(customer, 'bazdid')
# برگشت به هماهنگی و تبدیل دوباره به بازدید: پیامک معلق لغو شده، ولی
# کلید دفتر dedup خورده است ⇒ پیامک تازه ساخته نمی‌شود.
self._edit_action(customer, 'hamahangi')
self._edit_action(customer, 'bazdid')
self.assertEqual(self._visit_sms(customer).count(), 1)
def test_editing_bazdid_to_other_type_cancels_pending_sms(self):
"""بازدید → هماهنگی در ویرایش ⇒ پیامک معلق «ثبت بازدید» لغو می‌شود."""
customer = self._customer_with_phone()
self._post_action(customer, 'bazdid')
self.assertEqual(self._pending_visit_sms(customer).count(), 1)
self._edit_action(customer, 'hamahangi')
# ردیف حذف نمی‌شود (سابقه می‌ماند) ولی دیگر ارسال‌شدنی نیست.
self.assertEqual(self._pending_visit_sms(customer).count(), 0)
self.assertEqual(
self._visit_sms(customer).first().status, SMSQueue.Status.CANCELLED,
)
def test_editing_other_fields_of_bazdid_sends_no_new_sms(self):
"""ویرایش عادی یک اقدام بازدید (بدون تغییر نوع) پیامک تازه نمی‌فرستد."""
customer = self._customer_with_phone()
self._post_action(customer, 'bazdid')
self.assertEqual(self._visit_sms(customer).count(), 1)
self._edit_action(customer, 'bazdid')
self.assertEqual(self._visit_sms(customer).count(), 1)
def test_editing_bazdid_keeps_other_actions_pending_sms(self):
"""لغو پیامک معلق، فقط رکورد ویرایش‌شده را هدف می‌گیرد (نه بقیهٔ بازدیدها)."""
from admin_panel.models import Action
from admin_panel.sms.triggers import notify_visit_scheduled
customer = self._customer_with_phone()
self._post_action(customer, 'bazdid') # رکورد ۱ + پیامک
first = Action.objects.filter(customer=customer).order_by('id').first()
second = Action.objects.create(
customer=customer, actionType='bazdid', status='finish',
actionDateTime=timezone.now(), finishDateTime=timezone.now(),
)
notify_visit_scheduled(customer, second, created_by=self.user) # پیامک رکورد ۲
self.assertEqual(self._pending_visit_sms(customer).count(), 2)
# رکورد دوم را از بازدید به هماهنگی تغییر می‌دهیم ⇒ فقط پیامک رکورد ۲ لغو شود.
url = reverse('admin_panel:customerEditAction',
kwargs={'c_id': customer.id, 'action_id': second.id})
self.client.post(url, {
'actionType': 'hamahangi', 'dateTimeAnjam': '1700000000000',
'FinishDateTime': '1700000000000', 'desc': '', 'userID': self.user.id,
'is_done': 'on',
}, HTTP_REFERER=reverse('admin_panel:customerAction',
kwargs={'c_id': customer.id}))
keys = set(self._pending_visit_sms(customer).values_list('dedup_key', flat=True))
self.assertEqual(keys, {'visit_created:%d' % first.id})
# ---------- ثبت اقدام از نوع «نصب» ----------
def _install_sms(self, customer):
"""همهٔ ردیف‌های «ثبت نصب» (هر وضعیتی)."""
return SMSQueue.objects.filter(customer=customer,
sms_type=SMSType.INSTALL_CREATED)
def _pending_install_sms(self, customer):
return self._install_sms(customer).filter(
status__in=(SMSQueue.Status.PENDING, SMSQueue.Status.APPROVED),
)
def test_nasb_action_enqueues_install_created(self):
"""اقدام «نصب» ⇒ پیامک «ثبت نصب» از قالب install_created."""
customer = self._customer_with_phone()
response = self._post_action(customer, 'nasb')
self.assertIn(response.status_code, (200, 302))
item = self._install_sms(customer)
self.assertEqual(item.count(), 1)
self.assertIn('نصب', item.first().message)
# auto_send قالب خاموش است ⇒ ردیف «در انتظار تأیید» می‌ماند، نه ارسال فوری.
self.assertEqual(item.first().status, SMSQueue.Status.PENDING)
# پیامک بازدید ساخته نمی‌شود (دو نوع مستقل).
self.assertEqual(self._pending_visit_sms(customer).count(), 0)
def test_nasb_action_sms_has_its_own_dedup_key(self):
"""کلید یکتاسازی با پیشوند نوع خودش است (با visit_created قاطی نمی‌شود)."""
from admin_panel.models import Action
customer = self._customer_with_phone()
self._post_action(customer, 'nasb')
action = Action.objects.filter(customer=customer).order_by('id').first()
self.assertEqual(self._install_sms(customer).first().dedup_key,
'install_created:%d' % action.id)
def test_non_nasb_action_has_no_install_created(self):
customer = self._customer_with_phone()
self._post_action(customer, 'bazdid')
self.assertFalse(self._install_sms(customer).exists())
def test_editing_to_nasb_enqueues_install_created(self):
"""هماهنگی → نصب در ویرایش ⇒ پیامک «ثبت نصب» ساخته می‌شود."""
customer = self._customer_with_phone()
self._post_action(customer, 'hamahangi')
self.assertEqual(self._install_sms(customer).count(), 0)
response, action = self._edit_action(customer, 'nasb')
self.assertEqual(response.status_code, 302)
self.assertEqual(action.actionType, 'nasb')
self.assertEqual(self._pending_install_sms(customer).count(), 1)
def test_editing_nasb_to_other_type_cancels_pending_install_sms(self):
"""نصب → هماهنگی در ویرایش ⇒ پیامک معلق «ثبت نصب» لغو می‌شود."""
customer = self._customer_with_phone()
self._post_action(customer, 'nasb')
self.assertEqual(self._pending_install_sms(customer).count(), 1)
self._edit_action(customer, 'hamahangi')
self.assertEqual(self._pending_install_sms(customer).count(), 0)
self.assertEqual(
self._install_sms(customer).first().status, SMSQueue.Status.CANCELLED,
)
def test_editing_bazdid_to_nasb_switches_sms_type(self):
"""بازدید → نصب ⇒ پیامک بازدید لغو و پیامک نصب ساخته می‌شود (نه هر دو)."""
customer = self._customer_with_phone()
self._post_action(customer, 'bazdid')
self.assertEqual(self._pending_visit_sms(customer).count(), 1)
self._edit_action(customer, 'nasb')
self.assertEqual(self._pending_visit_sms(customer).count(), 0)
self.assertEqual(self._pending_install_sms(customer).count(), 1)
def test_install_template_can_be_disabled_from_templates_page(self):
"""تنظیمات «ثبت نصب» از صفحهٔ قالب‌ها: غیرفعال‌کردن ⇒ پیامکی ساخته نمی‌شود."""
template = SMSTemplate.objects.get(sms_type=SMSType.INSTALL_CREATED)
response = self.client.post(
reverse('admin_panel:sms_template_edit', kwargs={'t_id': template.id}),
{'text': template.text, 'title': template.title},
)
self.assertEqual(response.status_code, 302)
template.refresh_from_db()
self.assertFalse(template.is_enabled)
customer = self._customer_with_phone()
self._post_action(customer, 'nasb')
self.assertFalse(self._install_sms(customer).exists())
def test_install_template_text_is_editable_from_templates_page(self):
"""ویرایش متن قالب از پنل ⇒ همان متن در پیامک ساخته‌شده می‌آید."""
template = SMSTemplate.objects.get(sms_type=SMSType.INSTALL_CREATED)
response = self.client.post(
reverse('admin_panel:sms_template_edit', kwargs={'t_id': template.id}),
{'text': '{name} عزیز، نصب دستگاه انجام شد ({date}).',
'title': 'ثبت نصب', 'is_enabled': 'on'},
)
self.assertEqual(response.status_code, 302)
customer = self._customer_with_phone()
self._post_action(customer, 'nasb')
message = self._install_sms(customer).first().message
self.assertIn('نصب دستگاه انجام شد', message)
self.assertIn(customer.name_family, message)
def test_install_sms_disabled_per_customer_is_not_created(self):
"""opt-out مشتری برای نوع install_created رعایت می‌شود."""
from admin_panel.models import CustomerSMSSetting
customer = self._customer_with_phone()
CustomerSMSSetting.objects.create(
customer=customer, sms_type=SMSType.INSTALL_CREATED, is_enabled=False,
)
self._post_action(customer, 'nasb')
self.assertFalse(self._install_sms(customer).exists())
def test_install_template_can_be_enabled_for_auto_send(self):
"""اگر مدیر «ارسال خودکار» را روشن کند، ردیف مستقیماً تأییدشده ساخته می‌شود."""
SMSTemplate.objects.filter(sms_type=SMSType.INSTALL_CREATED).update(
auto_send=True,
)
customer = self._customer_with_phone()
self._post_action(customer, 'nasb')
self.assertEqual(
self._install_sms(customer).first().status, SMSQueue.Status.APPROVED,
)
# ---------- تشکر از پرداخت (وصول چک، نسیه، سند دستی) ----------
def test_pass_check_enqueues_payment_thanks(self):
@@ -2372,6 +2619,85 @@ class EventTriggerTests(PanelTestCase):
)
class InstallTemplateMigrationTests(TestCase):
"""مهاجرت ۰۰۸۱: قالب‌های موجود دست‌نخورده می‌مانند، «ثبت نصب» بدون ارسال خودکار.
توابع مهاجرت مستقیم و با همان ``apps`` واقعی صدا زده می‌شوند (بدون اجرای
schema)، تا در تراکنش تست انجام و بازگردانی شود.
"""
MODULE = 'admin_panel.migrations.0081_sms_type_install_created'
FIELDS = ('sms_type', 'title', 'text', 'is_enabled', 'auto_send',
'offset_days', 'repeat_interval_days', 'repeat_interval_months')
class _Apps:
"""جایگزین ``schema_editor.apps``: نگاشت به مدل‌های واقعی پروژه."""
@staticmethod
def get_model(app_label, model_name):
from django.apps import apps as real_apps
return real_apps.get_model(app_label, model_name)
def _migration(self):
from importlib import import_module
return import_module(self.MODULE)
def _snapshot(self):
return {row['sms_type']: row
for row in SMSTemplate.objects.values(*self.FIELDS)}
def _seed(self):
self._migration().seed_install_template(self._Apps, None)
def test_seed_leaves_existing_templates_untouched(self):
SMSTemplate.objects.filter(sms_type=SMSType.INSTALL_CREATED).delete()
# مقادیر غیرپیش‌فرض؛ اگر مهاجرت هر فیلدی را بازنویسی کند دیده می‌شود.
(SMSTemplate.objects.filter(sms_type=SMSType.VISIT).update(
title='عنوان دستی', text='متن دستی {name}', is_enabled=False,
auto_send=True, offset_days=3, repeat_interval_months=6,
))
before = self._snapshot()
self._seed()
after = self._snapshot()
for sms_type, row in before.items():
self.assertEqual(after[sms_type], row, msg=sms_type)
self.assertIn(SMSType.INSTALL_CREATED, after)
def test_seeded_install_template_has_auto_send_off(self):
SMSTemplate.objects.filter(sms_type=SMSType.INSTALL_CREATED).delete()
self._seed()
template = SMSTemplate.objects.get(sms_type=SMSType.INSTALL_CREATED)
self.assertTrue(template.is_enabled, msg='باید در پنل قالب‌ها دیده شود')
self.assertFalse(template.auto_send, msg='نباید بدون تأیید مدیر ارسال شود')
self.assertEqual(template.offset_days, 0)
self.assertIsNone(template.repeat_interval_days)
self.assertIsNone(template.repeat_interval_months)
def test_seed_is_idempotent_for_an_existing_install_template(self):
"""اگر قالب از قبل وجود داشته باشد، متن/تنظیماتش بازنویسی نمی‌شود."""
template = SMSTemplate.objects.get(sms_type=SMSType.INSTALL_CREATED)
SMSTemplate.objects.filter(pk=template.pk).update(
text='متن ویرایش‌شدهٔ مدیر {name}', auto_send=True, offset_days=5,
)
before = self._snapshot()
self._seed()
self.assertEqual(self._snapshot(), before)
def test_reverse_only_removes_the_new_type(self):
before = self._snapshot()
self._migration().drop_install_template(self._Apps, None)
after = self._snapshot()
self.assertNotIn(SMSType.INSTALL_CREATED, after)
self.assertEqual(set(after), set(before) - {SMSType.INSTALL_CREATED})
for sms_type, row in after.items():
self.assertEqual(row, before[sms_type], msg=sms_type)
class CustomerSmsHistoryTests(PanelTestCase):
"""تب «سوابق پیامک» در صفحهٔ مشتری: متن دقیق، تاریخ شمسی، وضعیت تحویل، صفحه‌بندی."""
@@ -2468,6 +2794,92 @@ class CustomerSmsHistoryTests(PanelTestCase):
html = self.client.get(self._url()).content.decode('utf-8')
self.assertIn('پیامکی برای این مشتری ثبت نشده است', html)
# ---------- انتقال تنظیمات پیامک از صفحهٔ فاکتور به این صفحه ----------
def test_factor_page_no_longer_contains_sms_settings_box(self):
"""باکس «پیامک» از صفحهٔ ایجاد فاکتور حذف شده (تنظیمات به تب پیامک رفت)."""
html = self.client.get(
reverse('admin_panel:customerNew', kwargs={'c_id': self.customer.id}),
).content.decode('utf-8')
self.assertNotIn(
reverse('admin_panel:sms_send_custom', kwargs={'c_id': self.customer.id}),
html,
)
self.assertNotIn(
reverse('admin_panel:sms_customer_toggle', kwargs={'c_id': self.customer.id}),
html,
)
def test_sms_page_holds_all_settings_controls(self):
"""همهٔ کنترل‌های تنظیمات روی صفحهٔ پیامک هستند: نوع‌ها، شمارهٔ اصلی، ارسال."""
html = self.client.get(self._url()).content.decode('utf-8')
self.assertIn(
reverse('admin_panel:sms_customer_toggle', kwargs={'c_id': self.customer.id}),
html,
)
self.assertIn(
reverse('admin_panel:sms_primary_toggle', kwargs={'c_id': self.customer.id}),
html,
)
self.assertIn(
reverse('admin_panel:sms_send_custom', kwargs={'c_id': self.customer.id}),
html,
)
# یک ردیف برای هر نوع پیامک (۸ نوع) و یک کلید for each.
self.assertEqual(html.count('name="sms_type"'), len(SMSType.choices))
self.assertEqual(html.count('role="switch"'), len(SMSType.choices))
def test_settings_live_in_modals_not_in_history_flow(self):
"""تنظیمات داخل مودال است تا فهرست سوابق شلوغ نشود."""
html = self.client.get(self._url()).content.decode('utf-8')
self.assertIn('id="smsSettingsModal"', html)
self.assertIn('id="smsCustomModal"', html)
self.assertIn('js-csmsx-open', html)
def test_stats_are_computed_for_the_header(self):
self._item(status=SMSQueue.Status.SENT)
self._item(status=SMSQueue.Status.PENDING)
self._item(status=SMSQueue.Status.FAILED)
response = self.client.get(self._url())
self.assertEqual(response.context['stat_sent'], 1)
self.assertEqual(response.context['stat_pending'], 1)
self.assertEqual(response.context['stat_failed'], 1)
self.assertEqual(response.context['total_count'], 3)
def test_toggle_redirects_back_to_sms_page_with_open_settings(self):
"""پس از تغییر تنظیم، کاربر در همان صفحه/مودال می‌ماند (نه صفحهٔ مشتری)."""
response = self.client.post(
reverse('admin_panel:sms_customer_toggle', kwargs={'c_id': self.customer.id}),
{'sms_type': SMSType.WELCOME, 'is_enabled': 'false'},
)
self.assertEqual(response.status_code, 302)
self.assertTrue(response.url.startswith(self._url()), response.url)
self.assertIn('open=settings', response.url)
self.assertIn('notice=customer_sms_saved', response.url)
def test_custom_send_redirects_back_to_sms_page(self):
mark_primary(self.customer, '09120000000')
response = self.client.post(
reverse('admin_panel:sms_send_custom', kwargs={'c_id': self.customer.id}),
{'text': 'سلام {name}'},
)
self.assertEqual(response.status_code, 302)
self.assertTrue(response.url.startswith(self._url()), response.url)
self.assertIn('notice=custom_queued', response.url)
def test_primary_toggle_redirects_back_to_sms_page(self):
response = self.client.post(
reverse('admin_panel:sms_primary_toggle', kwargs={'c_id': self.customer.id}),
{'phone': '09120000000', 'is_primary': 'true'},
)
self.assertEqual(response.status_code, 302)
self.assertTrue(response.url.startswith(self._url()), response.url)
def test_open_settings_query_keeps_modal_available(self):
response = self.client.get(self._url() + '?open=settings')
self.assertEqual(response.status_code, 200)
self.assertIn('id="smsSettingsModal"', response.content.decode('utf-8'))
class SmsPartsTests(TestCase):
"""محاسبهٔ «تعداد پیامکِ محاسبه‌شده» (parts) بر اساس کدگذاری GSM-7 / UCS-2."""
+2
View File
@@ -36,6 +36,7 @@ EXPECTED_URL_NAMES = {
'customerNazar',
'customerNewAction',
'customerEditAction',
'customerActionDelete',
'customerDel',
'customerFactors',
'customerFactor',
@@ -99,6 +100,7 @@ PARAMETERIZED = {
'customerNazar': {'c_id': 1},
'customerNewAction': {'c_id': 1},
'customerEditAction': {'c_id': 1, 'action_id': 1},
'customerActionDelete': {'c_id': 1, 'action_id': 1},
'customerDel': {'c_id': 1},
'customerFactors': {'c_id': 1},
'customerFactor': {'c_id': 1, 'f_id': 1},
+270 -4
View File
@@ -1417,8 +1417,10 @@ class CoordinationDoneConfirmTests(TestCase):
self.assertIn('holdAutoChecked = false', block)
def test_confirmation_is_gated_before_submit(self):
block = self.html.split('$("#ActionBox").on(\'submit\'', 1)[1]
block = block.split('});', 1)[0]
# هندلر «انجام شد» دومی است؛ هندلر اول مربوط به «عقب‌بردن تاریخ قرار»
# است. پس بلوک را از هندلری می‌گیریم که به showHoldConfirm می‌رسد.
block = self.html.split('showHoldConfirm();', 1)[0].rsplit(
'$("#ActionBox").on(\'submit\'', 1)[1]
# سه شرط خروج زودهنگام: ویرایش/تأییدشده، نوع غیر هماهنگی، تیک نخورده.
self.assertIn('isEditMode || holdConfirmed', block)
self.assertIn("!== 'hamahangi'", block)
@@ -2010,8 +2012,12 @@ class TodayFollowupTests(TestCase):
# ---------- ستون «تأخیر» فقط در نمای پیگیری مخفی ----------
def test_overdue_column_hidden_only_in_followup_view(self):
customer = self._customer('ستون تأخیر')
self._coordination(customer, 0)
# مشتری بدون هیچ قرار: در «تعیین تکلیف نشده» ردیف دارد، پس سلول ستون
# «تأخیر» رندر می‌شود. (نسخهٔ قبلی از یک هماهنگیِ «امروز ساعت ۱۲»
# استفاده می‌کرد؛ اگر تست پیش از ظهر اجرا می‌شد آن هماهنگی «آینده»
# شمرده می‌شد، مشتری از فهرست خارج می‌شد، جدول خالی می‌ماند و تست
# به ساعتِ اجرا وابسته بود.)
self._customer('ستون تأخیر')
followup_html = self.client.get(self.URL + '?view=today').content.decode()
self.assertNotIn('data-label="تأخیر"', followup_html)
@@ -2106,3 +2112,263 @@ class TodayFollowupTests(TestCase):
self._coordination(customer, -4)
html = self.client.get(self.URL + '?view=uncoordinated').content.decode()
self.assertNotIn('class="ez-overdue-alert"', html)
class ActionSoftDeleteTests(TestCase):
"""حذف (soft-delete) یک اقدام: status='cancell' + حسابرسی.
قواعد قفل‌شده:
* حذف ⇒ اقدام از `entezar`، `ActionList` و داشبورد خارج می‌شود.
* رکورد فیزیکی حذف **نمی‌شود** (سابقه برای حسابرسی می‌ماند).
* اقدام دارای نظرسنجی قابل حذف نیست.
* پیامک ارسال‌نشدهٔ مرتبط لغو می‌شود.
* `last_bazdid_dt` بازمحاسبه می‌شود.
* GET بی‌اثر است؛ URL فقط POST می‌پذیرد.
"""
def setUp(self):
self.user = make_user('deleter', superuser=True)
self.client.force_login(self.user)
self.customer = make_customer('مشتری حذف')
Customer.objects.filter(pk=self.customer.pk).update(
installDate=timezone.localdate() - timedelta(days=500))
self.customer.refresh_from_db()
def _action(self, **kwargs):
defaults = dict(customer=self.customer, actionType='hamahangi',
status='finish', actionDateTime=timezone.now(),
finishDateTime=timezone.now())
defaults.update(kwargs)
return Action.objects.create(**defaults)
def _delete(self, action):
return self.client.post(
reverse('admin_panel:customerActionDelete',
args=[self.customer.pk, action.pk]))
def _followup_ids(self):
response = self.client.get('/panel/entezar/?view=today')
return {o.id for o in response.context['customers']}
# ---------- رفتار اصلی ----------
def test_delete_marks_cancelled_with_audit(self):
action = self._action()
self._delete(action)
action.refresh_from_db()
self.assertEqual(action.status, 'cancell')
self.assertIsNotNone(action.deleted_at)
self.assertEqual(action.deleted_by_id, self.user.pk)
self.assertTrue(action.is_deleted)
def test_record_is_not_physically_deleted(self):
action = self._action()
pk = action.pk
self._delete(action)
self.assertTrue(Action.objects.filter(pk=pk).exists(),
'رکورد باید برای حسابرسی باقی بماند')
def test_deleted_action_leaves_followup_list(self):
action = self._action()
self.assertIn(self.customer.id, self._followup_ids())
self._delete(action)
self.assertNotIn(self.customer.id, self._followup_ids())
def test_get_request_does_not_delete(self):
action = self._action()
response = self.client.get(reverse(
'admin_panel:customerActionDelete',
args=[self.customer.pk, action.pk]))
self.assertEqual(response.status_code, 302)
action.refresh_from_db()
self.assertIsNone(action.deleted_at)
def test_cannot_delete_twice(self):
action = self._action()
self._delete(action)
# بار دوم باید خطا بدهد ولی کرش نکند
response = self._delete(action)
self.assertEqual(response.status_code, 302)
action.refresh_from_db()
self.assertEqual(action.status, 'cancell')
# ---------- ممنوعیت نظرسنجی ----------
def test_action_with_survey_cannot_be_deleted(self):
action = self._action(nazarDT=timezone.now(), nazar='خوب', grade=20)
self._delete(action)
action.refresh_from_db()
self.assertEqual(action.status, 'finish', 'نباید وضعیت عوض شود')
self.assertIsNone(action.deleted_at)
def test_delete_blocked_message_shown(self):
action = self._action(nazarDT=timezone.now(), nazar='خوب', grade=20)
response = self._delete(action)
messages_list = list(response.wsgi_request._messages)
self.assertTrue(any('نظرسنجی' in str(m) for m in messages_list))
# ---------- پیامک ----------
def test_pending_visit_sms_is_cancelled(self):
from admin_panel.models import SMSQueue, SMSTemplate
SMSTemplate.objects.update_or_create(
sms_type='visit_created',
defaults={'text': 'بازدید {name}', 'is_enabled': True})
PhoneNumber.objects.create(customer=self.customer, title='موبایل',
phone_number='09120000000')
Customer.objects.filter(pk=self.customer.pk).update(phone1='09120000000')
self.customer.refresh_from_db()
from admin_panel.sms.triggers import notify_visit_scheduled
action = self._action(actionType='bazdid', status='waite')
notify_visit_scheduled(self.customer, action, created_by=self.user)
self.assertEqual(
SMSQueue.objects.filter(customer=self.customer,
sms_type='visit_created').count(), 1)
self._delete(action)
self.assertEqual(
SMSQueue.objects.filter(customer=self.customer,
sms_type='visit_created',
status=SMSQueue.Status.CANCELLED).count(), 1)
# ---------- بازمحاسبهٔ ستون ----------
def test_last_bazdid_recalculated_after_delete(self):
old_visit = self._action(actionType='bazdid',
actionDateTime=at(-400), finishDateTime=at(-400))
recent = self._action(actionType='bazdid',
actionDateTime=at(-2), finishDateTime=at(-2))
refresh_last_bazdid(self.customer, reason='create', user=self.user)
self.customer.refresh_from_db()
self.assertEqual(self.customer.last_bazdid_dt.date(), at(-2).date())
self._delete(recent)
self.customer.refresh_from_db()
self.assertEqual(self.customer.last_bazdid_dt.date(), at(-400).date())
self.assertTrue(Action.objects.filter(pk=old_visit.pk).exists())
# ---------- سایر نماها ----------
def test_deleted_action_hidden_from_customer_action_list(self):
action = self._action()
self._delete(action)
html = self.client.get(
'/panel/customer/%d/action' % self.customer.pk).content.decode()
self.assertNotIn('action_id=\'%d\'' % action.pk, html)
def test_deleted_action_visible_with_explicit_filter(self):
"""با فیلتر صریح status=cancell باید دیده شود (حسابرسی)."""
action = self._action()
self._delete(action)
html = self.client.get(
'/panel/customer/%d/action?status=cancell' % self.customer.pk
).content.decode()
self.assertIn('action_id=\'%d\'' % action.pk, html)
def test_deleted_action_hidden_from_actionlist_all(self):
action = self._action(status='waite', forUser=self.user)
self._delete(action)
response = self.client.get(reverse('admin_panel:ActionListAll'))
ids = {a.pk for a in response.context['actions']}
self.assertNotIn(action.pk, ids)
def test_deleted_action_hidden_from_nazar_page(self):
action = self._action(nazarDT=None)
self._delete(action)
response = self.client.get(
'/panel/customer/%d/nazar/' % self.customer.pk)
ids = {a.pk for a in response.context['actions']}
self.assertNotIn(action.pk, ids)
class ActionDeleteUITests(TestCase):
"""دکمهٔ حذف روی صفحهٔ اقدام‌ها: حضور، قفل نظرسنجی و اسکوپ."""
def setUp(self):
self.user = make_user('deleteUi', superuser=True)
self.client.force_login(self.user)
self.customer = make_customer('UI حذف')
Customer.objects.filter(pk=self.customer.pk).update(
installDate=timezone.localdate() - timedelta(days=500))
self.customer.refresh_from_db()
def _action(self, **kwargs):
defaults = dict(customer=self.customer, actionType='hamahangi',
status='waite', actionDateTime=timezone.now())
defaults.update(kwargs)
return Action.objects.create(**defaults)
def test_delete_button_rendered_on_customer_action_page(self):
action = self._action()
html = self.client.get(
'/panel/customer/%d/action' % self.customer.pk).content.decode()
self.assertIn('/action/%d/delete' % action.pk, html)
self.assertIn('ax-del is-danger', html)
self.assertIn('method="post"', html)
self.assertIn('csrfmiddlewaretoken', html)
# تأیید پیش از حذف
self.assertIn('confirm(', html)
def test_delete_button_absent_on_actionlist(self):
action = self._action(forUser=self.user)
html = self.client.get(
'/panel/ActionList/%d/today' % self.user.pk).content.decode()
self.assertNotIn('ax-del is-danger', html,
'دکمهٔ حذف نباید در فهرست وظایف ظاهر شود')
self.assertNotIn('/action/%d/delete' % action.pk, html)
def test_survey_action_shows_locked_state(self):
self._action(nazarDT=timezone.now(), nazar='خوب', grade=20)
html = self.client.get(
'/panel/customer/%d/action' % self.customer.pk).content.decode()
self.assertIn('ax-del is-locked', html)
self.assertNotIn('ax-del is-danger', html)
def test_deleted_action_hidden_by_default(self):
kept = self._action()
gone = self._action()
self.client.post(reverse('admin_panel:customerActionDelete',
args=[self.customer.pk, gone.pk]))
html = self.client.get(
'/panel/customer/%d/action' % self.customer.pk).content.decode()
self.assertIn("/action/%d/delete" % kept.pk, html)
self.assertNotIn("/action/%d/delete" % gone.pk, html)
class DeleteButtonDoesNotOpenModalTests(TestCase):
"""کلیک روی «حذف» نباید مودال ویرایش را باز کند.
دکمهٔ حذف داخل کارتی است که کلاس ``actionBoxOpenB`` دارد و هندلرِ بازکردنِ
مودال روی همان کارت بسته شده است؛ بدون محافظ، رویداد به والد می‌رسد و
مودالِ ویرایش هم باز می‌شد. دو لایه محافظ گذاشته شده است:
۱) گاردِ ``closest('.ax-del-form, .ax-del')`` در هندلرِ ``actionBoxOpenB``
۲) ``stopPropagation`` روی خودِ فرم/دکمهٔ حذف
"""
def setUp(self):
self.user = make_user('noModalOnDelete', superuser=True)
self.client.force_login(self.user)
self.customer = make_customer('حذف بدون مودال')
self.action = make_visit(self.customer, action_dt=at(-1),
finish_dt=at(-1), action_type='hamahangi')
self.html = self.client.get(
'/panel/customer/%d/action' % self.customer.pk).content.decode()
def test_modal_handler_guards_delete_controls(self):
self.assertIn("closest('.ax-del-form, .ax-del')", self.html)
def test_delete_controls_stop_propagation(self):
self.assertIn('stopPropagation', self.html)
self.assertRegex(
self.html,
r"\$\('\.ax-del-form, \.ax-del'\)"
r"\s*\n\s*\.off\('\.axdel'\)"
r"\s*\n\s*\.on\('click\.axdel mousedown\.axdel'",
)
def test_guard_precedes_modal_open_logic(self):
"""گارد باید پیش از ``isEditMode = true`` اجرا شود."""
guard = self.html.index("closest('.ax-del-form, .ax-del')")
opener = self.html.index('isEditMode = true;', guard - 900)
self.assertLess(guard, opener)
def test_delete_form_has_csrf_and_post(self):
self.assertIn('ax-del-form', self.html)
self.assertIn('csrfmiddlewaretoken', self.html)