Compare commits
4
Commits
8fd881e0ea
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2e5d0bcdd3 | ||
|
|
fa73a734a0 | ||
|
|
99c25a8216 | ||
|
|
825f2a5973 |
No files matched your search
+50
-9
@@ -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),
|
||||
]
|
||||
@@ -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)** برای محاسبهٔ «آخرین بازدید»: هر سه ستونِ لازم
|
||||
# برای فیلتر و تجمیع داخل خودِ ایندکس است، پس دیتابیس به جدول اصلی مراجعه
|
||||
|
||||
@@ -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", "یادآوری سررسید نسیه"
|
||||
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -46,6 +46,11 @@ TIMING = {
|
||||
"field": None,
|
||||
"note": "بهمحض ثبت «اقدام از نوع بازدید» ارسال میشود؛ تنظیم زمانی ندارد.",
|
||||
},
|
||||
SMSType.INSTALL_CREATED: {
|
||||
"kind": NONE,
|
||||
"field": None,
|
||||
"note": "بهمحض ثبت «اقدام از نوع نصب» ارسال میشود؛ تنظیم زمانی ندارد.",
|
||||
},
|
||||
SMSType.PAYMENT_THANKS: {
|
||||
"kind": NONE,
|
||||
"field": None,
|
||||
|
||||
@@ -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 %}
|
||||
|
||||
@@ -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
@@ -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}
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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) |
|
||||
@@ -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 در فرم اجباری است.
|
||||
- تأیید دومرحلهای پیش از حذف (نوع اقدام در متن تأیید نمایش داده میشود).
|
||||
|
||||
---
|
||||
|
||||
## ۷. سناریوهای تستشده
|
||||
|
||||
| # | سناریو | نتیجهٔ انتظار | وضعیت |
|
||||
|
||||
@@ -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 @@
|
||||
{"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
@@ -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._
|
||||
|
||||
@@ -63,12 +63,12 @@
|
||||
</div>
|
||||
<div id="legend"></div>
|
||||
</div>
|
||||
<div id="stats">1991 nodes · 3632 edges · 207 communities</div>
|
||||
<div id="stats">2509 nodes · 4876 edges · 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'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'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
File diff suppressed because it is too large.
Load diff
+113
-71
@@ -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
@@ -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
|
||||
|
||||
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
|
||||
@@ -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
@@ -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."""
|
||||
|
||||
@@ -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
@@ -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)
|
||||
Reference in new issue
Block a user