some improve

This commit is contained in:
javad committed 2026-10-27 11:49:09 +03:30
1 parent fa73a734a0
commit 2e5d0bcdd3
17 files changed
+1512 -226

No files matched your search

+45 -6
View File
@@ -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),
]
+2 -1
View File
@@ -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", "یادآوری سررسید نسیه"
+3 -2
View File
@@ -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),
+5
View File
@@ -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,
+18
View File
@@ -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 %}
+56 -5
View File
@@ -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'))
+2 -5
View File
@@ -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
View File
@@ -17,6 +17,7 @@ from ..sms.parts import ENCODING_LABELS, describe, parts_count
from ..sms.phones import (customer_phone_entries, save_primary_normalized, 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
View File
@@ -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
+34 -6
View File
@@ -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` و پیامک)
> عمداً تغییر نکرد؛ «نصب» فقط لنگر چرخه است، نه بازدید.
### ۹.۳ اثر روی دادهٔ واقعی (همان نسخهٔ پشتیبان) ### ۹.۳ اثر روی دادهٔ واقعی (همان نسخهٔ پشتیبان)
+8 -1
View File
@@ -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` است.
--- ---
+80 -2
View File
@@ -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
View File
@@ -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."""