some improve
This commit is contained in:
1 parent
fa73a734a0
commit
2e5d0bcdd3
17 files changed
+1512
-226
No files matched your search
+45
-6
@@ -1,11 +1,11 @@
|
|||||||
Project Root: khatibi
|
Project Root: khatibi
|
||||||
Generated At: 2026-10-22T06:08:56+00:00
|
Generated At: 2026-10-27T07:48:20+00:00
|
||||||
Python Files: 189
|
Python Files: 194
|
||||||
Django Files: 12
|
Django Files: 12
|
||||||
JavaScript/TypeScript Files: 14
|
JavaScript/TypeScript Files: 14
|
||||||
HTML Files: 58
|
HTML Files: 59
|
||||||
Total Files: 756
|
Total Files: 794
|
||||||
Total Directories: 79
|
Total Directories: 81
|
||||||
|
|
||||||
khatibi
|
khatibi
|
||||||
├── .cursor
|
├── .cursor
|
||||||
@@ -131,6 +131,7 @@ khatibi
|
|||||||
│ │ ├── 0078_action_visit_covering_index.py
|
│ │ ├── 0078_action_visit_covering_index.py
|
||||||
│ │ ├── 0079_customer_visit_hold.py
|
│ │ ├── 0079_customer_visit_hold.py
|
||||||
│ │ ├── 0080_action_soft_delete.py
|
│ │ ├── 0080_action_soft_delete.py
|
||||||
|
│ │ ├── 0081_sms_type_install_created.py
|
||||||
│ │ └── __init__.py
|
│ │ └── __init__.py
|
||||||
│ ├── models
|
│ ├── models
|
||||||
│ │ ├── __init__.py
|
│ │ ├── __init__.py
|
||||||
@@ -243,7 +244,6 @@ khatibi
|
|||||||
│ │ │ └── nazar.html
|
│ │ │ └── nazar.html
|
||||||
│ │ ├── _customer_info.html
|
│ │ ├── _customer_info.html
|
||||||
│ │ ├── _customer_phones.html
|
│ │ ├── _customer_phones.html
|
||||||
│ │ ├── _customer_sms.html
|
|
||||||
│ │ ├── ActionList.html
|
│ │ ├── ActionList.html
|
||||||
│ │ ├── base.html
|
│ │ ├── base.html
|
||||||
│ │ ├── baseHesabdari.html
|
│ │ ├── baseHesabdari.html
|
||||||
@@ -326,6 +326,8 @@ khatibi
|
|||||||
├── data9
|
├── data9
|
||||||
│ ├── backup-20261020-180934.sqlite3
|
│ ├── backup-20261020-180934.sqlite3
|
||||||
│ ├── backup-20261020-213406.sqlite3
|
│ ├── backup-20261020-213406.sqlite3
|
||||||
|
│ ├── backup-20261020-213406.sqlite3-shm
|
||||||
|
│ ├── backup-20261020-213406.sqlite3-wal
|
||||||
│ ├── db2 (2).sqlite32
|
│ ├── db2 (2).sqlite32
|
||||||
│ ├── db2.sqlite32
|
│ ├── db2.sqlite32
|
||||||
│ ├── db222.sqlite3
|
│ ├── db222.sqlite3
|
||||||
@@ -337,6 +339,8 @@ khatibi
|
|||||||
│ ├── khatibi92669266_backups_db_backup_20261021_223009.sqlite3-wal
|
│ ├── khatibi92669266_backups_db_backup_20261021_223009.sqlite3-wal
|
||||||
│ └── kt.sqlite3
|
│ └── kt.sqlite3
|
||||||
├── docs
|
├── docs
|
||||||
|
│ ├── entezar_action_fields_report.html
|
||||||
|
│ ├── entezar_action_fields_report.md
|
||||||
│ └── visit_hold_entezar.md
|
│ └── visit_hold_entezar.md
|
||||||
├── graphify-out
|
├── graphify-out
|
||||||
│ ├── 2026-10-09
|
│ ├── 2026-10-09
|
||||||
@@ -374,12 +378,21 @@ khatibi
|
|||||||
│ │ ├── graph.json
|
│ │ ├── graph.json
|
||||||
│ │ ├── GRAPH_REPORT.md
|
│ │ ├── GRAPH_REPORT.md
|
||||||
│ │ └── manifest.json
|
│ │ └── manifest.json
|
||||||
|
│ ├── 2026-10-22
|
||||||
|
│ │ ├── .graphify_analysis.json
|
||||||
|
│ │ ├── .graphify_labels.json
|
||||||
|
│ │ ├── .graphify_semantic_marker
|
||||||
|
│ │ ├── graph.json
|
||||||
|
│ │ ├── GRAPH_REPORT.md
|
||||||
|
│ │ └── manifest.json
|
||||||
│ ├── cache
|
│ ├── cache
|
||||||
│ │ ├── ast
|
│ │ ├── ast
|
||||||
│ │ │ └── v0.9.82-s7
|
│ │ │ └── v0.9.82-s7
|
||||||
│ │ │ ├── 0075bdcd95747f7c750eba8757614aab71d4d8c77bd6764e4c5a98fe18dc8997.json
|
│ │ │ ├── 0075bdcd95747f7c750eba8757614aab71d4d8c77bd6764e4c5a98fe18dc8997.json
|
||||||
│ │ │ ├── 0175e34128b5ac5119a5da4cc53d889dce334d8f20357631d54627c53cae9b8e.json
|
│ │ │ ├── 0175e34128b5ac5119a5da4cc53d889dce334d8f20357631d54627c53cae9b8e.json
|
||||||
│ │ │ ├── 01850b7fad89dcb9e521fceed2c41e64e9909a0f4c4a54d818d346ccf63bbc5d.json
|
│ │ │ ├── 01850b7fad89dcb9e521fceed2c41e64e9909a0f4c4a54d818d346ccf63bbc5d.json
|
||||||
|
│ │ │ ├── 01b87542be8c5f2850a08a631287f94a89184e186d5cfca4d585800f41ee0f5d.json
|
||||||
|
│ │ │ ├── 0317da4b3f4f0b0b5f6cf487567ec7e1b6f3bed2c6ad5cb19bd526a020521c10.json
|
||||||
│ │ │ ├── 0348dd4662ebd96572421864ce77a266334a327f8bcbdc237d54c313d116bafb.json
|
│ │ │ ├── 0348dd4662ebd96572421864ce77a266334a327f8bcbdc237d54c313d116bafb.json
|
||||||
│ │ │ ├── 03c6fb41770a9293658a5db5af36f7e4afe64ae04b3ce8cc6c5874320706fd22.json
|
│ │ │ ├── 03c6fb41770a9293658a5db5af36f7e4afe64ae04b3ce8cc6c5874320706fd22.json
|
||||||
│ │ │ ├── 0400cc0eadd706ff70ef627ffe39f401fc72a591dcbcd5463fca07fc271f080d.json
|
│ │ │ ├── 0400cc0eadd706ff70ef627ffe39f401fc72a591dcbcd5463fca07fc271f080d.json
|
||||||
@@ -403,8 +416,11 @@ khatibi
|
|||||||
│ │ │ ├── 14f64f9b156b6b520e7af189b303a2844728f3ab8bbe1e65fed49160b1431cb2.json
|
│ │ │ ├── 14f64f9b156b6b520e7af189b303a2844728f3ab8bbe1e65fed49160b1431cb2.json
|
||||||
│ │ │ ├── 15be5091a5bfbf8f48c3f3ae344a980be5452eab00f054c001958aae96557514.json
|
│ │ │ ├── 15be5091a5bfbf8f48c3f3ae344a980be5452eab00f054c001958aae96557514.json
|
||||||
│ │ │ ├── 174e406c2649f2384921581cbf912945a95d54aab73707b9aa45752aaa7c54d7.json
|
│ │ │ ├── 174e406c2649f2384921581cbf912945a95d54aab73707b9aa45752aaa7c54d7.json
|
||||||
|
│ │ │ ├── 17850e40dfa0d02ec3b0d4b2e1f9b931fe6a3a90c6cd874dabeb9466bed5f1d1.json
|
||||||
│ │ │ ├── 183467d5c1ee7daac78dfb428bd335c17e17272bd4510358f38fc5924140c816.json
|
│ │ │ ├── 183467d5c1ee7daac78dfb428bd335c17e17272bd4510358f38fc5924140c816.json
|
||||||
│ │ │ ├── 1893253c5245ae073f9de9168d537384ab764b79d7b1631c785cd7335aab791b.json
|
│ │ │ ├── 1893253c5245ae073f9de9168d537384ab764b79d7b1631c785cd7335aab791b.json
|
||||||
|
│ │ │ ├── 1b244c373e08c304261f7f760687c7ebe0ba9c79e7ab7ea16c3fa4768da959f5.json
|
||||||
|
│ │ │ ├── 1ceebfc94adbc38aa951c83044b67943bc4032309ba3a61d9335dd833d49bbef.json
|
||||||
│ │ │ ├── 1d5a29a9dabb365c079039c788988636b66c3a4bd2dbe66b249df001c2b5396f.json
|
│ │ │ ├── 1d5a29a9dabb365c079039c788988636b66c3a4bd2dbe66b249df001c2b5396f.json
|
||||||
│ │ │ ├── 1d7a28f73bb52bbc018674a0c01405b7f395bcc5f340ecabe332e2fe440bcae8.json
|
│ │ │ ├── 1d7a28f73bb52bbc018674a0c01405b7f395bcc5f340ecabe332e2fe440bcae8.json
|
||||||
│ │ │ ├── 1d9f15f04104af07c60349f21bcd9f6f2c0e65c915e3e9c69040a10197b0fa6f.json
|
│ │ │ ├── 1d9f15f04104af07c60349f21bcd9f6f2c0e65c915e3e9c69040a10197b0fa6f.json
|
||||||
@@ -414,11 +430,13 @@ khatibi
|
|||||||
│ │ │ ├── 21af53aa7322868a25b47458e59f339a8f6e58b7914fbe8e3389ab86e0b0b643.json
|
│ │ │ ├── 21af53aa7322868a25b47458e59f339a8f6e58b7914fbe8e3389ab86e0b0b643.json
|
||||||
│ │ │ ├── 22310d4d49f2beee611c83915933ea06e80ec562fd92c170345c556cd3772561.json
|
│ │ │ ├── 22310d4d49f2beee611c83915933ea06e80ec562fd92c170345c556cd3772561.json
|
||||||
│ │ │ ├── 2356cb9f067d43739312459bad2cb6d9ecbacac703e7e40a282d5b89d64332b3.json
|
│ │ │ ├── 2356cb9f067d43739312459bad2cb6d9ecbacac703e7e40a282d5b89d64332b3.json
|
||||||
|
│ │ │ ├── 23c92d4714bc2e410ae9267e6ea7c139d5b16215da917f9a89c44a29724c69d7.json
|
||||||
│ │ │ ├── 23da13b308596ede7678e92e57041bcecdd8e9baff04b759ddf9a69803dd790a.json
|
│ │ │ ├── 23da13b308596ede7678e92e57041bcecdd8e9baff04b759ddf9a69803dd790a.json
|
||||||
│ │ │ ├── 23f4e3a62e03a38743659d94dd1350d17775da8be9175846e4ff3f38fa87c515.json
|
│ │ │ ├── 23f4e3a62e03a38743659d94dd1350d17775da8be9175846e4ff3f38fa87c515.json
|
||||||
│ │ │ ├── 24508e7a9b6fa93cdd28b9436bdb55fa24ad8f324baa95adb3afd37e301af9ac.json
|
│ │ │ ├── 24508e7a9b6fa93cdd28b9436bdb55fa24ad8f324baa95adb3afd37e301af9ac.json
|
||||||
│ │ │ ├── 2501a9885d6fa5292276ee8cc7db63a9a3c08605f38d199d425d33f650dac35e.json
|
│ │ │ ├── 2501a9885d6fa5292276ee8cc7db63a9a3c08605f38d199d425d33f650dac35e.json
|
||||||
│ │ │ ├── 25dbf169bbd8ae50f7cc568b4dbb7d259eb993ccddc9dbb20db96018ed093f5a.json
|
│ │ │ ├── 25dbf169bbd8ae50f7cc568b4dbb7d259eb993ccddc9dbb20db96018ed093f5a.json
|
||||||
|
│ │ │ ├── 28b192ecdcd9b33d8e4980d5043b6b2bc59095df392e22bf44d2166f7fa6d5ba.json
|
||||||
│ │ │ ├── 28df8bbd275806fd77c086629211231bd1329fe8156f521b19b5c5979f0d3b51.json
|
│ │ │ ├── 28df8bbd275806fd77c086629211231bd1329fe8156f521b19b5c5979f0d3b51.json
|
||||||
│ │ │ ├── 294e2d390f56acc0445564d428d90c641d76496cbc457bcfb09876382e9d87ab.json
|
│ │ │ ├── 294e2d390f56acc0445564d428d90c641d76496cbc457bcfb09876382e9d87ab.json
|
||||||
│ │ │ ├── 2976c530804df2075019fedaf2b908eeeff37b2703310c7e031b653d1fec71e5.json
|
│ │ │ ├── 2976c530804df2075019fedaf2b908eeeff37b2703310c7e031b653d1fec71e5.json
|
||||||
@@ -431,7 +449,9 @@ khatibi
|
|||||||
│ │ │ ├── 33b32e27be802f61375110f7f4ac9419fd4eb633523e01e31774b874448849c1.json
|
│ │ │ ├── 33b32e27be802f61375110f7f4ac9419fd4eb633523e01e31774b874448849c1.json
|
||||||
│ │ │ ├── 3614ae46baf3e1b6efa17011c37407a5d2931aba1886d8f8bdf8e4e154ab6c3f.json
|
│ │ │ ├── 3614ae46baf3e1b6efa17011c37407a5d2931aba1886d8f8bdf8e4e154ab6c3f.json
|
||||||
│ │ │ ├── 370c10e9d6f01aa67799b1f2191d5749a69ce4541aa55cbf4a28d45732bd641b.json
|
│ │ │ ├── 370c10e9d6f01aa67799b1f2191d5749a69ce4541aa55cbf4a28d45732bd641b.json
|
||||||
|
│ │ │ ├── 373e16b6c6996757d88a6ac751cd44028efa64701d746f8536819889417125ff.json
|
||||||
│ │ │ ├── 38980449465279974120694e1c667f229049f75d656fe518196d9cadc2429155.json
|
│ │ │ ├── 38980449465279974120694e1c667f229049f75d656fe518196d9cadc2429155.json
|
||||||
|
│ │ │ ├── 3a341a98aef632503bf2dc67ad87833e604c9aabe7ff69f5bcff7b54d6fe6407.json
|
||||||
│ │ │ ├── 3ba0d4c75ab8bfa5bc2a60dccb74197cb94bd161e526e54770a9b0a2aa757806.json
|
│ │ │ ├── 3ba0d4c75ab8bfa5bc2a60dccb74197cb94bd161e526e54770a9b0a2aa757806.json
|
||||||
│ │ │ ├── 3c9f8987dc7426b442fe2a90c7de5d909fc85988e172e7722c11f24a2c331481.json
|
│ │ │ ├── 3c9f8987dc7426b442fe2a90c7de5d909fc85988e172e7722c11f24a2c331481.json
|
||||||
│ │ │ ├── 3d94db4e528b0e06bb975a12c71733767fe57307bb6e3de7c6ce65721e097541.json
|
│ │ │ ├── 3d94db4e528b0e06bb975a12c71733767fe57307bb6e3de7c6ce65721e097541.json
|
||||||
@@ -446,6 +466,7 @@ khatibi
|
|||||||
│ │ │ ├── 45f7e59ef3f291171180b6763e8da408d8b7579bb7cb96dd4ca859e48aa5ce14.json
|
│ │ │ ├── 45f7e59ef3f291171180b6763e8da408d8b7579bb7cb96dd4ca859e48aa5ce14.json
|
||||||
│ │ │ ├── 46616763804b2b938d73c75f7651158e94803e0b86b3a759ecdd59375759eea3.json
|
│ │ │ ├── 46616763804b2b938d73c75f7651158e94803e0b86b3a759ecdd59375759eea3.json
|
||||||
│ │ │ ├── 46bde576f6f1e69457e4ad33238fdbd602d2a7f027644a84cb81068215fbd5e1.json
|
│ │ │ ├── 46bde576f6f1e69457e4ad33238fdbd602d2a7f027644a84cb81068215fbd5e1.json
|
||||||
|
│ │ │ ├── 4723c76012231305253c473fb5f2b81fa35a49f9f4aa38f54e9af3285aa51a02.json
|
||||||
│ │ │ ├── 477b0c06bcadb94314490ac76899f499d45a942aadef1e65623d303342157463.json
|
│ │ │ ├── 477b0c06bcadb94314490ac76899f499d45a942aadef1e65623d303342157463.json
|
||||||
│ │ │ ├── 492a9871203480b590199773e90727872f60f06013713a4a2936d87986cdbbe5.json
|
│ │ │ ├── 492a9871203480b590199773e90727872f60f06013713a4a2936d87986cdbbe5.json
|
||||||
│ │ │ ├── 4da7df08a0ef803551d404438fe24d6c8cc11d9a2c3cc8d85e37d5228ba3a166.json
|
│ │ │ ├── 4da7df08a0ef803551d404438fe24d6c8cc11d9a2c3cc8d85e37d5228ba3a166.json
|
||||||
@@ -453,6 +474,7 @@ khatibi
|
|||||||
│ │ │ ├── 4e604dd03a70463ae25f6615ea3ce4fe8e98fab15d6b11ddafd51d601062e84d.json
|
│ │ │ ├── 4e604dd03a70463ae25f6615ea3ce4fe8e98fab15d6b11ddafd51d601062e84d.json
|
||||||
│ │ │ ├── 506e815cbeaaf3aa6e448dcbf70c10b4d118dff41e05a292fb8ec6d76dd391c4.json
|
│ │ │ ├── 506e815cbeaaf3aa6e448dcbf70c10b4d118dff41e05a292fb8ec6d76dd391c4.json
|
||||||
│ │ │ ├── 507e42a0bbe7a582ce1fcb77651e1cfde643907ca0429fedfe11afe7ae3b11c4.json
|
│ │ │ ├── 507e42a0bbe7a582ce1fcb77651e1cfde643907ca0429fedfe11afe7ae3b11c4.json
|
||||||
|
│ │ │ ├── 50b38b9cbcb9d5aad3a94b28cb983b3694926bc20e0381419b82008efd042d65.json
|
||||||
│ │ │ ├── 52608b276c6f0a2f190186e5bac578715ad888ff5ba3c10ff30879c3baf9d9ce.json
|
│ │ │ ├── 52608b276c6f0a2f190186e5bac578715ad888ff5ba3c10ff30879c3baf9d9ce.json
|
||||||
│ │ │ ├── 527de91bd8bbc5a7ab79951457a9f192909faa9bbf02aaec72bb3d26e2298b57.json
|
│ │ │ ├── 527de91bd8bbc5a7ab79951457a9f192909faa9bbf02aaec72bb3d26e2298b57.json
|
||||||
│ │ │ ├── 5316515ee7fc3e1038e676b361d9028edc735993c3d34791a77a14503deb064d.json
|
│ │ │ ├── 5316515ee7fc3e1038e676b361d9028edc735993c3d34791a77a14503deb064d.json
|
||||||
@@ -476,8 +498,10 @@ khatibi
|
|||||||
│ │ │ ├── 5e19aa3aba5509cd48a9a93b39db071cfc2c2728ccae46b34caa0e214cc6be01.json
|
│ │ │ ├── 5e19aa3aba5509cd48a9a93b39db071cfc2c2728ccae46b34caa0e214cc6be01.json
|
||||||
│ │ │ ├── 5f050da3769e151e52138ee1ea5dcbcb6160cb57952c75665e607628d0e08d67.json
|
│ │ │ ├── 5f050da3769e151e52138ee1ea5dcbcb6160cb57952c75665e607628d0e08d67.json
|
||||||
│ │ │ ├── 5f858ab6d9f90e0f41617273b6f132af3f76f520deafb8d612adce373875e768.json
|
│ │ │ ├── 5f858ab6d9f90e0f41617273b6f132af3f76f520deafb8d612adce373875e768.json
|
||||||
|
│ │ │ ├── 5fe315e6417b8eb8b26079139d8bbe46ddaaf7cd67b078d927ea79d3815be453.json
|
||||||
│ │ │ ├── 60002a01d6fc22147e2994d1a1a447d5e577c02aed2443789af20571ec2d2a19.json
|
│ │ │ ├── 60002a01d6fc22147e2994d1a1a447d5e577c02aed2443789af20571ec2d2a19.json
|
||||||
│ │ │ ├── 60e00560038df059b1696e54dcf055cd658677d6e1201570a8500531addc0ac6.json
|
│ │ │ ├── 60e00560038df059b1696e54dcf055cd658677d6e1201570a8500531addc0ac6.json
|
||||||
|
│ │ │ ├── 611c158815bdf3dbec9f5a5c01bce751fcd07db536e9bbaba2b2d232f82f5ae6.json
|
||||||
│ │ │ ├── 62d88411a61ee9d3b60dda6c4febf33539b8b9e8226b5d2b9205f8e2b0c4478a.json
|
│ │ │ ├── 62d88411a61ee9d3b60dda6c4febf33539b8b9e8226b5d2b9205f8e2b0c4478a.json
|
||||||
│ │ │ ├── 63c38f2a8abe98c0d1e9cb5b7d9fd0451e1e46c2bcbf1d2c3b241b263ac0a9cd.json
|
│ │ │ ├── 63c38f2a8abe98c0d1e9cb5b7d9fd0451e1e46c2bcbf1d2c3b241b263ac0a9cd.json
|
||||||
│ │ │ ├── 6448d65fdcfe330b5d4278c8d1540e7b075238672b8101f9d2e1c4052f7c9c49.json
|
│ │ │ ├── 6448d65fdcfe330b5d4278c8d1540e7b075238672b8101f9d2e1c4052f7c9c49.json
|
||||||
@@ -493,7 +517,10 @@ khatibi
|
|||||||
│ │ │ ├── 6aca679f90689aa4c275389c8b4e2462a9456775e8b124071070af2c988ca0fc.json
|
│ │ │ ├── 6aca679f90689aa4c275389c8b4e2462a9456775e8b124071070af2c988ca0fc.json
|
||||||
│ │ │ ├── 6afa6136d9df8317bdd8d49f4109412776deb21ba123fdd2a97331b400a0230d.json
|
│ │ │ ├── 6afa6136d9df8317bdd8d49f4109412776deb21ba123fdd2a97331b400a0230d.json
|
||||||
│ │ │ ├── 6bfbb9eed6842bf331dec78724d7e740919c76345b54be844964f44640482a87.json
|
│ │ │ ├── 6bfbb9eed6842bf331dec78724d7e740919c76345b54be844964f44640482a87.json
|
||||||
|
│ │ │ ├── 6d026e901d3b2ff0cb9550c750966905afbbc681ba68db88e1de608fb2964495.json
|
||||||
│ │ │ ├── 6d4d7d7f829f13e46e4898aceb760d66d2255dc838eab0933f9a92d720f420a7.json
|
│ │ │ ├── 6d4d7d7f829f13e46e4898aceb760d66d2255dc838eab0933f9a92d720f420a7.json
|
||||||
|
│ │ │ ├── 6d556671b5632e29972dc09e345d68d522a5b3bcd2852b803d56668890fa46d7.json
|
||||||
|
│ │ │ ├── 6dcafcac3a0b169a08580cc4cb9196423dacfa51329841311665c363a97bc29e.json
|
||||||
│ │ │ ├── 6df9c1e88224e95c55dee4238fa91358360231ee47362294e884bc6e24d73612.json
|
│ │ │ ├── 6df9c1e88224e95c55dee4238fa91358360231ee47362294e884bc6e24d73612.json
|
||||||
│ │ │ ├── 6e262456570c174e207055c7c964fe85431addb0f59298b1e4d912500dfb23a3.json
|
│ │ │ ├── 6e262456570c174e207055c7c964fe85431addb0f59298b1e4d912500dfb23a3.json
|
||||||
│ │ │ ├── 6e2ed39911a83c75e432ffd0dab22fd095ce8c5bb186d6ab22d594375f3b32bc.json
|
│ │ │ ├── 6e2ed39911a83c75e432ffd0dab22fd095ce8c5bb186d6ab22d594375f3b32bc.json
|
||||||
@@ -511,6 +538,7 @@ khatibi
|
|||||||
│ │ │ ├── 77a1520156879de714ee872c29b1387967bcc5ce470b47d37087ea2a110f3875.json
|
│ │ │ ├── 77a1520156879de714ee872c29b1387967bcc5ce470b47d37087ea2a110f3875.json
|
||||||
│ │ │ ├── 780977bca042ffdde1cca2811a3a46c36bd3fcff2723c94ac527428113131c64.json
|
│ │ │ ├── 780977bca042ffdde1cca2811a3a46c36bd3fcff2723c94ac527428113131c64.json
|
||||||
│ │ │ ├── 784dd791c21e8837a3e925331cf234b2138ded3d2012968739604d5535a18e09.json
|
│ │ │ ├── 784dd791c21e8837a3e925331cf234b2138ded3d2012968739604d5535a18e09.json
|
||||||
|
│ │ │ ├── 78d30d0a40b28969d64653d2508fd53e53709b95a45f3e380ebd6c7aff04ace9.json
|
||||||
│ │ │ ├── 79ffc45a7139464dd6c7b1fc6f1f0f9c44347fbd7dcee92c6d4b63525424c442.json
|
│ │ │ ├── 79ffc45a7139464dd6c7b1fc6f1f0f9c44347fbd7dcee92c6d4b63525424c442.json
|
||||||
│ │ │ ├── 7be7f428e6acfe4c99370707cc7de9253f801feff1564088f2584d5dc8c42a89.json
|
│ │ │ ├── 7be7f428e6acfe4c99370707cc7de9253f801feff1564088f2584d5dc8c42a89.json
|
||||||
│ │ │ ├── 7bf404104bb32fc04568c2d287891fa69955ac3f46718c99dcca64a4bfff6fe3.json
|
│ │ │ ├── 7bf404104bb32fc04568c2d287891fa69955ac3f46718c99dcca64a4bfff6fe3.json
|
||||||
@@ -544,6 +572,7 @@ khatibi
|
|||||||
│ │ │ ├── 9766508e5a013c3d3cbb2602719391199e90af5720c9782a77ca71565aa346b1.json
|
│ │ │ ├── 9766508e5a013c3d3cbb2602719391199e90af5720c9782a77ca71565aa346b1.json
|
||||||
│ │ │ ├── 9880af8b991c19abe5ed019ad7b7291b8b281ed9dcb894a00c3fd8259a59b4a4.json
|
│ │ │ ├── 9880af8b991c19abe5ed019ad7b7291b8b281ed9dcb894a00c3fd8259a59b4a4.json
|
||||||
│ │ │ ├── 996411678d23c3cf8a0e6c1c5076701e8bf5f3dad21ca907ec4116d15366a668.json
|
│ │ │ ├── 996411678d23c3cf8a0e6c1c5076701e8bf5f3dad21ca907ec4116d15366a668.json
|
||||||
|
│ │ │ ├── 9b2fd03f2769231e4c207b2f0ee915e3b5eb050232f2fe6c59ddb01904976650.json
|
||||||
│ │ │ ├── 9c2c2f6f8d4d4c3a9a60da3ed38a26dc789e8cfaff5d46d140d2f208e5fef3c3.json
|
│ │ │ ├── 9c2c2f6f8d4d4c3a9a60da3ed38a26dc789e8cfaff5d46d140d2f208e5fef3c3.json
|
||||||
│ │ │ ├── 9cfb6e4f7b08a863675af032e7823c0523170f5201e686c5ef72b39c04b07a12.json
|
│ │ │ ├── 9cfb6e4f7b08a863675af032e7823c0523170f5201e686c5ef72b39c04b07a12.json
|
||||||
│ │ │ ├── 9eabdb0cacba38c8fb6b3e777932e06bd82172cdc0bcccf2fc0bfc23b5bd4974.json
|
│ │ │ ├── 9eabdb0cacba38c8fb6b3e777932e06bd82172cdc0bcccf2fc0bfc23b5bd4974.json
|
||||||
@@ -568,6 +597,7 @@ khatibi
|
|||||||
│ │ │ ├── b59999c2fd322c241c7bd37e6ed0a95c9124cbb79f8424f1fa7cb2e92fe63817.json
|
│ │ │ ├── b59999c2fd322c241c7bd37e6ed0a95c9124cbb79f8424f1fa7cb2e92fe63817.json
|
||||||
│ │ │ ├── b82d5f6840b4930eb62fdb1c7b3a58124fff805e529ede7684516a65322fcae0.json
|
│ │ │ ├── b82d5f6840b4930eb62fdb1c7b3a58124fff805e529ede7684516a65322fcae0.json
|
||||||
│ │ │ ├── b87a932e418a18531d61dc15776db652401e7cf1682dead02abdf50e2cd00530.json
|
│ │ │ ├── b87a932e418a18531d61dc15776db652401e7cf1682dead02abdf50e2cd00530.json
|
||||||
|
│ │ │ ├── b9b878bef7c0ae7d407a42c797a4697990237971be65a5d01a6c7b9b444a2a9a.json
|
||||||
│ │ │ ├── b9f8ea31056fe0c0fc40a84640c58f29895312c65bf694567487204d8f1170e9.json
|
│ │ │ ├── b9f8ea31056fe0c0fc40a84640c58f29895312c65bf694567487204d8f1170e9.json
|
||||||
│ │ │ ├── bb140cfe12cfb3e32dc9815393035fc33c7649c2faeeccb82cdee56a930ea635.json
|
│ │ │ ├── bb140cfe12cfb3e32dc9815393035fc33c7649c2faeeccb82cdee56a930ea635.json
|
||||||
│ │ │ ├── bb74cda4073a6da7a4b60fecfb3bb40796a51ceafcbdd128d60e5ce23fa4f319.json
|
│ │ │ ├── bb74cda4073a6da7a4b60fecfb3bb40796a51ceafcbdd128d60e5ce23fa4f319.json
|
||||||
@@ -613,9 +643,11 @@ khatibi
|
|||||||
│ │ │ ├── d9590fb13a20db20da78d33674f56397a18e3356d08dab6f7c0739130caee096.json
|
│ │ │ ├── d9590fb13a20db20da78d33674f56397a18e3356d08dab6f7c0739130caee096.json
|
||||||
│ │ │ ├── db68b8db5217e2295eb1195760d7352960627da044a4c4f8f6b89c67ad2f8f57.json
|
│ │ │ ├── db68b8db5217e2295eb1195760d7352960627da044a4c4f8f6b89c67ad2f8f57.json
|
||||||
│ │ │ ├── db794bbcafe219af962673ef26efb1ee1eacf3923e13113352f3eedfd3192d23.json
|
│ │ │ ├── db794bbcafe219af962673ef26efb1ee1eacf3923e13113352f3eedfd3192d23.json
|
||||||
|
│ │ │ ├── db81ec42bfd68175829c568dac66c2844afba128b94afa2accdf24ed67aeccaf.json
|
||||||
│ │ │ ├── dbbdad9d992922aff62ad3262a531716cfc32e2f15f600773b9a0cf10545058d.json
|
│ │ │ ├── dbbdad9d992922aff62ad3262a531716cfc32e2f15f600773b9a0cf10545058d.json
|
||||||
│ │ │ ├── dc0344515d1b3ffe8643560516b88418be0b306fba56101b8a630ed73d48db46.json
|
│ │ │ ├── dc0344515d1b3ffe8643560516b88418be0b306fba56101b8a630ed73d48db46.json
|
||||||
│ │ │ ├── dcad55d51bb4494a642af555d5bfe78c5b8ee3d11de1b3d8f4286d608980854b.json
|
│ │ │ ├── dcad55d51bb4494a642af555d5bfe78c5b8ee3d11de1b3d8f4286d608980854b.json
|
||||||
|
│ │ │ ├── ddd79719e349936349e9ec7684d3eec146d3b73f95a419ad142ae603ab199acd.json
|
||||||
│ │ │ ├── e08c4784f0f82403ff24a1676e3d75e11a208aadf8fe94a723461c9658afd1e0.json
|
│ │ │ ├── e08c4784f0f82403ff24a1676e3d75e11a208aadf8fe94a723461c9658afd1e0.json
|
||||||
│ │ │ ├── e0eb3c543474df255f97ae0679df48999b86b75dc42943b9d67f4cfcf1acae34.json
|
│ │ │ ├── e0eb3c543474df255f97ae0679df48999b86b75dc42943b9d67f4cfcf1acae34.json
|
||||||
│ │ │ ├── e16f78807ff202fa2c81867b321acfacede2d24cc785856eef0633d8d352796b.json
|
│ │ │ ├── e16f78807ff202fa2c81867b321acfacede2d24cc785856eef0633d8d352796b.json
|
||||||
@@ -630,6 +662,7 @@ khatibi
|
|||||||
│ │ │ ├── e9e14d581ed59799b2d83c67402e8555b5b59e7d49425950ec490422bd0307d9.json
|
│ │ │ ├── e9e14d581ed59799b2d83c67402e8555b5b59e7d49425950ec490422bd0307d9.json
|
||||||
│ │ │ ├── eaceb29a907e3e67bed5255cc1f8b8b3eab9d9946f4d4df2cc99070151285029.json
|
│ │ │ ├── eaceb29a907e3e67bed5255cc1f8b8b3eab9d9946f4d4df2cc99070151285029.json
|
||||||
│ │ │ ├── ec07e022005d3513838eee7e6618ff060e25d914cab7745aa6172f22d4089497.json
|
│ │ │ ├── ec07e022005d3513838eee7e6618ff060e25d914cab7745aa6172f22d4089497.json
|
||||||
|
│ │ │ ├── ec3570ad9673cc5dfc9a0d12f208b7f4d897d28d1ae2974d6a3fa63ad27598d3.json
|
||||||
│ │ │ ├── eccad1f007b52643ce8d81576efb7d1969ecbff2e9db288989fd9a9c21fd7288.json
|
│ │ │ ├── eccad1f007b52643ce8d81576efb7d1969ecbff2e9db288989fd9a9c21fd7288.json
|
||||||
│ │ │ ├── ecd9c1dbfa0a72e253adb680ae7088eff24c26360e53a65862b274ea1281ceae.json
|
│ │ │ ├── ecd9c1dbfa0a72e253adb680ae7088eff24c26360e53a65862b274ea1281ceae.json
|
||||||
│ │ │ ├── ee450e6251e62d8972efb53bc35145c4a65050a7b6db2954b84bda42fca7b704.json
|
│ │ │ ├── ee450e6251e62d8972efb53bc35145c4a65050a7b6db2954b84bda42fca7b704.json
|
||||||
@@ -645,6 +678,7 @@ khatibi
|
|||||||
│ │ │ ├── f72f6f3fe9d226e4717e158dedd318fff0a1d52ece54f965311c2b4f862dcfee.json
|
│ │ │ ├── f72f6f3fe9d226e4717e158dedd318fff0a1d52ece54f965311c2b4f862dcfee.json
|
||||||
│ │ │ ├── f76726d2898f2a3ba7168d3fb77c21087432eb9c200631b664ecb3b1837aaf17.json
|
│ │ │ ├── f76726d2898f2a3ba7168d3fb77c21087432eb9c200631b664ecb3b1837aaf17.json
|
||||||
│ │ │ ├── f92b94479afedf47039d8821a77facb55d9a862338e85876b80cac3b41c844f6.json
|
│ │ │ ├── f92b94479afedf47039d8821a77facb55d9a862338e85876b80cac3b41c844f6.json
|
||||||
|
│ │ │ ├── fa4a8a35e96c919372da27c7af11abea0c89fff941c0b20dbc4e48c15f32597c.json
|
||||||
│ │ │ ├── fb6825c4e9ffba13d17a2da18a1e07aef792089a7cd24b31e7f4f32ee39287e8.json
|
│ │ │ ├── fb6825c4e9ffba13d17a2da18a1e07aef792089a7cd24b31e7f4f32ee39287e8.json
|
||||||
│ │ │ ├── fbfc351c35c5272de5612b4740e380b06a24c37f8074b977e35f62ad85a6dcc0.json
|
│ │ │ ├── fbfc351c35c5272de5612b4740e380b06a24c37f8074b977e35f62ad85a6dcc0.json
|
||||||
│ │ │ ├── fc6ae63ae2639372c4ae4fbe9718feca814261dda099ba58bb780d14c69086cd.json
|
│ │ │ ├── fc6ae63ae2639372c4ae4fbe9718feca814261dda099ba58bb780d14c69086cd.json
|
||||||
@@ -790,6 +824,10 @@ khatibi
|
|||||||
│ ├── settings.py
|
│ ├── settings.py
|
||||||
│ ├── urls.py
|
│ ├── urls.py
|
||||||
│ └── wsgi.py
|
│ └── wsgi.py
|
||||||
|
├── scripts
|
||||||
|
│ ├── coordination_hold_order_probe.py
|
||||||
|
│ ├── entezar_action_audit.py
|
||||||
|
│ └── hold_write_verification.py
|
||||||
├── static
|
├── static
|
||||||
│ └── .gitkeep
|
│ └── .gitkeep
|
||||||
├── tests
|
├── tests
|
||||||
@@ -797,6 +835,7 @@ khatibi
|
|||||||
│ ├── factories.py
|
│ ├── factories.py
|
||||||
│ ├── test_customer_name_fix.py
|
│ ├── test_customer_name_fix.py
|
||||||
│ ├── test_customers.py
|
│ ├── test_customers.py
|
||||||
|
│ ├── test_entezar_action_simulation.py
|
||||||
│ ├── test_factors.py
|
│ ├── test_factors.py
|
||||||
│ ├── test_inventory.py
|
│ ├── test_inventory.py
|
||||||
│ ├── test_models_contract.py
|
│ ├── test_models_contract.py
|
||||||
|
|||||||
@@ -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),
|
||||||
|
]
|
||||||
@@ -11,7 +11,7 @@ from django.db import models
|
|||||||
|
|
||||||
|
|
||||||
class SMSType(models.TextChoices):
|
class SMSType(models.TextChoices):
|
||||||
"""انواع پیامکهای سیستم (دقیقاً ۷ نوع، به ترتیب چرخهٔ کاری).
|
"""انواع پیامکهای سیستم (دقیقاً ۸ نوع، به ترتیب چرخهٔ کاری).
|
||||||
|
|
||||||
ترتیب تعریف = ترتیب نمایش در پنل. هر نوع یا از یک رویداد (view) یا از یک
|
ترتیب تعریف = ترتیب نمایش در پنل. هر نوع یا از یک رویداد (view) یا از یک
|
||||||
اسکنر دورهای در صف قرار میگیرد؛ نگاشت آن در ``admin_panel/sms/triggers.py``
|
اسکنر دورهای در صف قرار میگیرد؛ نگاشت آن در ``admin_panel/sms/triggers.py``
|
||||||
@@ -23,6 +23,7 @@ class SMSType(models.TextChoices):
|
|||||||
CHEQUE_REGISTRATION = "cheque_registration", "یادآوری تأمین موجودی چک"
|
CHEQUE_REGISTRATION = "cheque_registration", "یادآوری تأمین موجودی چک"
|
||||||
VISIT = "visit", "یادآوری بازدید خدمات دستگاه"
|
VISIT = "visit", "یادآوری بازدید خدمات دستگاه"
|
||||||
VISIT_CREATED = "visit_created", "ثبت بازدید (اقدام از نوع بازدید)"
|
VISIT_CREATED = "visit_created", "ثبت بازدید (اقدام از نوع بازدید)"
|
||||||
|
INSTALL_CREATED = "install_created", "ثبت نصب (اقدام از نوع نصب)"
|
||||||
PAYMENT_THANKS = "payment_thanks", "تشکر از پرداخت"
|
PAYMENT_THANKS = "payment_thanks", "تشکر از پرداخت"
|
||||||
NESIYE_DUE = "nesiye_due", "یادآوری سررسید نسیه"
|
NESIYE_DUE = "nesiye_due", "یادآوری سررسید نسیه"
|
||||||
|
|
||||||
|
|||||||
@@ -261,8 +261,9 @@ def scan_visit(today, *, now=None):
|
|||||||
return count
|
return count
|
||||||
|
|
||||||
|
|
||||||
# فقط همان ۷ نوع مجاز؛ انواع رویدادی (خوشآمدگویی، نظرسنجی، ثبت بازدید، تشکر از
|
# فقط انواعی که «موعد» دارند و دورهای اسکن میشوند؛ انواع رویدادی (خوشآمدگویی،
|
||||||
# پرداخت) در ``admin_panel/sms/triggers.py`` توسط ویوها صف میشوند، نه اینجا.
|
# نظرسنجی، ثبت بازدید، ثبت نصب، تشکر از پرداخت) در ``admin_panel/sms/triggers.py``
|
||||||
|
# توسط ویوها صف میشوند، نه اینجا.
|
||||||
SCANNERS = [
|
SCANNERS = [
|
||||||
("cheque_registration", scan_cheque_funding),
|
("cheque_registration", scan_cheque_funding),
|
||||||
("nesiye_due", scan_nesiye_due),
|
("nesiye_due", scan_nesiye_due),
|
||||||
|
|||||||
@@ -46,6 +46,11 @@ TIMING = {
|
|||||||
"field": None,
|
"field": None,
|
||||||
"note": "بهمحض ثبت «اقدام از نوع بازدید» ارسال میشود؛ تنظیم زمانی ندارد.",
|
"note": "بهمحض ثبت «اقدام از نوع بازدید» ارسال میشود؛ تنظیم زمانی ندارد.",
|
||||||
},
|
},
|
||||||
|
SMSType.INSTALL_CREATED: {
|
||||||
|
"kind": NONE,
|
||||||
|
"field": None,
|
||||||
|
"note": "بهمحض ثبت «اقدام از نوع نصب» ارسال میشود؛ تنظیم زمانی ندارد.",
|
||||||
|
},
|
||||||
SMSType.PAYMENT_THANKS: {
|
SMSType.PAYMENT_THANKS: {
|
||||||
"kind": NONE,
|
"kind": NONE,
|
||||||
"field": None,
|
"field": None,
|
||||||
|
|||||||
@@ -7,6 +7,7 @@
|
|||||||
* :func:`notify_welcome` — ایجاد مشتری.
|
* :func:`notify_welcome` — ایجاد مشتری.
|
||||||
* :func:`notify_factor_survey` — ثبت فاکتور (نظرسنجی).
|
* :func:`notify_factor_survey` — ثبت فاکتور (نظرسنجی).
|
||||||
* :func:`notify_visit_scheduled` — ثبت «اقدام از نوع بازدید».
|
* :func:`notify_visit_scheduled` — ثبت «اقدام از نوع بازدید».
|
||||||
|
* :func:`notify_install_scheduled` — ثبت «اقدام از نوع نصب».
|
||||||
* :func:`notify_payment_thanks` — ثبت پرداخت (سند بستانکار / وصول).
|
* :func:`notify_payment_thanks` — ثبت پرداخت (سند بستانکار / وصول).
|
||||||
|
|
||||||
قرارداد پروژه: استفاده از signal ممنوع است؛ همهچیز صریح از دلِ ویو صدا زده میشود.
|
قرارداد پروژه: استفاده از 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,
|
def notify_payment_thanks(customer, amount, *, article_id=None, method=None,
|
||||||
created_by=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 %}
|
|
||||||
@@ -180,9 +180,12 @@
|
|||||||
</form>
|
</form>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{% include "./_customer_sms.html" %}
|
|
||||||
</div>
|
</div>
|
||||||
|
{% comment %}
|
||||||
|
باکس «پیامک» از این صفحه حذف شد: تنظیمات پیامک به تب «سوابق پیامک» مشتری
|
||||||
|
(/panel/customer/<id>/sms/) منتقل شده تا فرم فاکتور تمامعرض بماند و سوابق و
|
||||||
|
تنظیمات هر دو در یک صفحهٔ اختصاصی دیده شوند.
|
||||||
|
{% endcomment %}
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -4,30 +4,223 @@
|
|||||||
|
|
||||||
{% block BodyCustomer1 %}
|
{% block BodyCustomer1 %}
|
||||||
|
|
||||||
|
{% comment %}
|
||||||
|
صفحهٔ «پیامک مشتری» = سوابق + تنظیمات در یک صفحه.
|
||||||
|
قاعدهٔ طراحی: تنظیمات هرگز روی سوابق نمینشیند؛ فقط آمار + دکمهها بالاست و
|
||||||
|
تنظیمات/ارسال سفارشی/شمارههای اصلی داخل مودال باز میشوند.
|
||||||
|
همهٔ کلاسها با پیشوند csmsx و محدود به #customerSmsHistory هستند تا به
|
||||||
|
صفحات دیگر نشت نکنند.
|
||||||
|
{% endcomment %}
|
||||||
<style>
|
<style>
|
||||||
/* جدول سوابق پیامک مشتری */
|
{% verbatim %}
|
||||||
.smshist-msg {
|
#customerSmsHistory, #customerSmsHistory *, #customerSmsHistory *::before,
|
||||||
white-space: pre-wrap;
|
#customerSmsHistory *::after { box-sizing: border-box; }
|
||||||
overflow-wrap: anywhere;
|
/* متغیرها روی خودِ صفحه و روی ریشهٔ مودالها تعریف میشوند؛ مودالها در بلوک
|
||||||
max-width: 34rem;
|
جدا (BodyCustomer2) رندر میشوند و خارج از #customerSmsHistory هستند. */
|
||||||
line-height: 1.9;
|
#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; }
|
{% endverbatim %}
|
||||||
.smshist-badge { white-space: nowrap; }
|
|
||||||
</style>
|
</style>
|
||||||
|
|
||||||
<div id="customerSmsHistory" class="column is-12-mobile is-12-desktop" dir="rtl">
|
<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">
|
<div class="control">
|
||||||
|
<label for="csmsx-status">وضعیت ارسال</label>
|
||||||
<div class="select">
|
<div class="select">
|
||||||
<select name="status">
|
<select id="csmsx-status" name="status">
|
||||||
<option value="">همهٔ وضعیتهای ارسال</option>
|
<option value="">همهٔ وضعیتها</option>
|
||||||
{% for value, label in status_choices %}
|
{% for value, label in status_choices %}
|
||||||
<option value="{{ value }}" {% if status == value %}selected{% endif %}>{{ label }}</option>
|
<option value="{{ value }}" {% if status == value %}selected{% endif %}>{{ label }}</option>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
@@ -35,88 +228,113 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="control">
|
<div class="control">
|
||||||
|
<label for="csmsx-delivery">وضعیت تحویل</label>
|
||||||
<div class="select">
|
<div class="select">
|
||||||
<select name="delivery">
|
<select id="csmsx-delivery" name="delivery">
|
||||||
<option value="">همهٔ وضعیتهای تحویل</option>
|
<option value="">همهٔ وضعیتها</option>
|
||||||
{% for value, label in delivery_choices %}
|
{% for value, label in delivery_choices %}
|
||||||
<option value="{{ value }}" {% if delivery == value %}selected{% endif %}>{{ label }}</option>
|
<option value="{{ value }}" {% if delivery == value %}selected{% endif %}>{{ label }}</option>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
</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 %}
|
{% if status or delivery %}
|
||||||
<div class="control">
|
<div class="control">
|
||||||
<a class="button is-light" href="{% url 'admin_panel:customerSmsHistory' customer.id %}">حذف فیلتر</a>
|
<a class="button is-light" href="{% url 'admin_panel:customerSmsHistory' customer.id %}">حذف فیلتر</a>
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% 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>
|
</form>
|
||||||
|
|
||||||
{% if status or delivery %}
|
{# ---------- ۳) سوابق ---------- #}
|
||||||
<p class="mb-2 smshist-meta">نتایج فیلترشده: <strong>{{ filtered_count }}</strong> مورد</p>
|
<div class="csmsx-history">
|
||||||
{% endif %}
|
<div class="csmsx-history__scroll">
|
||||||
|
<table class="table is-fullwidth is-narrow is-hoverable is-striped">
|
||||||
<div class="table-container">
|
<thead>
|
||||||
<table class="table is-fullwidth is-narrow is-hoverable is-striped is-bordered">
|
<tr>
|
||||||
<thead>
|
<th class="csmsx-col-id">شناسه</th>
|
||||||
<tr>
|
<th style="min-width: 8.5rem;">نوع پیامک</th>
|
||||||
<th>شناسه</th>
|
<th style="min-width: 8rem;">گیرنده</th>
|
||||||
<th style="min-width: 7rem;">نوع پیامک</th>
|
<th style="min-width: 16rem;">متن پیامک</th>
|
||||||
<th style="min-width: 8rem;">شماره گیرنده</th>
|
<th>وضعیت ارسال</th>
|
||||||
<th style="min-width: 18rem;">متن پیامک</th>
|
<th style="min-width: 8rem;">وضعیت تحویل</th>
|
||||||
<th>وضعیت ارسال</th>
|
<th style="min-width: 7rem;">تاریخ ایجاد</th>
|
||||||
<th style="min-width: 8rem;">وضعیت تحویل</th>
|
<th class="csmsx-col-sent" style="min-width: 7rem;">تاریخ ارسال</th>
|
||||||
<th>تاریخ ایجاد</th>
|
</tr>
|
||||||
<th>تاریخ ارسال</th>
|
</thead>
|
||||||
</tr>
|
<tbody>
|
||||||
</thead>
|
{% for item in items %}
|
||||||
<tbody>
|
<tr>
|
||||||
{% for item in items %}
|
<td class="csmsx-col-id csmsx-meta" data-label="شناسه">{{ item.id }}</td>
|
||||||
<tr>
|
<td data-label="نوع">
|
||||||
<td>{{ item.id }}</td>
|
{% if item.sms_type %}
|
||||||
<td>{{ item.get_sms_type_display|default:'—' }}</td>
|
<span class="csmsx-type-chip">{{ item.get_sms_type_display }}</span>
|
||||||
<td dir="ltr">{{ item.phone_number }}</td>
|
{% else %}
|
||||||
<td class="smshist-msg">{{ item.message }}</td>
|
<span class="csmsx-type-chip">سفارشی</span>
|
||||||
<td>
|
{% endif %}
|
||||||
<span class="tag smshist-badge
|
{% if item.attempts %}
|
||||||
{% if item.status == 'sent' %}is-success
|
<div class="csmsx-meta">تلاش: {{ item.attempts }}</div>
|
||||||
{% elif item.status == 'failed' %}is-danger
|
{% endif %}
|
||||||
{% elif item.status == 'pending' %}is-warning
|
</td>
|
||||||
{% elif item.status == 'approved' %}is-info
|
<td class="csmsx-meta" dir="ltr" data-label="گیرنده">{{ item.phone_number }}</td>
|
||||||
{% else %}is-light{% endif %}">
|
<td data-label="متن">
|
||||||
{{ item.get_status_display }}
|
<div class="csmsx-msg {% if item.message|length > 170 %}is-clamped{% endif %}">{{ item.message }}</div>
|
||||||
</span>
|
{% if item.message|length > 170 %}
|
||||||
{% if item.last_error %}
|
<button type="button" class="csmsx-expand js-csmsx-expand" aria-expanded="false">نمایش کامل</button>
|
||||||
<div class="has-text-danger smshist-meta" title="{{ item.last_error }}">
|
{% endif %}
|
||||||
خطا: {{ item.last_error|truncatechars:80 }}
|
</td>
|
||||||
</div>
|
<td data-label="ارسال">
|
||||||
{% endif %}
|
<span class="tag csmsx-badge
|
||||||
</td>
|
{% if item.status == 'sent' %}is-success
|
||||||
<td>
|
{% elif item.status == 'failed' %}is-danger
|
||||||
<span class="tag smshist-badge
|
{% elif item.status == 'pending' %}is-warning
|
||||||
{% if item.delivery_status == 'delivered' %}is-success
|
{% elif item.status == 'approved' %}is-info
|
||||||
{% elif item.delivery_status == 'not_delivered' %}is-danger
|
{% else %}is-light{% endif %}">
|
||||||
{% elif item.delivery_status == 'expired' %}is-danger
|
{{ item.get_status_display }}
|
||||||
{% elif item.delivery_status == 'pending' %}is-warning
|
</span>
|
||||||
{% else %}is-light{% endif %}">
|
{% if item.last_error %}
|
||||||
{{ item.get_delivery_status_display|default:'بدون پیگیری' }}
|
<div class="has-text-danger csmsx-meta" title="{{ item.last_error }}">
|
||||||
</span>
|
خطا: {{ item.last_error|truncatechars:80 }}
|
||||||
{% if item.delivery_checked_at %}
|
</div>
|
||||||
<div class="has-text-grey smshist-meta">استعلام: {{ item.delivery_checked_at|toJalaliDT }}</div>
|
{% endif %}
|
||||||
{% endif %}
|
</td>
|
||||||
</td>
|
<td data-label="تحویل">
|
||||||
<td class="smshist-meta">{{ item.created_at|toJalaliDT }}</td>
|
<span class="tag csmsx-badge
|
||||||
<td class="smshist-meta">{{ item.sent_at|toJalaliDT|default:'—' }}</td>
|
{% if item.delivery_status == 'delivered' %}is-success
|
||||||
</tr>
|
{% elif item.delivery_status == 'not_delivered' %}is-danger
|
||||||
{% empty %}
|
{% elif item.delivery_status == 'expired' %}is-danger
|
||||||
<tr><td colspan="8" class="has-text-centered">پیامکی برای این مشتری ثبت نشده است.</td></tr>
|
{% elif item.delivery_status == 'pending' %}is-warning
|
||||||
{% endfor %}
|
{% else %}is-light{% endif %}">
|
||||||
</tbody>
|
{{ item.get_delivery_status_display|default:'بدون پیگیری' }}
|
||||||
</table>
|
</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>
|
</div>
|
||||||
|
|
||||||
{% if items.has_other_pages %}
|
{% 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 %}
|
{% 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>
|
<a class="pagination-previous" href="?page={{ items.previous_page_number }}{% if status %}&status={{ status }}{% endif %}{% if delivery %}&delivery={{ delivery }}{% endif %}">قبلی</a>
|
||||||
{% else %}
|
{% else %}
|
||||||
@@ -135,3 +353,235 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{% endblock %}
|
{% 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 %}
|
||||||
|
|
||||||
@@ -11,9 +11,10 @@ from django.utils import timezone
|
|||||||
from persiantools.jdatetime import JalaliDate
|
from persiantools.jdatetime import JalaliDate
|
||||||
|
|
||||||
from ..models import Action, Customer
|
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,
|
from ..visit_policy import (REASON_CANCEL, REASON_CREATE, REASON_EDIT,
|
||||||
ActionDeleteError, as_aware, clear_visit_hold,
|
ActionDeleteError, as_aware,
|
||||||
|
cancel_pending_sms_for_action, clear_visit_hold,
|
||||||
default_hold_until, normalize_finish_datetime,
|
default_hold_until, normalize_finish_datetime,
|
||||||
normalize_hold_until, refresh_last_bazdid,
|
normalize_hold_until, refresh_last_bazdid,
|
||||||
set_visit_hold, soft_delete_action,
|
set_visit_hold, soft_delete_action,
|
||||||
@@ -159,6 +160,45 @@ def _apply_visit_hold(customer_obj, payload, *, status, is_new, force_hold=False
|
|||||||
return result
|
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):
|
def _jalali_day(value):
|
||||||
"""تاریخ شمسی روز (به وقت محلی) برای پیامهای کاربر."""
|
"""تاریخ شمسی روز (به وقت محلی) برای پیامهای کاربر."""
|
||||||
if value is None:
|
if value is None:
|
||||||
@@ -232,9 +272,8 @@ def customerNewAction(request ,c_id):
|
|||||||
)
|
)
|
||||||
_report_hold_result(request, hold_result)
|
_report_hold_result(request, hold_result)
|
||||||
|
|
||||||
# پیامک «بازدید شما برای تاریخ … ثبت شد» فقط برای اقدام از نوع بازدید.
|
# پیامک «بازدید/نصب شما برای تاریخ … ثبت شد» (برای اقدام از نوع بازدید یا نصب).
|
||||||
if newAction.actionType == Action.ActionType.BAZDID:
|
_sync_action_event_sms(customer_obj, newAction, user=request.user)
|
||||||
notify_visit_scheduled(customer_obj, newAction, created_by=request.user)
|
|
||||||
|
|
||||||
return redirect('/panel/customer/'+str(c_id)+'/action')
|
return redirect('/panel/customer/'+str(c_id)+'/action')
|
||||||
|
|
||||||
@@ -270,6 +309,9 @@ def customerEditAction(request ,c_id, action_id):
|
|||||||
customer_obj = Customer.objects.get(id=int(c_id))
|
customer_obj = Customer.objects.get(id=int(c_id))
|
||||||
newAction = Action.objects.get(id=action_id)
|
newAction = Action.objects.get(id=action_id)
|
||||||
was_done = newAction.status == Action.Status.FINISH
|
was_done = newAction.status == Action.Status.FINISH
|
||||||
|
# نوع **قبلی** رکورد (پیش از بازنویسی) — برای تشخیص گذار «غیربازدید → بازدید»
|
||||||
|
# و برعکس. اگر بعد از ``save()`` خوانده شود، همیشه مقدار جدید است.
|
||||||
|
previous_type = newAction.actionType
|
||||||
|
|
||||||
newAction.actionType = payload['actionType']
|
newAction.actionType = payload['actionType']
|
||||||
newAction.actionDateTime = payload['actionDateTime']
|
newAction.actionDateTime = payload['actionDateTime']
|
||||||
@@ -297,6 +339,15 @@ def customerEditAction(request ,c_id, action_id):
|
|||||||
)
|
)
|
||||||
_report_hold_result(request, hold_result)
|
_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('/panel/customer/'+str(c_id)+'/action')
|
||||||
return redirect(request.META.get('HTTP_REFERER', 'redirect_if_referer_not_found'))
|
return redirect(request.META.get('HTTP_REFERER', 'redirect_if_referer_not_found'))
|
||||||
|
|
||||||
|
|||||||
@@ -17,7 +17,6 @@ from ..sms.balance import customer_balance
|
|||||||
from ..sms.phones import (normalize_primary_values,
|
from ..sms.phones import (normalize_primary_values,
|
||||||
save_primary_normalized)
|
save_primary_normalized)
|
||||||
from ..sms.triggers import notify_payment_thanks, notify_welcome
|
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,
|
from ..visit_policy import (SOURCE_COORDINATION, SOURCE_MANUAL, SOURCE_SCHEDULED,
|
||||||
appointment_sources_map, as_aware,
|
appointment_sources_map, as_aware,
|
||||||
followups_on_date, last_visit_map, overdue_followups,
|
followups_on_date, last_visit_map, overdue_followups,
|
||||||
@@ -164,11 +163,9 @@ def customerNew2(request ,c_id):
|
|||||||
#all_prd = serializers.serialize("json", all_prda)
|
#all_prd = serializers.serialize("json", all_prda)
|
||||||
#return JsonResponse(all_prd,safe = False)
|
#return JsonResponse(all_prd,safe = False)
|
||||||
dateNow = datetime.datetime.now().date
|
dateNow = datetime.datetime.now().date
|
||||||
sms_toggles = customer_toggle_map(customer)
|
|
||||||
data = {
|
data = {
|
||||||
'customer': customer, 'categorys': categorys, 'dateNow': dateNow,
|
'customer': customer, 'categorys': categorys, 'dateNow': dateNow,
|
||||||
'factorha': factorha, 'mandehesabCs': mandehesabCs,
|
'factorha': factorha, 'mandehesabCs': mandehesabCs,
|
||||||
'sms_toggles': sms_toggles,
|
|
||||||
}
|
}
|
||||||
return render(request, 'admin_panel/customerNew2.html', data)
|
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)
|
today_followups = followups_on_date(today_date, now=now)
|
||||||
|
|
||||||
# پیگیریهای عقبافتاده: موعدشان گذشته و هنوز تعیین تکلیف نشدهاند.
|
# پیگیریهای عقبافتاده: موعدشان گذشته و هنوز تعیین تکلیف نشدهاند.
|
||||||
|
|||||||
+79
-13
@@ -17,6 +17,7 @@ from ..sms.parts import ENCODING_LABELS, describe, parts_count
|
|||||||
from ..sms.phones import (customer_phone_entries, save_primary_normalized,
|
from ..sms.phones import (customer_phone_entries, save_primary_normalized,
|
||||||
stored_primary_normalized)
|
stored_primary_normalized)
|
||||||
from ..sms.render import format_jalali, render_template
|
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
|
from ..sms.timing import apply_timing, current_timing_value, timing_for
|
||||||
|
|
||||||
_manage_required = permission_required("admin_panel.ManageSMS", raise_exception=True)
|
_manage_required = permission_required("admin_panel.ManageSMS", raise_exception=True)
|
||||||
@@ -58,6 +59,16 @@ def _notice(url, code, **extra):
|
|||||||
def _customer_url(c_id):
|
def _customer_url(c_id):
|
||||||
return reverse("admin_panel:customer", kwargs={"c_id": 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 = [
|
TEMPLATE_VARIABLES = [
|
||||||
("{name}", "نام مشتری"),
|
("{name}", "نام مشتری"),
|
||||||
@@ -79,6 +90,7 @@ SMS_TYPE_UI = {
|
|||||||
SMSType.CHEQUE_REGISTRATION: {"icon": "🧾", "when": "چند روز پیش از سررسید چک"},
|
SMSType.CHEQUE_REGISTRATION: {"icon": "🧾", "when": "چند روز پیش از سررسید چک"},
|
||||||
SMSType.VISIT: {"icon": "🔧", "when": "پس از گذشت دورهٔ بازدید دستگاه"},
|
SMSType.VISIT: {"icon": "🔧", "when": "پس از گذشت دورهٔ بازدید دستگاه"},
|
||||||
SMSType.VISIT_CREATED: {"icon": "📅", "when": "پس از ثبت اقدام بازدید"},
|
SMSType.VISIT_CREATED: {"icon": "📅", "when": "پس از ثبت اقدام بازدید"},
|
||||||
|
SMSType.INSTALL_CREATED: {"icon": "🛠️", "when": "پس از ثبت اقدام نصب"},
|
||||||
SMSType.PAYMENT_THANKS: {"icon": "🙏", "when": "پس از ثبت پرداخت"},
|
SMSType.PAYMENT_THANKS: {"icon": "🙏", "when": "پس از ثبت پرداخت"},
|
||||||
SMSType.NESIYE_DUE: {"icon": "💳", "when": "پیش از سررسید نسیه"},
|
SMSType.NESIYE_DUE: {"icon": "💳", "when": "پیش از سررسید نسیه"},
|
||||||
}
|
}
|
||||||
@@ -313,8 +325,12 @@ def sms_customer_toggle(request, c_id):
|
|||||||
)
|
)
|
||||||
setting.is_enabled = enabled
|
setting.is_enabled = enabled
|
||||||
setting.save()
|
setting.save()
|
||||||
return _notice(_customer_url(c_id), "customer_sms_saved")
|
# ``open=settings`` ⇒ صفحه با همان مودال تنظیمات باز میشود تا کاربر
|
||||||
return redirect("admin_panel:customer", c_id=c_id)
|
# چند نوع را پشتسرهم روشن/خاموش کند و هر بار مودال را باز نکند.
|
||||||
|
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/')
|
@login_required(login_url='/panel/p_login/')
|
||||||
@@ -336,13 +352,13 @@ def sms_primary_toggle(request, c_id):
|
|||||||
"",
|
"",
|
||||||
)
|
)
|
||||||
if not normalized:
|
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]
|
current = [n for n in stored_primary_normalized(customer) if n != normalized]
|
||||||
if enabled:
|
if enabled:
|
||||||
current.append(normalized)
|
current.append(normalized)
|
||||||
save_primary_normalized(customer, current)
|
save_primary_normalized(customer, current)
|
||||||
return _notice(_customer_url(c_id), "primary_updated")
|
return _notice(_customer_sms_url(c_id), "primary_updated")
|
||||||
return redirect("admin_panel:customer", c_id=c_id)
|
return redirect("admin_panel:customerSmsHistory", c_id=c_id)
|
||||||
|
|
||||||
|
|
||||||
@login_required(login_url='/panel/p_login/')
|
@login_required(login_url='/panel/p_login/')
|
||||||
@@ -358,15 +374,16 @@ def sms_send_custom(request, c_id):
|
|||||||
text = (request.POST.get("text", "") or "").strip()
|
text = (request.POST.get("text", "") or "").strip()
|
||||||
rendered = render_template(text, {"name": customer.name_family}).strip()
|
rendered = render_template(text, {"name": customer.name_family}).strip()
|
||||||
if not rendered:
|
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(
|
created = enqueue_sms(
|
||||||
customer=customer, sms_type="", message=rendered,
|
customer=customer, sms_type="", message=rendered,
|
||||||
created_by=request.user,
|
created_by=request.user,
|
||||||
approved=True, # متن را خودِ مدیر نوشته و ارسال را خواسته است.
|
approved=True, # متن را خودِ مدیر نوشته و ارسال را خواسته است.
|
||||||
)
|
)
|
||||||
code = "custom_queued" if created else "custom_no_phone"
|
code = "custom_queued" if created else "custom_no_phone"
|
||||||
return _notice(_customer_url(c_id), code)
|
return _notice(_customer_sms_url(c_id), code)
|
||||||
return redirect("admin_panel:customer", c_id=c_id)
|
return redirect("admin_panel:customerSmsHistory", c_id=c_id)
|
||||||
|
|
||||||
|
|
||||||
@login_required(login_url='/panel/p_login/')
|
@login_required(login_url='/panel/p_login/')
|
||||||
@@ -428,11 +445,17 @@ def sms_queue(request):
|
|||||||
@login_required(login_url='/panel/p_login/')
|
@login_required(login_url='/panel/p_login/')
|
||||||
@_manage_required
|
@_manage_required
|
||||||
def customer_sms_history(request, c_id):
|
def customer_sms_history(request, c_id):
|
||||||
"""سوابق پیامکهای یک مشتری روی صفحهٔ خود مشتری.
|
"""سوابق + تنظیمات پیامک یک مشتری (همه در یک صفحه).
|
||||||
|
|
||||||
همهٔ پیامکهایی که برای شمارههای این مشتری در صف قرار گرفته/ارسال شدهاند
|
ساختار صفحه عمداً دو لایه است تا **تنظیمات، سوابق را شلوغ نکند**:
|
||||||
(هر وضعیتی) با متن دقیق، تاریخ شمسی و وضعیت تحویل (دلیوری) نمایش داده میشوند و
|
|
||||||
فهرست صفحهبندی میشود. با ``?status=`` و ``?delivery=`` میتوان فیلتر کرد.
|
* لایهٔ اول (همیشه دیده میشود): آمار کوتاه، فیلترها و فهرست سوابق.
|
||||||
|
* لایهٔ دوم (فقط با کلیک): تنظیمات پیامک، شمارههای اصلی و ارسال سفارشی،
|
||||||
|
همه داخل مودالهای Bulma.
|
||||||
|
|
||||||
|
تنظیماتی که قبلاً داخل باکس «پیامک» صفحهٔ ایجاد فاکتور بود
|
||||||
|
(``_customer_sms.html``) حالا اینجاست؛ آن باکس حذف شد تا فاکتور و سوابق هر
|
||||||
|
دو تمامعرض بمانند.
|
||||||
"""
|
"""
|
||||||
customer = get_object_or_404(Customer, id=c_id)
|
customer = get_object_or_404(Customer, id=c_id)
|
||||||
status = request.GET.get("status", "")
|
status = request.GET.get("status", "")
|
||||||
@@ -446,6 +469,34 @@ def customer_sms_history(request, c_id):
|
|||||||
qs = qs.filter(delivery_status=delivery)
|
qs = qs.filter(delivery_status=delivery)
|
||||||
paginator = Paginator(qs, 20)
|
paginator = Paginator(qs, 20)
|
||||||
page = paginator.get_page(request.GET.get("page"))
|
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 = {
|
data = {
|
||||||
"customer": customer,
|
"customer": customer,
|
||||||
"items": page,
|
"items": page,
|
||||||
@@ -453,8 +504,23 @@ def customer_sms_history(request, c_id):
|
|||||||
"delivery": delivery,
|
"delivery": delivery,
|
||||||
"status_choices": SMSQueue.Status.choices,
|
"status_choices": SMSQueue.Status.choices,
|
||||||
"delivery_choices": SMSQueue.DeliveryStatus.choices,
|
"delivery_choices": SMSQueue.DeliveryStatus.choices,
|
||||||
"total_count": SMSQueue.objects.filter(customer=customer).count(),
|
"total_count": total_count,
|
||||||
"filtered_count": paginator.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)
|
return render(request, "admin_panel/customerSmsHistory.html", data)
|
||||||
|
|
||||||
|
|||||||
+115
-58
@@ -437,38 +437,56 @@ def _valid_manual_holds(holds):
|
|||||||
# تاریخ آینده). سناریوی رایج «همین حالا تماس گرفتم و قرار دو هفتهٔ بعد
|
# تاریخ آینده). سناریوی رایج «همین حالا تماس گرفتم و قرار دو هفتهٔ بعد
|
||||||
# گذاشتم» تاریخ اقدام را «امروز» میذارد؛ اگر شرط آیندهبودن تاریخ اقدام را
|
# گذاشتم» تاریخ اقدام را «امروز» میذارد؛ اگر شرط آیندهبودن تاریخ اقدام را
|
||||||
# بگذاریم، همین مورد (هدف اصلی این قابلیت) بیاثر میشد. پس فقط وضعیت مهم است.
|
# بگذاریم، همین مورد (هدف اصلی این قابلیت) بیاثر میشد. پس فقط وضعیت مهم است.
|
||||||
coordination_latest = dict(
|
# «چه کسی جدیدتر است» بر اساس (createdDT، id) سنجیده میشود، نه فقط createdDT.
|
||||||
Action.objects.filter(
|
# چرا: createdDT در SQLite/ویندوز دقت کافی ندارد و دو اقدام که در یک لحظه ثبت
|
||||||
customer_id__in=customer_ids,
|
# شوند مقدار یکسان میگیرند؛ با مقایسهٔ صرفاً `>` آنگاه «بازدید بعد از
|
||||||
actionType=Action.ActionType.HAMAHANGI,
|
# هماهنگی» تشخیص داده نمیشد و قرارِ مرده معتبر میماند. id یکتای افزایشی
|
||||||
status__in=(Action.Status.WAITE, Action.Status.FINISH),
|
# همان ترتیب ثبت را میدهد، پس بهعنوان مرزِ تساوی استفاده میشود.
|
||||||
|
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(
|
other_latest = {
|
||||||
Action.objects.filter(
|
cid: (last, last_id)
|
||||||
customer_id__in=customer_ids, actionType__in=_SUPERSEDING_TYPES,
|
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")
|
||||||
)
|
)
|
||||||
.values("customer_id")
|
}
|
||||||
.annotate(last=Max("createdDT"))
|
|
||||||
.values_list("customer_id", "last")
|
|
||||||
)
|
|
||||||
|
|
||||||
valid = {}
|
valid = {}
|
||||||
for customer_id, until in holds.items():
|
for customer_id, until in holds.items():
|
||||||
if until is None:
|
if until is None:
|
||||||
continue
|
continue
|
||||||
coordinated_at = coordination_latest.get(customer_id)
|
coordination = coordination_latest.get(customer_id)
|
||||||
if coordinated_at is None:
|
if coordination 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):
|
|
||||||
# پس از هماهنگی، اقدام دیگری ثبت شده ⇒ قرار منتفی است.
|
|
||||||
continue
|
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
|
valid[customer_id] = until
|
||||||
return valid
|
return valid
|
||||||
|
|
||||||
@@ -509,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`` و نه «هر بازدیدی»؟ بازدیدِ در انتظارِ بسیار قدیمی
|
چرا ``>= since`` و نه «هر بازدیدی»؟ بازدیدِ در انتظارِ بسیار قدیمی
|
||||||
(backlog بستهنشده) قرار فعال نیست؛ آن مشتری هنوز نیاز به تماس دارد.
|
(backlog بستهنشده) قرار فعال نیست؛ آن مشتری هنوز نیاز به تماس دارد.
|
||||||
"""
|
"""
|
||||||
return set(
|
return set(
|
||||||
Action.objects.filter(
|
Action.objects.filter(
|
||||||
actionType=Action.ActionType.BAZDID,
|
actionType__in=_CYCLE_RESET_TYPES,
|
||||||
status=Action.Status.WAITE,
|
status=Action.Status.WAITE,
|
||||||
)
|
)
|
||||||
.filter(
|
.filter(
|
||||||
@@ -547,8 +571,8 @@ def followups_on_date(target_date, *, now=None):
|
|||||||
امروز یا آینده — را میبیند تا کارشناس بتواند با تقویم عقب/جلو برود.
|
امروز یا آینده — را میبیند تا کارشناس بتواند با تقویم عقب/جلو برود.
|
||||||
|
|
||||||
خروجی: ``{customer_id: {منبع: تاریخ}}`` مرتب بر اساس زودترین ساعت موعد.
|
خروجی: ``{customer_id: {منبع: تاریخ}}`` مرتب بر اساس زودترین ساعت موعد.
|
||||||
مشتریانی که قرار **بازدید** دارند، یا بازدیدشان از همان روز موعد به بعد
|
مشتریانی که قرار **بازدید/نصب** دارند، یا بازدید/نصبشان از همان روز موعد به
|
||||||
**انجام شده**، در خروجی نیستند (فهرست، «کارِ باقیمانده» است نه آرشیو).
|
بعد **انجام شده**، در خروجی نیستند (فهرست، «کارِ باقیمانده» است نه آرشیو).
|
||||||
"""
|
"""
|
||||||
day_start, day_end = _local_day_bounds(target_date)
|
day_start, day_end = _local_day_bounds(target_date)
|
||||||
|
|
||||||
@@ -593,28 +617,33 @@ def followups_on_date(target_date, *, now=None):
|
|||||||
if current is None or when < current:
|
if current is None or when < current:
|
||||||
bucket[SOURCE_COORDINATION] = when
|
bucket[SOURCE_COORDINATION] = when
|
||||||
|
|
||||||
# ۳) حذف مشتریانی که برایشان «بازدید» گذاشته شده است.
|
# ۳) حذف مشتریانی که برایشان «بازدید» یا «نصب» گذاشته شده است.
|
||||||
if result:
|
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)
|
result.pop(customer_id, None)
|
||||||
|
|
||||||
# ۴) حذف مشتریانی که بازدیدشان از همان روزِ موعد به بعد **انجام شده** است.
|
# ۴) حذف مشتریانی که بازدید/نصبشان از همان روزِ موعد به بعد **انجام شده** است.
|
||||||
# قاعدهٔ کسبوکار: اگر بازدید انجام شده، تماسِ آن روز لازم نیست. قبلاً فقط
|
# قاعدهٔ کسبوکار: اگر بازدید (یا نصب) انجام شده، تماسِ آن روز لازم نیست. قبلاً
|
||||||
# بازدیدِ «در انتظار» دیده میشد، پس مشتریِ بازدیدشده باز هم در فهرست
|
# فقط بازدیدِ «در انتظار» دیده میشد، پس مشتریِ بازدیدشده باز هم در فهرست
|
||||||
# «نیازمند پیگیری امروز» میماند (۹۲٪ ردیفهای دادهٔ واقعی).
|
# «نیازمند پیگیری امروز» میماند (۹۲٪ ردیفهای دادهٔ واقعی).
|
||||||
|
# لنگر از :func:`cycle_anchor_map` میآید — یعنی همان دو نوعی که چرخه را از نو
|
||||||
|
# شروع میکنند (``bazdid``/``nasb``). برای ``bazdid`` خروجی عیناً همان مقدار
|
||||||
|
# قبلی (``last_visit_map``) است، پس رفتار موجود تغییر نمیکند و «نصب» هم ردیف
|
||||||
|
# را میبندد. عمداً ``last_visit_map`` گشاد نشد: آن «تاریخ آخرین بازدید» است
|
||||||
|
# (ستون جدول، ``last_bazdid_dt`` و پیامک) و نباید نصب را بازدید حساب کند.
|
||||||
# فقط برای امروز/آینده اعمال میشود تا مرور روزهای گذشته (تقویم) سندِ کاریِ
|
# فقط برای امروز/آینده اعمال میشود تا مرور روزهای گذشته (تقویم) سندِ کاریِ
|
||||||
# همان روز را با دادههای بعدی بازنویسی نکند.
|
# همان روز را با دادههای بعدی بازنویسی نکند.
|
||||||
if result:
|
if result:
|
||||||
now = _now(now)
|
now = _now(now)
|
||||||
current_tz = timezone.get_current_timezone()
|
current_tz = timezone.get_current_timezone()
|
||||||
if target_date >= now.astimezone(current_tz).date():
|
if target_date >= now.astimezone(current_tz).date():
|
||||||
visits = last_visit_map(now=now, customer_ids=list(result))
|
anchors = cycle_anchor_map(now=now, customer_ids=list(result))
|
||||||
for customer_id, visited_at in visits.items():
|
for customer_id, done_at in anchors.items():
|
||||||
bucket = result.get(customer_id)
|
bucket = result.get(customer_id)
|
||||||
if not bucket or visited_at is None:
|
if not bucket or done_at is None:
|
||||||
continue
|
continue
|
||||||
due_day = min(bucket.values()).astimezone(current_tz).date()
|
due_day = min(bucket.values()).astimezone(current_tz).date()
|
||||||
if visited_at.astimezone(current_tz).date() >= due_day:
|
if done_at.astimezone(current_tz).date() >= due_day:
|
||||||
result.pop(customer_id, None)
|
result.pop(customer_id, None)
|
||||||
|
|
||||||
return result
|
return result
|
||||||
@@ -631,10 +660,10 @@ def overdue_followups(*, now=None, target_date=None):
|
|||||||
|
|
||||||
* اگر مشتری هماهنگیِ **امروز یا آینده** داشته باشد، عقبافتاده نیست
|
* اگر مشتری هماهنگیِ **امروز یا آینده** داشته باشد، عقبافتاده نیست
|
||||||
(موعدش تازه است).
|
(موعدش تازه است).
|
||||||
* اگر برایش **قرار بازدید** ثبت شده باشد، از فهرست خارج میشود — همان قاعدهٔ
|
* اگر برایش **قرار بازدید/نصب** ثبت شده باشد، از فهرست خارج میشود — همان
|
||||||
:func:`followups_on_date` («بازدید گذاشته شده ⇒ تماس لازم نیست»).
|
قاعدهٔ :func:`followups_on_date` («بازدید/نصب گذاشته شده ⇒ تماس لازم نیست»).
|
||||||
* اگر بازدیدش **از همان موعد به بعد انجام شده** باشد، از فهرست خارج میشود
|
* اگر بازدید یا نصبش **از همان موعد به بعد انجام شده** باشد، از فهرست خارج
|
||||||
(پیگیری با بازدید بسته شده است).
|
میشود (پیگیری با بازدید/نصب بسته شده است).
|
||||||
* اگر **قرار دستیِ معتبر** داشته باشد («تا تاریخ پیگیری نشود»)، از فهرست
|
* اگر **قرار دستیِ معتبر** داشته باشد («تا تاریخ پیگیری نشود»)، از فهرست
|
||||||
خارج میشود؛ چون خودِ کاربر صریحاً خواسته تا آن تاریخ تماس نگیریم.
|
خارج میشود؛ چون خودِ کاربر صریحاً خواسته تا آن تاریخ تماس نگیریم.
|
||||||
* هماهنگیِ لغوشده (``cancell``) اصلاً دیده نمیشود.
|
* هماهنگیِ لغوشده (``cancell``) اصلاً دیده نمیشود.
|
||||||
@@ -669,10 +698,10 @@ def overdue_followups(*, now=None, target_date=None):
|
|||||||
if not overdue:
|
if not overdue:
|
||||||
return {}
|
return {}
|
||||||
|
|
||||||
# حذف مشتریانی که برایشان بازدید گذاشته شده است (قرار بازدید از آن روز).
|
# حذف مشتریانی که برایشان بازدید/نصب گذاشته شده است (قرار از آن روز).
|
||||||
# «از آن روز» = زودترین موعدِ ازدسترفته؛ بازدیدی که بعد از آن گذاشته شده
|
# «از آن روز» = زودترین موعدِ ازدسترفته؛ بازدید/نصبی که بعد از آن گذاشته
|
||||||
# تکلیف مشتری را روشن کرده است.
|
# شده تکلیف مشتری را روشن کرده است.
|
||||||
pending_visits = _pending_visit_customer_ids(
|
pending_visits = _pending_appointment_ids(
|
||||||
since=_local_day_bounds(min(overdue.values()))[0]
|
since=_local_day_bounds(min(overdue.values()))[0]
|
||||||
)
|
)
|
||||||
for customer_id in pending_visits:
|
for customer_id in pending_visits:
|
||||||
@@ -680,14 +709,16 @@ def overdue_followups(*, now=None, target_date=None):
|
|||||||
|
|
||||||
current_tz = timezone.get_current_timezone()
|
current_tz = timezone.get_current_timezone()
|
||||||
|
|
||||||
# بازدیدِ **انجامشده** از همان موعد به بعد ⇒ این پیگیری انجام شده است.
|
# بازدید/نصبِ **انجامشده** از همان موعد به بعد ⇒ این پیگیری انجام شده است.
|
||||||
# قبلاً فقط بازدیدِ «در انتظار» دیده میشد و همین، فهرست «از روزهای قبل»
|
# قبلاً فقط بازدیدِ «در انتظار» دیده میشد و همین، فهرست «از روزهای قبل»
|
||||||
# را پر از مشتریانی میکرد که بازدیدشان ثبت شده بود (۸۸٪ دادهٔ واقعی).
|
# را پر از مشتریانی میکرد که بازدیدشان ثبت شده بود (۸۸٪ دادهٔ واقعی).
|
||||||
visits = last_visit_map(now=now, customer_ids=list(overdue))
|
# لنگر همان ``cycle_anchor_map`` است (``bazdid``/``nasb``) تا «نصب» هم مثل
|
||||||
for customer_id, visited_at in visits.items():
|
# بازدید پیگیری را ببندد.
|
||||||
if visited_at is None:
|
anchors = cycle_anchor_map(now=now, customer_ids=list(overdue))
|
||||||
|
for customer_id, done_at in anchors.items():
|
||||||
|
if done_at is None:
|
||||||
continue
|
continue
|
||||||
if visited_at.astimezone(current_tz).date() >= overdue[customer_id]:
|
if done_at.astimezone(current_tz).date() >= overdue[customer_id]:
|
||||||
overdue.pop(customer_id, None)
|
overdue.pop(customer_id, None)
|
||||||
|
|
||||||
# «قرار دستیِ معتبر» یعنی کاربر صریحاً گفته «تا این تاریخ پیگیری نشود»؛
|
# «قرار دستیِ معتبر» یعنی کاربر صریحاً گفته «تا این تاریخ پیگیری نشود»؛
|
||||||
@@ -1008,14 +1039,24 @@ class ActionDeleteError(Exception):
|
|||||||
"""حذف مجاز نیست؛ پیام برای نمایش به کاربر."""
|
"""حذف مجاز نیست؛ پیام برای نمایش به کاربر."""
|
||||||
|
|
||||||
|
|
||||||
def cancel_pending_sms_for_action(customer, *, now=None):
|
def cancel_pending_sms_for_action(customer, *, action_id=None, sms_type=None, now=None):
|
||||||
"""لغو پیامکهای **ارسالنشدهٔ** «بازدید ثبت شد» همان مشتری.
|
"""لغو پیامکهای **ارسالنشدهٔ** رویدادیِ همان مشتری («بازدید/نصب ثبت شد»).
|
||||||
|
|
||||||
چرا با ``action`` پیدا نمیکنیم؟ چون ``SMSQueue`` هیچ ارجاعی به رکورد اقدام
|
چرا با ``action`` پیدا نمیکنیم؟ چون ``SMSQueue`` هیچ ارجاعی به رکورد اقدام
|
||||||
ندارد (فقط ``dedup_key`` متنی). برای جلوگیری از ارسال پیامکِ بازدیدی که
|
ندارد (فقط ``dedup_key`` متنی). برای جلوگیری از ارسال پیامکِ بازدیدی که
|
||||||
دیگر وجود ندارد، پیامکهای معلق همان مشتری با نوع ``visit_created`` لغو
|
دیگر وجود ندارد، پیامکهای معلق همان مشتری با نوع ``visit_created`` لغو
|
||||||
میشوند. پیامکهای **ارسالشده** دست نمیخورند (سابقه است).
|
میشوند. پیامکهای **ارسالشده** دست نمیخورند (سابقه است).
|
||||||
|
|
||||||
|
``sms_type`` (اختیاری): نوع پیامک مورد هدف؛ پیشفرض ``visit_created``
|
||||||
|
(رفتار قبلی، که حذف اقدام از آن استفاده میکند). برای «نصب» مقدار
|
||||||
|
``install_created`` داده میشود.
|
||||||
|
|
||||||
|
``action_id`` (اختیاری): فقط پیامکهای همان رکورد اقدام را لغو میکند. کلید
|
||||||
|
یکتاسازی تریگر ``{sms_type}:{action_id}`` (یا با پسوند شماره) است؛ پس
|
||||||
|
«ویرایش یک رکورد از بازدید به نوع دیگر» نباید پیامک بازدیدِ *رکورد دیگر*
|
||||||
|
را از بین ببرد. پیشفرض (``None``) همهٔ پیامکهای معلق همان نوع را لغو
|
||||||
|
میکند.
|
||||||
|
|
||||||
خروجی: تعداد رکوردهای لغوشده.
|
خروجی: تعداد رکوردهای لغوشده.
|
||||||
"""
|
"""
|
||||||
# ایمپورت محلی: sms.models در زمان ایمپورت این ماژول ممکن است آماده نباشد.
|
# ایمپورت محلی: sms.models در زمان ایمپورت این ماژول ممکن است آماده نباشد.
|
||||||
@@ -1024,16 +1065,24 @@ def cancel_pending_sms_for_action(customer, *, now=None):
|
|||||||
if customer is None or customer.pk is None:
|
if customer is None or customer.pk is None:
|
||||||
return 0
|
return 0
|
||||||
now = _now(now)
|
now = _now(now)
|
||||||
|
sms_type = sms_type or SMSType.VISIT_CREATED
|
||||||
pending = (SMSQueue.Status.PENDING, SMSQueue.Status.APPROVED)
|
pending = (SMSQueue.Status.PENDING, SMSQueue.Status.APPROVED)
|
||||||
return (
|
query = (
|
||||||
SMSQueue.objects.filter(
|
SMSQueue.objects.filter(
|
||||||
customer_id=customer.pk,
|
customer_id=customer.pk,
|
||||||
sms_type=SMSType.VISIT_CREATED,
|
sms_type=sms_type,
|
||||||
status__in=pending,
|
status__in=pending,
|
||||||
)
|
)
|
||||||
.exclude(status=SMSQueue.Status.SENT)
|
.exclude(status=SMSQueue.Status.SENT)
|
||||||
.update(status=SMSQueue.Status.CANCELLED, updated_at=now)
|
|
||||||
)
|
)
|
||||||
|
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):
|
def soft_delete_action(action, *, user=None, now=None):
|
||||||
@@ -1051,7 +1100,8 @@ def soft_delete_action(action, *, user=None, now=None):
|
|||||||
۱. ``last_bazdid_dt`` بازمحاسبه میشود (کش کهنه نماند).
|
۱. ``last_bazdid_dt`` بازمحاسبه میشود (کش کهنه نماند).
|
||||||
۲. قرار دستی اگر دیگر مرجعی نداشته باشد، خودکار بیاثر میشود
|
۲. قرار دستی اگر دیگر مرجعی نداشته باشد، خودکار بیاثر میشود
|
||||||
(قاعدهٔ «ستون، مرجع نیست»).
|
(قاعدهٔ «ستون، مرجع نیست»).
|
||||||
۳. پیامکهای **ارسالنشده**ی همان مشتری لغو میشوند.
|
۳. پیامکهای **ارسالنشده**ی رویدادیِ همان مشتری لغو میشوند (``visit_created``
|
||||||
|
و برای اقدام «نصب»، ``install_created``).
|
||||||
|
|
||||||
ممنوعیت: اقدامی که نظرسنجی ثبتشده دارد (``nazarDT`` پر است) حذف نمیشود؛
|
ممنوعیت: اقدامی که نظرسنجی ثبتشده دارد (``nazarDT`` پر است) حذف نمیشود؛
|
||||||
زیرا هیچ آرشیو/کپیای وجود ندارد و گزارش رضایتسنجی برای همیشه تغییر میکرد.
|
زیرا هیچ آرشیو/کپیای وجود ندارد و گزارش رضایتسنجی برای همیشه تغییر میکرد.
|
||||||
@@ -1081,6 +1131,13 @@ def soft_delete_action(action, *, user=None, now=None):
|
|||||||
if customer is not None:
|
if customer is not None:
|
||||||
refresh_last_bazdid(customer, reason=REASON_CANCEL, user=user, now=now)
|
refresh_last_bazdid(customer, reason=REASON_CANCEL, user=user, now=now)
|
||||||
cancelled = cancel_pending_sms_for_action(customer, 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:
|
else:
|
||||||
cancelled = 0
|
cancelled = 0
|
||||||
|
|
||||||
|
|||||||
@@ -158,7 +158,8 @@ customerActionInc.html
|
|||||||
| نوع | زمان ارسال | تصمیمگیر | دادههای دخیل |
|
| نوع | زمان ارسال | تصمیمگیر | دادههای دخیل |
|
||||||
|---|---|---|---|
|
|---|---|---|---|
|
||||||
| `visit` (دورهای) | اسکنر (`scan_visit`) وقتی موعدِ «آخرین بازدید + ۹ ماه» رسیده باشد | `cycle_anchor_map` + `next_appointment_map` | **هر دو فیلد** (فیلد اول بهواسطهٔ لنگر، فیلد دوم بهواسطهٔ قرار) |
|
| `visit` (دورهای) | اسکنر (`scan_visit`) وقتی موعدِ «آخرین بازدید + ۹ ماه» رسیده باشد | `cycle_anchor_map` + `next_appointment_map` | **هر دو فیلد** (فیلد اول بهواسطهٔ لنگر، فیلد دوم بهواسطهٔ قرار) |
|
||||||
| `visit_created` (رویدادی) | بلافاصله پس از ثبت اقدام **بازدید** | `notify_visit_scheduled` | فقط `actionDateTime` (برای متن `{date}`) |
|
| `visit_created` (رویدادی) | بلافاصله پس از ثبت اقدام **بازدید** — هم در «ایجاد» و هم وقتی نوع اقدام در «ویرایش» **به** بازدید تغییر کند | `notify_visit_scheduled` | فقط `actionDateTime` (برای متن `{date}`) |
|
||||||
|
| `install_created` (رویدادی) | بلافاصله پس از ثبت اقدام **نصب** — همان دو مسیر (ایجاد و تغییر نوع در ویرایش) | `notify_install_scheduled` | فقط `actionDateTime` (برای متن `{date}`) |
|
||||||
|
|
||||||
### ۴.۲ ماتریس اثر (نتیجهٔ شبیهسازی، همه با `scan_visit`)
|
### ۴.۲ ماتریس اثر (نتیجهٔ شبیهسازی، همه با `scan_visit`)
|
||||||
|
|
||||||
@@ -171,6 +172,7 @@ customerActionInc.html
|
|||||||
| هماهنگی با تاریخ +۵ روز (بدون تعویق) | ⛔ | — |
|
| هماهنگی با تاریخ +۵ روز (بدون تعویق) | ⛔ | — |
|
||||||
| هماهنگی «انجامشده» با تاریخ گذشته و بدون تعویق | ✅ ارسال | — |
|
| هماهنگی «انجامشده» با تاریخ گذشته و بدون تعویق | ✅ ارسال | — |
|
||||||
| ثبت اقدام بازدید (هر وضعیتی) | — | ✅ با `{date}` = `actionDateTime` |
|
| ثبت اقدام بازدید (هر وضعیتی) | — | ✅ با `{date}` = `actionDateTime` |
|
||||||
|
| ثبت اقدام نصب (هر وضعیتی) | — | ✅ `install_created`، مستقل از `visit_created` |
|
||||||
| ثبت اقدام هماهنگی | — | ❌ |
|
| ثبت اقدام هماهنگی | — | ❌ |
|
||||||
| قرار دستی فعال + ثبت بازدید | — | ✅ (فیلد دوم مانع نیست) |
|
| قرار دستی فعال + ثبت بازدید | — | ✅ (فیلد دوم مانع نیست) |
|
||||||
|
|
||||||
@@ -181,11 +183,13 @@ customerActionInc.html
|
|||||||
|
|
||||||
### ۴.۳ چرا «تعویق» روی `visit_created` اثر ندارد؟
|
### ۴.۳ چرا «تعویق» روی `visit_created` اثر ندارد؟
|
||||||
|
|
||||||
چون این پیامک در `views/actions.py:170-171` فقط برای `actionType == bazdid` صدا زده
|
چون این پیامک در ویوهای اقدام (`views/actions.py`، تابع `_sync_action_event_sms`) فقط
|
||||||
میشود، و اصلاً از `next_appointment_map` عبور نمیکند. یعنی اگر هماهنگی بگذارید و بعد
|
برای `actionType == bazdid` صدا زده میشود، و اصلاً از `next_appointment_map` عبور
|
||||||
بازدیدی ثبت کنید، پیامکِ «بازدید شما برای تاریخ … ثبت شد» میرود حتی اگر «تا تاریخ
|
نمیکند. یعنی اگر هماهنگی بگذارید و بعد بازدیدی ثبت کنید، پیامکِ «بازدید شما برای
|
||||||
پیگیری نشود» فعال باشد. (اگر رفتار مطلوب چیز دیگری است، باید در `notify_visit_scheduled`
|
تاریخ … ثبت شد» میرود حتی اگر «تا تاریخ پیگیری نشود» فعال باشد. همین قاعده برای
|
||||||
یا در شرط فراخوانی آن تغییر کند.)
|
`install_created` (اقدام از نوع نصب) هم برقرار است. (اگر رفتار مطلوب چیز دیگری است،
|
||||||
|
باید در `notify_visit_scheduled`/`notify_install_scheduled` یا در شرط فراخوانی آنها
|
||||||
|
تغییر کند.)
|
||||||
|
|
||||||
### ۴.۴ جزئیات مهم `scan_visit`
|
### ۴.۴ جزئیات مهم `scan_visit`
|
||||||
|
|
||||||
@@ -525,6 +529,30 @@ for customer_id in manual_holds_map(now=now):
|
|||||||
| B6 | فنی | مرز «وظایف امروز» با رشتهٔ naive ساخته میشد (وابسته به تایمزون/DST) | `strftime('%Y-%m-%d 00:00:00')` در `ActionListToday`/`ActionListUserToday` | `_local_day_range()` صریح و aware با پوشش DST |
|
| B6 | فنی | مرز «وظایف امروز» با رشتهٔ naive ساخته میشد (وابسته به تایمزون/DST) | `strftime('%Y-%m-%d 00:00:00')` در `ActionListToday`/`ActionListUserToday` | `_local_day_range()` صریح و aware با پوشش DST |
|
||||||
| B7 | فنی | هر GET صفحهٔ انتظار، `CacheTable` را مینوشت (حتی بدون تغییر) | `save()` بیقید در ویو | فقط در صورت تغییر + `update_fields` |
|
| B7 | فنی | هر GET صفحهٔ انتظار، `CacheTable` را مینوشت (حتی بدون تغییر) | `save()` بیقید در ویو | فقط در صورت تغییر + `update_fields` |
|
||||||
| B8 | فنی (تست) | تست `test_overdue_column_hidden_only_in_followup_view` به ساعت اجرا وابسته بود | هماهنگی «امروز ساعت ۱۲» میتوانست «آینده» شمرده شود | تست با مشتری بدون قرار، مستقل از ساعت |
|
| 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` و پیامک)
|
||||||
|
> عمداً تغییر نکرد؛ «نصب» فقط لنگر چرخه است، نه بازدید.
|
||||||
|
|
||||||
### ۹.۳ اثر روی دادهٔ واقعی (همان نسخهٔ پشتیبان)
|
### ۹.۳ اثر روی دادهٔ واقعی (همان نسخهٔ پشتیبان)
|
||||||
|
|
||||||
|
|||||||
@@ -64,9 +64,16 @@
|
|||||||
| حذف اقدام هماهنگی | قرار **خودکار بیاثر** میشود |
|
| حذف اقدام هماهنگی | قرار **خودکار بیاثر** میشود |
|
||||||
| لغو هماهنگی (`cancell`) | بیاثر میشود |
|
| لغو هماهنگی (`cancell`) | بیاثر میشود |
|
||||||
| ویرایش تاریخ هماهنگی | قرار با تاریخ جدید همراستا میشود |
|
| ویرایش تاریخ هماهنگی | قرار با تاریخ جدید همراستا میشود |
|
||||||
| **ثبت بازدید (یا هر اقدام دیگر) پس از هماهنگی** | قرار **خودکار بیاثر** میشود |
|
| **ثبت بازدید یا نصب پس از هماهنگی** | قرار **خودکار بیاثر** میشود |
|
||||||
|
| ثبت سایر اقدامها (وصول، تماس…) | قرار دستنخورده میماند |
|
||||||
| کهنه ماندن ستون | **هیچ اثر مخربی ندارد** |
|
| کهنه ماندن ستون | **هیچ اثر مخربی ندارد** |
|
||||||
|
|
||||||
|
> **تکمیل ۲۰۲۶-۱۰-۲۷:** «نصب» در فهرستهای کار هم مثل «بازدید» عمل میکند:
|
||||||
|
> `nasb + waite` با تاریخ از امروز به بعد و `nasb + finish` از موعد به بعد، ردیف را
|
||||||
|
> از `?view=today` و `?view=today&missed=1` خارج میکند
|
||||||
|
> (`visit_policy._pending_appointment_ids` و `cycle_anchor_map`). این دو نوع همان
|
||||||
|
> `_CYCLE_RESET_TYPES` هستند که چرخهٔ ۹ ماهه و قرار دستی را هم بازنشانی میکنند.
|
||||||
|
|
||||||
به همین دلیل دیگر خطر «کش کهنه» وجود ندارد: منبع حقیقت همیشه جدول `Action` است.
|
به همین دلیل دیگر خطر «کش کهنه» وجود ندارد: منبع حقیقت همیشه جدول `Action` است.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
@@ -368,6 +368,25 @@ class AppointmentLifetimeSimulationTests(SimulationBase):
|
|||||||
make_action(self.customer, 'nasb', 'finish', action_dt=NOW, finish_dt=NOW)
|
make_action(self.customer, 'nasb', 'finish', action_dt=NOW, finish_dt=NOW)
|
||||||
self.assertNotIn(self.customer.pk, manual_holds_map())
|
self.assertNotIn(self.customer.pk, manual_holds_map())
|
||||||
|
|
||||||
|
def test_same_microsecond_visit_still_kills_appointment(self):
|
||||||
|
"""ثبت بازدید در همان لحظهٔ هماهنگی هم قرار را باطل میکند.
|
||||||
|
|
||||||
|
``createdDT`` روی SQLite/ویندوز دقت کافی ندارد و دو رکورد پشتسرهم میتوانند
|
||||||
|
مقدار یکسان بگیرند؛ مقایسه با ``id`` بهعنوان مرز تساوی، این حالت را هم
|
||||||
|
درست تشخیص میدهد (قبلاً تست به زمان اجرا وابسته و ناپایدار بود).
|
||||||
|
"""
|
||||||
|
from admin_panel.models import Action
|
||||||
|
|
||||||
|
self._hold_customer()
|
||||||
|
visit = make_action(self.customer, 'bazdid', 'finish',
|
||||||
|
action_dt=NOW, finish_dt=NOW)
|
||||||
|
# هر دو رکورد همان createdDT را میگیرند (بازسازی شرط مسابقه).
|
||||||
|
stamp = Action.objects.get(
|
||||||
|
customer=self.customer, actionType='hamahangi',
|
||||||
|
).createdDT
|
||||||
|
Action.objects.filter(pk=visit.pk).update(createdDT=stamp)
|
||||||
|
self.assertNotIn(self.customer.pk, manual_holds_map())
|
||||||
|
|
||||||
|
|
||||||
class FollowupDaySimulationTests(SimulationBase):
|
class FollowupDaySimulationTests(SimulationBase):
|
||||||
"""فهرست «نیازمند پیگیری امروز»: کارِ باقیمانده، نه آرشیو."""
|
"""فهرست «نیازمند پیگیری امروز»: کارِ باقیمانده، نه آرشیو."""
|
||||||
@@ -397,6 +416,45 @@ class FollowupDaySimulationTests(SimulationBase):
|
|||||||
make_action(self.customer, 'bazdid', 'finish', action_dt=NOW, finish_dt=NOW)
|
make_action(self.customer, 'bazdid', 'finish', action_dt=NOW, finish_dt=NOW)
|
||||||
self.assertIn(self.customer.pk, followups_on_date(yesterday))
|
self.assertIn(self.customer.pk, followups_on_date(yesterday))
|
||||||
|
|
||||||
|
# --- «نصب» هم مثل بازدید فهرست کار را میبندد ---
|
||||||
|
# قاعدهٔ کسبوکار: نصب و بازدید هر دو چرخه را از نو شروع میکنند
|
||||||
|
# (``_CYCLE_RESET_TYPES``) و قرار دستی را باطل میکنند؛ پس فهرست «نیازمند
|
||||||
|
# پیگیری» هم باید با هر دو بسته شود.
|
||||||
|
def test_install_done_today_clears_today_row(self):
|
||||||
|
"""«نصبِ انجامشدهٔ امروز» هم ردیف امروز را میبندد (مثل بازدید)."""
|
||||||
|
make_action(self.customer, 'hamahangi', 'finish',
|
||||||
|
action_dt=NOW, finish_dt=NOW)
|
||||||
|
self.assertIn(self.customer.pk, followups_on_date(timezone.localdate()))
|
||||||
|
make_action(self.customer, 'nasb', 'finish', action_dt=NOW, finish_dt=NOW)
|
||||||
|
self.assertNotIn(self.customer.pk, followups_on_date(timezone.localdate()))
|
||||||
|
ids, _ = self.stale_ids('today')
|
||||||
|
self.assertNotIn(self.customer.pk, ids)
|
||||||
|
|
||||||
|
def test_scheduled_install_hides_today_row(self):
|
||||||
|
"""«نصب» با وضعیت waite و تاریخ **آینده** ⇒ ردیف امروز پنهان میشود."""
|
||||||
|
make_action(self.customer, 'hamahangi', 'finish',
|
||||||
|
action_dt=NOW, finish_dt=NOW)
|
||||||
|
self.assertIn(self.customer.pk, followups_on_date(timezone.localdate()))
|
||||||
|
make_action(self.customer, 'nasb', 'waite', action_dt=at(7))
|
||||||
|
self.assertNotIn(self.customer.pk, followups_on_date(timezone.localdate()))
|
||||||
|
ids, _ = self.stale_ids('today')
|
||||||
|
self.assertNotIn(self.customer.pk, ids)
|
||||||
|
|
||||||
|
def test_old_pending_install_does_not_hide(self):
|
||||||
|
"""نصبِ «در انتظار» با تاریخ گذشته قرار فعال نیست ⇒ ردیف میماند."""
|
||||||
|
make_action(self.customer, 'hamahangi', 'finish',
|
||||||
|
action_dt=NOW, finish_dt=NOW)
|
||||||
|
make_action(self.customer, 'nasb', 'waite', action_dt=at(-30))
|
||||||
|
self.assertIn(self.customer.pk, followups_on_date(timezone.localdate()))
|
||||||
|
|
||||||
|
def test_install_yesterday_does_not_clear_today_row(self):
|
||||||
|
"""نصبِ دیروز، موعدِ امروز را نمیبندد (اگر موعد تازهای گذاشته شده)."""
|
||||||
|
make_action(self.customer, 'hamahangi', 'finish',
|
||||||
|
action_dt=NOW, finish_dt=NOW)
|
||||||
|
make_action(self.customer, 'nasb', 'finish',
|
||||||
|
action_dt=at(-1), finish_dt=at(-1))
|
||||||
|
self.assertIn(self.customer.pk, followups_on_date(timezone.localdate()))
|
||||||
|
|
||||||
|
|
||||||
class TaskListBoundaryTests(SimulationBase):
|
class TaskListBoundaryTests(SimulationBase):
|
||||||
"""«لیست وظایف امروز»: مرزهای روز به وقت محلی و aware.
|
"""«لیست وظایف امروز»: مرزهای روز به وقت محلی و aware.
|
||||||
@@ -744,8 +802,9 @@ class MissedViewSimulationTests(SimulationBase):
|
|||||||
"""رفتار فهرست ``?view=today&missed=1`` پس از رفع باگ.
|
"""رفتار فهرست ``?view=today&missed=1`` پس از رفع باگ.
|
||||||
|
|
||||||
قاعدهٔ کسبوکار: این فهرست فقط «پیگیریهای واقعاً باقیمانده» را نشان میدهد.
|
قاعدهٔ کسبوکار: این فهرست فقط «پیگیریهای واقعاً باقیمانده» را نشان میدهد.
|
||||||
هر مشتری که (الف) بازدیدش از موعد به بعد انجام شده، یا (ب) قرار دستیِ معتبر
|
هر مشتری که (الف) بازدید یا نصبش از موعد به بعد انجام شده، یا (ب) قرار
|
||||||
دارد، یا (ج) بازدیدِ در انتظار از آن روز دارد، از فهرست خارج میشود.
|
دستیِ معتبر دارد، یا (ج) بازدید/نصبِ در انتظار از آن روز دارد، از فهرست
|
||||||
|
خارج میشود.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
URL = '/panel/entezar/?view=today&missed=1'
|
URL = '/panel/entezar/?view=today&missed=1'
|
||||||
@@ -807,6 +866,25 @@ class MissedViewSimulationTests(SimulationBase):
|
|||||||
ids, _ = self.missed()
|
ids, _ = self.missed()
|
||||||
self.assertNotIn(self.customer.pk, ids)
|
self.assertNotIn(self.customer.pk, ids)
|
||||||
|
|
||||||
|
def test_pending_install_removes_from_missed(self):
|
||||||
|
"""«نصبِ» در انتظار با تاریخ آینده هم مشتری را از missed خارج میکند."""
|
||||||
|
make_action(self.customer, 'hamahangi', 'finish',
|
||||||
|
action_dt=at(-3), finish_dt=at(-3))
|
||||||
|
make_action(self.customer, 'nasb', 'waite', action_dt=at(5))
|
||||||
|
ids, _ = self.missed()
|
||||||
|
self.assertNotIn(self.customer.pk, ids)
|
||||||
|
|
||||||
|
def test_finished_install_after_coordination_is_not_listed(self):
|
||||||
|
"""هماهنگی دیروز + نصب انجامشدهٔ امروز ⇒ نباید در missed بیاید."""
|
||||||
|
make_action(self.customer, 'hamahangi', 'finish',
|
||||||
|
action_dt=at(-1, -2), finish_dt=at(-1))
|
||||||
|
make_action(self.customer, 'nasb', 'finish',
|
||||||
|
action_dt=NOW, finish_dt=NOW)
|
||||||
|
ids, response = self.missed()
|
||||||
|
self.assertNotIn(self.customer.pk, ids)
|
||||||
|
self.assertNotIn(self.customer.pk, overdue_followups())
|
||||||
|
self.assertEqual(response.context['counts']['followup_overdue'], 0)
|
||||||
|
|
||||||
def test_old_pending_visit_does_not_remove(self):
|
def test_old_pending_visit_does_not_remove(self):
|
||||||
"""بازدیدِ در انتظارِ قدیمیتر از موعد، قرار فعال نیست ⇒ در missed میماند."""
|
"""بازدیدِ در انتظارِ قدیمیتر از موعد، قرار فعال نیست ⇒ در missed میماند."""
|
||||||
make_action(self.customer, 'hamahangi', 'finish',
|
make_action(self.customer, 'hamahangi', 'finish',
|
||||||
|
|||||||
+416
-4
@@ -2177,14 +2177,14 @@ class EventTriggerTests(PanelTestCase):
|
|||||||
mark_primary(customer, '09120000000')
|
mark_primary(customer, '09120000000')
|
||||||
return customer
|
return customer
|
||||||
|
|
||||||
# ---------- قرارداد: دقیقاً ۷ نوع ----------
|
# ---------- قرارداد: دقیقاً ۸ نوع ----------
|
||||||
|
|
||||||
def test_exactly_seven_sms_types(self):
|
def test_exactly_eight_sms_types(self):
|
||||||
self.assertEqual(len(SMSType.choices), 7)
|
self.assertEqual(len(SMSType.choices), 8)
|
||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
{code for code, _label in SMSType.choices},
|
{code for code, _label in SMSType.choices},
|
||||||
{'welcome', 'survey', 'cheque_registration', 'visit',
|
{'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):
|
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))
|
seeded = set(SMSTemplate.objects.values_list('sms_type', flat=True))
|
||||||
self.assertEqual(seeded, {code for code, _label in SMSType.choices})
|
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):
|
def _post_factor(self, customer, payments):
|
||||||
@@ -2284,6 +2293,244 @@ class EventTriggerTests(PanelTestCase):
|
|||||||
SMSQueue.objects.filter(sms_type=SMSType.VISIT_CREATED).exists(),
|
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):
|
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):
|
class CustomerSmsHistoryTests(PanelTestCase):
|
||||||
"""تب «سوابق پیامک» در صفحهٔ مشتری: متن دقیق، تاریخ شمسی، وضعیت تحویل، صفحهبندی."""
|
"""تب «سوابق پیامک» در صفحهٔ مشتری: متن دقیق، تاریخ شمسی، وضعیت تحویل، صفحهبندی."""
|
||||||
|
|
||||||
@@ -2468,6 +2794,92 @@ class CustomerSmsHistoryTests(PanelTestCase):
|
|||||||
html = self.client.get(self._url()).content.decode('utf-8')
|
html = self.client.get(self._url()).content.decode('utf-8')
|
||||||
self.assertIn('پیامکی برای این مشتری ثبت نشده است', html)
|
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):
|
class SmsPartsTests(TestCase):
|
||||||
"""محاسبهٔ «تعداد پیامکِ محاسبهشده» (parts) بر اساس کدگذاری GSM-7 / UCS-2."""
|
"""محاسبهٔ «تعداد پیامکِ محاسبهشده» (parts) بر اساس کدگذاری GSM-7 / UCS-2."""
|
||||||
|
|||||||
Reference in new issue
Block a user