some improve
This commit is contained in:
1 parent
fa73a734a0
commit
2e5d0bcdd3
17 files changed
+1464
-178
No files matched your search
+45
-6
@@ -1,11 +1,11 @@
|
||||
Project Root: khatibi
|
||||
Generated At: 2026-10-22T06:08:56+00:00
|
||||
Python Files: 189
|
||||
Generated At: 2026-10-27T07:48:20+00:00
|
||||
Python Files: 194
|
||||
Django Files: 12
|
||||
JavaScript/TypeScript Files: 14
|
||||
HTML Files: 58
|
||||
Total Files: 756
|
||||
Total Directories: 79
|
||||
HTML Files: 59
|
||||
Total Files: 794
|
||||
Total Directories: 81
|
||||
|
||||
khatibi
|
||||
├── .cursor
|
||||
@@ -131,6 +131,7 @@ khatibi
|
||||
│ │ ├── 0078_action_visit_covering_index.py
|
||||
│ │ ├── 0079_customer_visit_hold.py
|
||||
│ │ ├── 0080_action_soft_delete.py
|
||||
│ │ ├── 0081_sms_type_install_created.py
|
||||
│ │ └── __init__.py
|
||||
│ ├── models
|
||||
│ │ ├── __init__.py
|
||||
@@ -243,7 +244,6 @@ khatibi
|
||||
│ │ │ └── nazar.html
|
||||
│ │ ├── _customer_info.html
|
||||
│ │ ├── _customer_phones.html
|
||||
│ │ ├── _customer_sms.html
|
||||
│ │ ├── ActionList.html
|
||||
│ │ ├── base.html
|
||||
│ │ ├── baseHesabdari.html
|
||||
@@ -326,6 +326,8 @@ khatibi
|
||||
├── data9
|
||||
│ ├── backup-20261020-180934.sqlite3
|
||||
│ ├── backup-20261020-213406.sqlite3
|
||||
│ ├── backup-20261020-213406.sqlite3-shm
|
||||
│ ├── backup-20261020-213406.sqlite3-wal
|
||||
│ ├── db2 (2).sqlite32
|
||||
│ ├── db2.sqlite32
|
||||
│ ├── db222.sqlite3
|
||||
@@ -337,6 +339,8 @@ khatibi
|
||||
│ ├── khatibi92669266_backups_db_backup_20261021_223009.sqlite3-wal
|
||||
│ └── kt.sqlite3
|
||||
├── docs
|
||||
│ ├── entezar_action_fields_report.html
|
||||
│ ├── entezar_action_fields_report.md
|
||||
│ └── visit_hold_entezar.md
|
||||
├── graphify-out
|
||||
│ ├── 2026-10-09
|
||||
@@ -374,12 +378,21 @@ khatibi
|
||||
│ │ ├── graph.json
|
||||
│ │ ├── GRAPH_REPORT.md
|
||||
│ │ └── manifest.json
|
||||
│ ├── 2026-10-22
|
||||
│ │ ├── .graphify_analysis.json
|
||||
│ │ ├── .graphify_labels.json
|
||||
│ │ ├── .graphify_semantic_marker
|
||||
│ │ ├── graph.json
|
||||
│ │ ├── GRAPH_REPORT.md
|
||||
│ │ └── manifest.json
|
||||
│ ├── cache
|
||||
│ │ ├── ast
|
||||
│ │ │ └── v0.9.82-s7
|
||||
│ │ │ ├── 0075bdcd95747f7c750eba8757614aab71d4d8c77bd6764e4c5a98fe18dc8997.json
|
||||
│ │ │ ├── 0175e34128b5ac5119a5da4cc53d889dce334d8f20357631d54627c53cae9b8e.json
|
||||
│ │ │ ├── 01850b7fad89dcb9e521fceed2c41e64e9909a0f4c4a54d818d346ccf63bbc5d.json
|
||||
│ │ │ ├── 01b87542be8c5f2850a08a631287f94a89184e186d5cfca4d585800f41ee0f5d.json
|
||||
│ │ │ ├── 0317da4b3f4f0b0b5f6cf487567ec7e1b6f3bed2c6ad5cb19bd526a020521c10.json
|
||||
│ │ │ ├── 0348dd4662ebd96572421864ce77a266334a327f8bcbdc237d54c313d116bafb.json
|
||||
│ │ │ ├── 03c6fb41770a9293658a5db5af36f7e4afe64ae04b3ce8cc6c5874320706fd22.json
|
||||
│ │ │ ├── 0400cc0eadd706ff70ef627ffe39f401fc72a591dcbcd5463fca07fc271f080d.json
|
||||
@@ -403,8 +416,11 @@ khatibi
|
||||
│ │ │ ├── 14f64f9b156b6b520e7af189b303a2844728f3ab8bbe1e65fed49160b1431cb2.json
|
||||
│ │ │ ├── 15be5091a5bfbf8f48c3f3ae344a980be5452eab00f054c001958aae96557514.json
|
||||
│ │ │ ├── 174e406c2649f2384921581cbf912945a95d54aab73707b9aa45752aaa7c54d7.json
|
||||
│ │ │ ├── 17850e40dfa0d02ec3b0d4b2e1f9b931fe6a3a90c6cd874dabeb9466bed5f1d1.json
|
||||
│ │ │ ├── 183467d5c1ee7daac78dfb428bd335c17e17272bd4510358f38fc5924140c816.json
|
||||
│ │ │ ├── 1893253c5245ae073f9de9168d537384ab764b79d7b1631c785cd7335aab791b.json
|
||||
│ │ │ ├── 1b244c373e08c304261f7f760687c7ebe0ba9c79e7ab7ea16c3fa4768da959f5.json
|
||||
│ │ │ ├── 1ceebfc94adbc38aa951c83044b67943bc4032309ba3a61d9335dd833d49bbef.json
|
||||
│ │ │ ├── 1d5a29a9dabb365c079039c788988636b66c3a4bd2dbe66b249df001c2b5396f.json
|
||||
│ │ │ ├── 1d7a28f73bb52bbc018674a0c01405b7f395bcc5f340ecabe332e2fe440bcae8.json
|
||||
│ │ │ ├── 1d9f15f04104af07c60349f21bcd9f6f2c0e65c915e3e9c69040a10197b0fa6f.json
|
||||
@@ -414,11 +430,13 @@ khatibi
|
||||
│ │ │ ├── 21af53aa7322868a25b47458e59f339a8f6e58b7914fbe8e3389ab86e0b0b643.json
|
||||
│ │ │ ├── 22310d4d49f2beee611c83915933ea06e80ec562fd92c170345c556cd3772561.json
|
||||
│ │ │ ├── 2356cb9f067d43739312459bad2cb6d9ecbacac703e7e40a282d5b89d64332b3.json
|
||||
│ │ │ ├── 23c92d4714bc2e410ae9267e6ea7c139d5b16215da917f9a89c44a29724c69d7.json
|
||||
│ │ │ ├── 23da13b308596ede7678e92e57041bcecdd8e9baff04b759ddf9a69803dd790a.json
|
||||
│ │ │ ├── 23f4e3a62e03a38743659d94dd1350d17775da8be9175846e4ff3f38fa87c515.json
|
||||
│ │ │ ├── 24508e7a9b6fa93cdd28b9436bdb55fa24ad8f324baa95adb3afd37e301af9ac.json
|
||||
│ │ │ ├── 2501a9885d6fa5292276ee8cc7db63a9a3c08605f38d199d425d33f650dac35e.json
|
||||
│ │ │ ├── 25dbf169bbd8ae50f7cc568b4dbb7d259eb993ccddc9dbb20db96018ed093f5a.json
|
||||
│ │ │ ├── 28b192ecdcd9b33d8e4980d5043b6b2bc59095df392e22bf44d2166f7fa6d5ba.json
|
||||
│ │ │ ├── 28df8bbd275806fd77c086629211231bd1329fe8156f521b19b5c5979f0d3b51.json
|
||||
│ │ │ ├── 294e2d390f56acc0445564d428d90c641d76496cbc457bcfb09876382e9d87ab.json
|
||||
│ │ │ ├── 2976c530804df2075019fedaf2b908eeeff37b2703310c7e031b653d1fec71e5.json
|
||||
@@ -431,7 +449,9 @@ khatibi
|
||||
│ │ │ ├── 33b32e27be802f61375110f7f4ac9419fd4eb633523e01e31774b874448849c1.json
|
||||
│ │ │ ├── 3614ae46baf3e1b6efa17011c37407a5d2931aba1886d8f8bdf8e4e154ab6c3f.json
|
||||
│ │ │ ├── 370c10e9d6f01aa67799b1f2191d5749a69ce4541aa55cbf4a28d45732bd641b.json
|
||||
│ │ │ ├── 373e16b6c6996757d88a6ac751cd44028efa64701d746f8536819889417125ff.json
|
||||
│ │ │ ├── 38980449465279974120694e1c667f229049f75d656fe518196d9cadc2429155.json
|
||||
│ │ │ ├── 3a341a98aef632503bf2dc67ad87833e604c9aabe7ff69f5bcff7b54d6fe6407.json
|
||||
│ │ │ ├── 3ba0d4c75ab8bfa5bc2a60dccb74197cb94bd161e526e54770a9b0a2aa757806.json
|
||||
│ │ │ ├── 3c9f8987dc7426b442fe2a90c7de5d909fc85988e172e7722c11f24a2c331481.json
|
||||
│ │ │ ├── 3d94db4e528b0e06bb975a12c71733767fe57307bb6e3de7c6ce65721e097541.json
|
||||
@@ -446,6 +466,7 @@ khatibi
|
||||
│ │ │ ├── 45f7e59ef3f291171180b6763e8da408d8b7579bb7cb96dd4ca859e48aa5ce14.json
|
||||
│ │ │ ├── 46616763804b2b938d73c75f7651158e94803e0b86b3a759ecdd59375759eea3.json
|
||||
│ │ │ ├── 46bde576f6f1e69457e4ad33238fdbd602d2a7f027644a84cb81068215fbd5e1.json
|
||||
│ │ │ ├── 4723c76012231305253c473fb5f2b81fa35a49f9f4aa38f54e9af3285aa51a02.json
|
||||
│ │ │ ├── 477b0c06bcadb94314490ac76899f499d45a942aadef1e65623d303342157463.json
|
||||
│ │ │ ├── 492a9871203480b590199773e90727872f60f06013713a4a2936d87986cdbbe5.json
|
||||
│ │ │ ├── 4da7df08a0ef803551d404438fe24d6c8cc11d9a2c3cc8d85e37d5228ba3a166.json
|
||||
@@ -453,6 +474,7 @@ khatibi
|
||||
│ │ │ ├── 4e604dd03a70463ae25f6615ea3ce4fe8e98fab15d6b11ddafd51d601062e84d.json
|
||||
│ │ │ ├── 506e815cbeaaf3aa6e448dcbf70c10b4d118dff41e05a292fb8ec6d76dd391c4.json
|
||||
│ │ │ ├── 507e42a0bbe7a582ce1fcb77651e1cfde643907ca0429fedfe11afe7ae3b11c4.json
|
||||
│ │ │ ├── 50b38b9cbcb9d5aad3a94b28cb983b3694926bc20e0381419b82008efd042d65.json
|
||||
│ │ │ ├── 52608b276c6f0a2f190186e5bac578715ad888ff5ba3c10ff30879c3baf9d9ce.json
|
||||
│ │ │ ├── 527de91bd8bbc5a7ab79951457a9f192909faa9bbf02aaec72bb3d26e2298b57.json
|
||||
│ │ │ ├── 5316515ee7fc3e1038e676b361d9028edc735993c3d34791a77a14503deb064d.json
|
||||
@@ -476,8 +498,10 @@ khatibi
|
||||
│ │ │ ├── 5e19aa3aba5509cd48a9a93b39db071cfc2c2728ccae46b34caa0e214cc6be01.json
|
||||
│ │ │ ├── 5f050da3769e151e52138ee1ea5dcbcb6160cb57952c75665e607628d0e08d67.json
|
||||
│ │ │ ├── 5f858ab6d9f90e0f41617273b6f132af3f76f520deafb8d612adce373875e768.json
|
||||
│ │ │ ├── 5fe315e6417b8eb8b26079139d8bbe46ddaaf7cd67b078d927ea79d3815be453.json
|
||||
│ │ │ ├── 60002a01d6fc22147e2994d1a1a447d5e577c02aed2443789af20571ec2d2a19.json
|
||||
│ │ │ ├── 60e00560038df059b1696e54dcf055cd658677d6e1201570a8500531addc0ac6.json
|
||||
│ │ │ ├── 611c158815bdf3dbec9f5a5c01bce751fcd07db536e9bbaba2b2d232f82f5ae6.json
|
||||
│ │ │ ├── 62d88411a61ee9d3b60dda6c4febf33539b8b9e8226b5d2b9205f8e2b0c4478a.json
|
||||
│ │ │ ├── 63c38f2a8abe98c0d1e9cb5b7d9fd0451e1e46c2bcbf1d2c3b241b263ac0a9cd.json
|
||||
│ │ │ ├── 6448d65fdcfe330b5d4278c8d1540e7b075238672b8101f9d2e1c4052f7c9c49.json
|
||||
@@ -493,7 +517,10 @@ khatibi
|
||||
│ │ │ ├── 6aca679f90689aa4c275389c8b4e2462a9456775e8b124071070af2c988ca0fc.json
|
||||
│ │ │ ├── 6afa6136d9df8317bdd8d49f4109412776deb21ba123fdd2a97331b400a0230d.json
|
||||
│ │ │ ├── 6bfbb9eed6842bf331dec78724d7e740919c76345b54be844964f44640482a87.json
|
||||
│ │ │ ├── 6d026e901d3b2ff0cb9550c750966905afbbc681ba68db88e1de608fb2964495.json
|
||||
│ │ │ ├── 6d4d7d7f829f13e46e4898aceb760d66d2255dc838eab0933f9a92d720f420a7.json
|
||||
│ │ │ ├── 6d556671b5632e29972dc09e345d68d522a5b3bcd2852b803d56668890fa46d7.json
|
||||
│ │ │ ├── 6dcafcac3a0b169a08580cc4cb9196423dacfa51329841311665c363a97bc29e.json
|
||||
│ │ │ ├── 6df9c1e88224e95c55dee4238fa91358360231ee47362294e884bc6e24d73612.json
|
||||
│ │ │ ├── 6e262456570c174e207055c7c964fe85431addb0f59298b1e4d912500dfb23a3.json
|
||||
│ │ │ ├── 6e2ed39911a83c75e432ffd0dab22fd095ce8c5bb186d6ab22d594375f3b32bc.json
|
||||
@@ -511,6 +538,7 @@ khatibi
|
||||
│ │ │ ├── 77a1520156879de714ee872c29b1387967bcc5ce470b47d37087ea2a110f3875.json
|
||||
│ │ │ ├── 780977bca042ffdde1cca2811a3a46c36bd3fcff2723c94ac527428113131c64.json
|
||||
│ │ │ ├── 784dd791c21e8837a3e925331cf234b2138ded3d2012968739604d5535a18e09.json
|
||||
│ │ │ ├── 78d30d0a40b28969d64653d2508fd53e53709b95a45f3e380ebd6c7aff04ace9.json
|
||||
│ │ │ ├── 79ffc45a7139464dd6c7b1fc6f1f0f9c44347fbd7dcee92c6d4b63525424c442.json
|
||||
│ │ │ ├── 7be7f428e6acfe4c99370707cc7de9253f801feff1564088f2584d5dc8c42a89.json
|
||||
│ │ │ ├── 7bf404104bb32fc04568c2d287891fa69955ac3f46718c99dcca64a4bfff6fe3.json
|
||||
@@ -544,6 +572,7 @@ khatibi
|
||||
│ │ │ ├── 9766508e5a013c3d3cbb2602719391199e90af5720c9782a77ca71565aa346b1.json
|
||||
│ │ │ ├── 9880af8b991c19abe5ed019ad7b7291b8b281ed9dcb894a00c3fd8259a59b4a4.json
|
||||
│ │ │ ├── 996411678d23c3cf8a0e6c1c5076701e8bf5f3dad21ca907ec4116d15366a668.json
|
||||
│ │ │ ├── 9b2fd03f2769231e4c207b2f0ee915e3b5eb050232f2fe6c59ddb01904976650.json
|
||||
│ │ │ ├── 9c2c2f6f8d4d4c3a9a60da3ed38a26dc789e8cfaff5d46d140d2f208e5fef3c3.json
|
||||
│ │ │ ├── 9cfb6e4f7b08a863675af032e7823c0523170f5201e686c5ef72b39c04b07a12.json
|
||||
│ │ │ ├── 9eabdb0cacba38c8fb6b3e777932e06bd82172cdc0bcccf2fc0bfc23b5bd4974.json
|
||||
@@ -568,6 +597,7 @@ khatibi
|
||||
│ │ │ ├── b59999c2fd322c241c7bd37e6ed0a95c9124cbb79f8424f1fa7cb2e92fe63817.json
|
||||
│ │ │ ├── b82d5f6840b4930eb62fdb1c7b3a58124fff805e529ede7684516a65322fcae0.json
|
||||
│ │ │ ├── b87a932e418a18531d61dc15776db652401e7cf1682dead02abdf50e2cd00530.json
|
||||
│ │ │ ├── b9b878bef7c0ae7d407a42c797a4697990237971be65a5d01a6c7b9b444a2a9a.json
|
||||
│ │ │ ├── b9f8ea31056fe0c0fc40a84640c58f29895312c65bf694567487204d8f1170e9.json
|
||||
│ │ │ ├── bb140cfe12cfb3e32dc9815393035fc33c7649c2faeeccb82cdee56a930ea635.json
|
||||
│ │ │ ├── bb74cda4073a6da7a4b60fecfb3bb40796a51ceafcbdd128d60e5ce23fa4f319.json
|
||||
@@ -613,9 +643,11 @@ khatibi
|
||||
│ │ │ ├── d9590fb13a20db20da78d33674f56397a18e3356d08dab6f7c0739130caee096.json
|
||||
│ │ │ ├── db68b8db5217e2295eb1195760d7352960627da044a4c4f8f6b89c67ad2f8f57.json
|
||||
│ │ │ ├── db794bbcafe219af962673ef26efb1ee1eacf3923e13113352f3eedfd3192d23.json
|
||||
│ │ │ ├── db81ec42bfd68175829c568dac66c2844afba128b94afa2accdf24ed67aeccaf.json
|
||||
│ │ │ ├── dbbdad9d992922aff62ad3262a531716cfc32e2f15f600773b9a0cf10545058d.json
|
||||
│ │ │ ├── dc0344515d1b3ffe8643560516b88418be0b306fba56101b8a630ed73d48db46.json
|
||||
│ │ │ ├── dcad55d51bb4494a642af555d5bfe78c5b8ee3d11de1b3d8f4286d608980854b.json
|
||||
│ │ │ ├── ddd79719e349936349e9ec7684d3eec146d3b73f95a419ad142ae603ab199acd.json
|
||||
│ │ │ ├── e08c4784f0f82403ff24a1676e3d75e11a208aadf8fe94a723461c9658afd1e0.json
|
||||
│ │ │ ├── e0eb3c543474df255f97ae0679df48999b86b75dc42943b9d67f4cfcf1acae34.json
|
||||
│ │ │ ├── e16f78807ff202fa2c81867b321acfacede2d24cc785856eef0633d8d352796b.json
|
||||
@@ -630,6 +662,7 @@ khatibi
|
||||
│ │ │ ├── e9e14d581ed59799b2d83c67402e8555b5b59e7d49425950ec490422bd0307d9.json
|
||||
│ │ │ ├── eaceb29a907e3e67bed5255cc1f8b8b3eab9d9946f4d4df2cc99070151285029.json
|
||||
│ │ │ ├── ec07e022005d3513838eee7e6618ff060e25d914cab7745aa6172f22d4089497.json
|
||||
│ │ │ ├── ec3570ad9673cc5dfc9a0d12f208b7f4d897d28d1ae2974d6a3fa63ad27598d3.json
|
||||
│ │ │ ├── eccad1f007b52643ce8d81576efb7d1969ecbff2e9db288989fd9a9c21fd7288.json
|
||||
│ │ │ ├── ecd9c1dbfa0a72e253adb680ae7088eff24c26360e53a65862b274ea1281ceae.json
|
||||
│ │ │ ├── ee450e6251e62d8972efb53bc35145c4a65050a7b6db2954b84bda42fca7b704.json
|
||||
@@ -645,6 +678,7 @@ khatibi
|
||||
│ │ │ ├── f72f6f3fe9d226e4717e158dedd318fff0a1d52ece54f965311c2b4f862dcfee.json
|
||||
│ │ │ ├── f76726d2898f2a3ba7168d3fb77c21087432eb9c200631b664ecb3b1837aaf17.json
|
||||
│ │ │ ├── f92b94479afedf47039d8821a77facb55d9a862338e85876b80cac3b41c844f6.json
|
||||
│ │ │ ├── fa4a8a35e96c919372da27c7af11abea0c89fff941c0b20dbc4e48c15f32597c.json
|
||||
│ │ │ ├── fb6825c4e9ffba13d17a2da18a1e07aef792089a7cd24b31e7f4f32ee39287e8.json
|
||||
│ │ │ ├── fbfc351c35c5272de5612b4740e380b06a24c37f8074b977e35f62ad85a6dcc0.json
|
||||
│ │ │ ├── fc6ae63ae2639372c4ae4fbe9718feca814261dda099ba58bb780d14c69086cd.json
|
||||
@@ -790,6 +824,10 @@ khatibi
|
||||
│ ├── settings.py
|
||||
│ ├── urls.py
|
||||
│ └── wsgi.py
|
||||
├── scripts
|
||||
│ ├── coordination_hold_order_probe.py
|
||||
│ ├── entezar_action_audit.py
|
||||
│ └── hold_write_verification.py
|
||||
├── static
|
||||
│ └── .gitkeep
|
||||
├── tests
|
||||
@@ -797,6 +835,7 @@ khatibi
|
||||
│ ├── factories.py
|
||||
│ ├── test_customer_name_fix.py
|
||||
│ ├── test_customers.py
|
||||
│ ├── test_entezar_action_simulation.py
|
||||
│ ├── test_factors.py
|
||||
│ ├── test_inventory.py
|
||||
│ ├── test_models_contract.py
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
"""افزودن نوع پیامک ``install_created`` («ثبت نصب — اقدام از نوع نصب»).
|
||||
|
||||
**این مهاجرت هیچ دادهٔ موجودی را تغییر نمیدهد.** سه کار انجام میشود:
|
||||
|
||||
۱. ``choices`` سه فیلد ``sms_type`` (قالب، تنظیم مشتری، صف) به ۸ نوع گسترش
|
||||
مییابد تا مقدار جدید در پنل/اعتبارسنجی معتبر باشد. ``AlterField`` فقط
|
||||
متادیتای ستون را عوض میکند و به ارزش ردیفها دست نمیزند.
|
||||
۲. **فقط** یک قالب تازه برای نوع جدید ساخته میشود (``get_or_create``): اگر از
|
||||
قبل وجود داشته باشد، هیچ فیلدی از آن بازنویسی نمیشود.
|
||||
* ``is_enabled=True`` ⇒ از پنل قالبها دیده/ویرایش شود.
|
||||
* ``auto_send=False`` (صریح) ⇒ در صف «در انتظار تأیید» میماند تا مدیر خودش
|
||||
تأیید/ارسال کند؛ هیچ پیامکی ناخواسته ارسال نمیشود.
|
||||
۳. قالبهای هفت نوع قبلی حتی لمس هم نمیشوند: در این فایل هیچ ``update``/``save``
|
||||
روی آنها وجود ندارد.
|
||||
|
||||
متغیرهای متنی همان ``{name}`` و ``{date}`` هستند؛ نیازی به تغییر ساختار نیست.
|
||||
"""
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
SMS_TYPE_CHOICES = [
|
||||
("welcome", "خوشآمدگویی (ایجاد مشتری)"),
|
||||
("survey", "نظرسنجی پس از ثبت فاکتور"),
|
||||
("cheque_registration", "یادآوری تأمین موجودی چک"),
|
||||
("visit", "یادآوری بازدید خدمات دستگاه"),
|
||||
("visit_created", "ثبت بازدید (اقدام از نوع بازدید)"),
|
||||
("install_created", "ثبت نصب (اقدام از نوع نصب)"),
|
||||
("payment_thanks", "تشکر از پرداخت"),
|
||||
("nesiye_due", "یادآوری سررسید نسیه"),
|
||||
]
|
||||
|
||||
NEW_SMS_TYPE = "install_created"
|
||||
|
||||
# قالب پیشفرض نوع جدید. متن مشابه «ثبت بازدید» است تا لحن پیامها یکدست بماند.
|
||||
INSTALL_TEMPLATE = {
|
||||
"title": "ثبت نصب",
|
||||
"text": "{name} عزیز، نصب شما برای تاریخ {date} ثبت شد. "
|
||||
"از همکاری شما سپاسگزاریم.",
|
||||
"is_enabled": True,
|
||||
# خاموش: پیامک در «در انتظار تأیید» میماند (رفتار پیشفرض امن پروژه).
|
||||
"auto_send": False,
|
||||
"offset_days": 0,
|
||||
"repeat_interval_days": None,
|
||||
"repeat_interval_months": None,
|
||||
}
|
||||
|
||||
|
||||
def seed_install_template(apps, schema_editor):
|
||||
"""ایجاد قالب «ثبت نصب» اگر وجود ندارد (اجرای دوباره بیخطر است).
|
||||
|
||||
``get_or_create`` عمداً بدون هیچ ``update`` است: قالبهای موجود (هر هفت نوع
|
||||
قبلی) و حتی خودِ ``install_created`` اگر از قبل باشد، دستنخورده میمانند.
|
||||
"""
|
||||
SMSTemplate = apps.get_model("admin_panel", "SMSTemplate")
|
||||
SMSTemplate.objects.get_or_create(
|
||||
sms_type=NEW_SMS_TYPE,
|
||||
defaults={
|
||||
"title": INSTALL_TEMPLATE["title"],
|
||||
"text": INSTALL_TEMPLATE["text"],
|
||||
"is_enabled": INSTALL_TEMPLATE["is_enabled"],
|
||||
"auto_send": INSTALL_TEMPLATE["auto_send"],
|
||||
"offset_days": INSTALL_TEMPLATE["offset_days"],
|
||||
"repeat_interval_days": INSTALL_TEMPLATE["repeat_interval_days"],
|
||||
"repeat_interval_months": INSTALL_TEMPLATE["repeat_interval_months"],
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def drop_install_template(apps, schema_editor):
|
||||
"""بازگشت: فقط ردیفهای همان نوع جدید حذف میشوند.
|
||||
|
||||
سایر قالبها و تنظیمهای مشتری دست نمیخورند.
|
||||
"""
|
||||
apps.get_model("admin_panel", "SMSTemplate").objects.filter(
|
||||
sms_type=NEW_SMS_TYPE,
|
||||
).delete()
|
||||
apps.get_model("admin_panel", "CustomerSMSSetting").objects.filter(
|
||||
sms_type=NEW_SMS_TYPE,
|
||||
).delete()
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("admin_panel", "0080_action_soft_delete"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name="smstemplate",
|
||||
name="sms_type",
|
||||
field=models.CharField(
|
||||
choices=SMS_TYPE_CHOICES, max_length=40, unique=True,
|
||||
verbose_name="نوع پیامک",
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="customersmssetting",
|
||||
name="sms_type",
|
||||
field=models.CharField(
|
||||
choices=SMS_TYPE_CHOICES, max_length=40, verbose_name="نوع پیامک",
|
||||
),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name="smsqueue",
|
||||
name="sms_type",
|
||||
field=models.CharField(
|
||||
blank=True, choices=SMS_TYPE_CHOICES, default="", max_length=40,
|
||||
verbose_name="نوع",
|
||||
),
|
||||
),
|
||||
migrations.RunPython(seed_install_template, drop_install_template),
|
||||
]
|
||||
@@ -11,7 +11,7 @@ from django.db import models
|
||||
|
||||
|
||||
class SMSType(models.TextChoices):
|
||||
"""انواع پیامکهای سیستم (دقیقاً ۷ نوع، به ترتیب چرخهٔ کاری).
|
||||
"""انواع پیامکهای سیستم (دقیقاً ۸ نوع، به ترتیب چرخهٔ کاری).
|
||||
|
||||
ترتیب تعریف = ترتیب نمایش در پنل. هر نوع یا از یک رویداد (view) یا از یک
|
||||
اسکنر دورهای در صف قرار میگیرد؛ نگاشت آن در ``admin_panel/sms/triggers.py``
|
||||
@@ -23,6 +23,7 @@ class SMSType(models.TextChoices):
|
||||
CHEQUE_REGISTRATION = "cheque_registration", "یادآوری تأمین موجودی چک"
|
||||
VISIT = "visit", "یادآوری بازدید خدمات دستگاه"
|
||||
VISIT_CREATED = "visit_created", "ثبت بازدید (اقدام از نوع بازدید)"
|
||||
INSTALL_CREATED = "install_created", "ثبت نصب (اقدام از نوع نصب)"
|
||||
PAYMENT_THANKS = "payment_thanks", "تشکر از پرداخت"
|
||||
NESIYE_DUE = "nesiye_due", "یادآوری سررسید نسیه"
|
||||
|
||||
|
||||
@@ -261,8 +261,9 @@ def scan_visit(today, *, now=None):
|
||||
return count
|
||||
|
||||
|
||||
# فقط همان ۷ نوع مجاز؛ انواع رویدادی (خوشآمدگویی، نظرسنجی، ثبت بازدید، تشکر از
|
||||
# پرداخت) در ``admin_panel/sms/triggers.py`` توسط ویوها صف میشوند، نه اینجا.
|
||||
# فقط انواعی که «موعد» دارند و دورهای اسکن میشوند؛ انواع رویدادی (خوشآمدگویی،
|
||||
# نظرسنجی، ثبت بازدید، ثبت نصب، تشکر از پرداخت) در ``admin_panel/sms/triggers.py``
|
||||
# توسط ویوها صف میشوند، نه اینجا.
|
||||
SCANNERS = [
|
||||
("cheque_registration", scan_cheque_funding),
|
||||
("nesiye_due", scan_nesiye_due),
|
||||
|
||||
@@ -46,6 +46,11 @@ TIMING = {
|
||||
"field": None,
|
||||
"note": "بهمحض ثبت «اقدام از نوع بازدید» ارسال میشود؛ تنظیم زمانی ندارد.",
|
||||
},
|
||||
SMSType.INSTALL_CREATED: {
|
||||
"kind": NONE,
|
||||
"field": None,
|
||||
"note": "بهمحض ثبت «اقدام از نوع نصب» ارسال میشود؛ تنظیم زمانی ندارد.",
|
||||
},
|
||||
SMSType.PAYMENT_THANKS: {
|
||||
"kind": NONE,
|
||||
"field": None,
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
* :func:`notify_welcome` — ایجاد مشتری.
|
||||
* :func:`notify_factor_survey` — ثبت فاکتور (نظرسنجی).
|
||||
* :func:`notify_visit_scheduled` — ثبت «اقدام از نوع بازدید».
|
||||
* :func:`notify_install_scheduled` — ثبت «اقدام از نوع نصب».
|
||||
* :func:`notify_payment_thanks` — ثبت پرداخت (سند بستانکار / وصول).
|
||||
|
||||
قرارداد پروژه: استفاده از signal ممنوع است؛ همهچیز صریح از دلِ ویو صدا زده میشود.
|
||||
@@ -61,6 +62,23 @@ def notify_visit_scheduled(customer, action, *, created_by=None):
|
||||
)
|
||||
|
||||
|
||||
def notify_install_scheduled(customer, action, *, created_by=None):
|
||||
"""پیامک «نصب شما برای تاریخ … ثبت شد» پس از ایجاد اقدام نصب.
|
||||
|
||||
قرینهٔ :func:`notify_visit_scheduled` است و همان قرارداد را دارد: فقط برای
|
||||
اقدام ``nasb`` صدا زده میشود، متن از قالب ``install_created`` میآید و
|
||||
یکتاسازی با شناسهٔ همان رکورد اقدام انجام میشود (ویرایش دوباره = بدون تکرار).
|
||||
"""
|
||||
if customer is None or action is None or action.pk is None:
|
||||
return 0
|
||||
return enqueue_sms(
|
||||
customer=customer, sms_type=SMSType.INSTALL_CREATED,
|
||||
context={"name": customer.name_family, "date": action.actionDateTime},
|
||||
dedup_key=f"install_created:{action.pk}",
|
||||
created_by=created_by,
|
||||
)
|
||||
|
||||
|
||||
def notify_payment_thanks(customer, amount, *, article_id=None, method=None,
|
||||
created_by=None):
|
||||
"""پیامک تشکر از پرداخت؛ پس از ثبت سند پرداخت (بستانکار/وصول).
|
||||
|
||||
@@ -1,40 +0,0 @@
|
||||
{% if perms.admin_panel.ManageSMS %}
|
||||
<div class="box" dir="rtl">
|
||||
<h2 class="title is-5">پیامک</h2>
|
||||
|
||||
<div class="columns is-multiline">
|
||||
<div class="column is-12">
|
||||
<h3 class="subtitle is-6">فعال/غیرفعال پیامکها برای این مشتری</h3>
|
||||
{% for item in sms_toggles %}
|
||||
<form method="post" action="{% url 'admin_panel:sms_customer_toggle' customer.id %}" class="field has-addons">
|
||||
{% csrf_token %}
|
||||
<input type="hidden" name="sms_type" value="{{ item.code }}">
|
||||
<input type="hidden" name="is_enabled" value="{% if item.enabled %}false{% else %}true{% endif %}">
|
||||
<div class="control is-expanded">
|
||||
<input class="input" type="text" value="{{ item.label }}" disabled>
|
||||
</div>
|
||||
<div class="control">
|
||||
<button class="button {% if item.enabled %}is-success{% else %}is-danger{% endif %}" type="submit">
|
||||
{% if item.enabled %}فعال{% else %}غیرفعال{% endif %}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
<div class="column is-12">
|
||||
<h3 class="subtitle is-6">ارسال پیامک سفارشی</h3>
|
||||
<form method="post" action="{% url 'admin_panel:sms_send_custom' customer.id %}">
|
||||
{% csrf_token %}
|
||||
<div class="field">
|
||||
<div class="control">
|
||||
<textarea class="textarea" name="text" rows="2" placeholder="متن پیامک..."></textarea>
|
||||
</div>
|
||||
</div>
|
||||
<p class="help">میتوانید از متغیرهایی مثل {name} (نام مشتری) استفاده کنید.</p>
|
||||
<button class="button is-info" type="submit">ارسال به شمارههای اصلی</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
@@ -180,9 +180,12 @@
|
||||
</form>
|
||||
|
||||
</div>
|
||||
|
||||
{% include "./_customer_sms.html" %}
|
||||
</div>
|
||||
{% comment %}
|
||||
باکس «پیامک» از این صفحه حذف شد: تنظیمات پیامک به تب «سوابق پیامک» مشتری
|
||||
(/panel/customer/<id>/sms/) منتقل شده تا فرم فاکتور تمامعرض بماند و سوابق و
|
||||
تنظیمات هر دو در یک صفحهٔ اختصاصی دیده شوند.
|
||||
{% endcomment %}
|
||||
{% endblock %}
|
||||
|
||||
|
||||
|
||||
@@ -4,30 +4,223 @@
|
||||
|
||||
{% block BodyCustomer1 %}
|
||||
|
||||
{% comment %}
|
||||
صفحهٔ «پیامک مشتری» = سوابق + تنظیمات در یک صفحه.
|
||||
قاعدهٔ طراحی: تنظیمات هرگز روی سوابق نمینشیند؛ فقط آمار + دکمهها بالاست و
|
||||
تنظیمات/ارسال سفارشی/شمارههای اصلی داخل مودال باز میشوند.
|
||||
همهٔ کلاسها با پیشوند csmsx و محدود به #customerSmsHistory هستند تا به
|
||||
صفحات دیگر نشت نکنند.
|
||||
{% endcomment %}
|
||||
<style>
|
||||
/* جدول سوابق پیامک مشتری */
|
||||
.smshist-msg {
|
||||
white-space: pre-wrap;
|
||||
overflow-wrap: anywhere;
|
||||
max-width: 34rem;
|
||||
line-height: 1.9;
|
||||
{% verbatim %}
|
||||
#customerSmsHistory, #customerSmsHistory *, #customerSmsHistory *::before,
|
||||
#customerSmsHistory *::after { box-sizing: border-box; }
|
||||
/* متغیرها روی خودِ صفحه و روی ریشهٔ مودالها تعریف میشوند؛ مودالها در بلوک
|
||||
جدا (BodyCustomer2) رندر میشوند و خارج از #customerSmsHistory هستند. */
|
||||
#customerSmsHistory, .csmsx-modal { --cs-line: #e8ecf3; --cs-ink: #363636;
|
||||
--cs-muted: #7a7a7a; --cs-brand: #3273dc; --cs-ok: #48c774; }
|
||||
|
||||
/* ---------- نوار بالای صفحه ---------- */
|
||||
.csmsx-head { display: flex; flex-wrap: wrap; gap: 1rem; align-items: flex-start;
|
||||
justify-content: space-between; padding: 1.1rem 1.2rem; margin-bottom: 1rem;
|
||||
border: 1px solid var(--cs-line); border-radius: 14px;
|
||||
background: linear-gradient(180deg, #fbfdff 0%, #f5f8fd 100%); }
|
||||
.csmsx-head__main { min-width: 0; }
|
||||
.csmsx-title { font-size: 1.15rem; font-weight: 700; color: var(--cs-ink);
|
||||
margin: 0 0 .25rem; overflow-wrap: anywhere; }
|
||||
.csmsx-sub { margin: 0; color: var(--cs-muted); font-size: .82rem; line-height: 1.8; }
|
||||
.csmsx-sub b { color: var(--cs-ink); }
|
||||
.csmsx-stats { display: flex; flex-wrap: wrap; gap: .45rem; margin-top: .7rem; }
|
||||
.csmsx-stat { display: inline-flex; align-items: center; gap: .35rem;
|
||||
font-size: .78rem; padding: .25rem .65rem; border-radius: 999px;
|
||||
background: #fff; border: 1px solid var(--cs-line); color: #4a4a4a; white-space: nowrap; }
|
||||
.csmsx-stat b { font-variant-numeric: tabular-nums; }
|
||||
.csmsx-stat.is-sent { border-color: #c8ebd5; background: #f2fbf6; }
|
||||
.csmsx-stat.is-pending { border-color: #ffeaa7; background: #fffdf3; }
|
||||
.csmsx-stat.is-failed { border-color: #ffd0d9; background: #fff5f7; }
|
||||
.csmsx-head__actions { display: flex; flex-wrap: wrap; gap: .5rem; }
|
||||
{% endverbatim %}
|
||||
</style>
|
||||
<style>
|
||||
{% verbatim %}
|
||||
/* ---------- نوار فیلترها ---------- */
|
||||
.csmsx-filters { display: flex; flex-wrap: wrap; gap: .6rem; align-items: flex-end;
|
||||
padding: .85rem 1rem; margin-bottom: 1rem; background: #fff;
|
||||
border: 1px solid var(--cs-line); border-radius: 12px; }
|
||||
.csmsx-filters .control { margin: 0; }
|
||||
.csmsx-filters label { display: block; font-size: .75rem; color: var(--cs-muted);
|
||||
margin-bottom: .25rem; }
|
||||
.csmsx-filters select { min-width: 10.5rem; }
|
||||
.csmsx-filters__note { margin-inline-start: auto; font-size: .78rem;
|
||||
color: var(--cs-muted); }
|
||||
|
||||
/* ---------- فهرست سوابق ---------- */
|
||||
.csmsx-history { background: #fff; border: 1px solid var(--cs-line);
|
||||
border-radius: 14px; overflow: hidden; }
|
||||
.csmsx-history__scroll { overflow-x: auto; }
|
||||
.csmsx-history table { margin: 0 !important; background: transparent; }
|
||||
.csmsx-history thead th { position: sticky; top: 0; z-index: 1; background: #f7f9fc;
|
||||
color: #4a4a4a; font-size: .78rem; font-weight: 700;
|
||||
border-bottom: 1px solid var(--cs-line) !important; }
|
||||
.csmsx-history tbody tr:hover { background: #fafcff; }
|
||||
.csmsx-msg { white-space: pre-wrap; overflow-wrap: anywhere; max-width: 34rem;
|
||||
line-height: 1.95; font-size: .84rem; }
|
||||
/* پیامکهای بلند (متن قالبها چندخطی است) کل جدول را بیاندازه بلند میکردند؛
|
||||
متن در ۴ خط خلاصه میشود و با «نمایش کامل» باز میشود. */
|
||||
.csmsx-msg.is-clamped { display: -webkit-box; -webkit-line-clamp: 4;
|
||||
-webkit-box-orient: vertical; overflow: hidden; }
|
||||
.csmsx-expand { margin-top: .25rem; font-size: .72rem; padding: .1rem .45rem;
|
||||
border-radius: 6px; border: 1px solid var(--cs-line); background: #fff;
|
||||
color: var(--cs-brand); cursor: pointer; }
|
||||
.csmsx-expand:hover { background: #f4f8ff; }
|
||||
.csmsx-meta { font-size: .76rem; line-height: 1.7; color: var(--cs-muted); }
|
||||
.csmsx-badge { white-space: nowrap; }
|
||||
.csmsx-type-chip { display: inline-block; font-size: .75rem; padding: .1rem .5rem;
|
||||
border-radius: 999px; background: #f1f5fb; color: #33507d; white-space: nowrap; }
|
||||
{% endverbatim %}
|
||||
</style>
|
||||
<style>
|
||||
{% verbatim %}
|
||||
/* ---------- مودالها ---------- */
|
||||
.csmsx-modal .modal-content { max-width: 46rem; width: calc(100% - 1.5rem); }
|
||||
.csmsx-modal .box { padding: 0; overflow: hidden; border-radius: 14px; }
|
||||
.csmsx-modal__head { display: flex; align-items: flex-start; justify-content: space-between;
|
||||
gap: .75rem; padding: 1rem 1.15rem; border-bottom: 1px solid var(--cs-line);
|
||||
background: linear-gradient(180deg, #fbfdff 0%, #f4f8fd 100%); }
|
||||
.csmsx-modal__title { margin: 0; font-size: 1.02rem; font-weight: 700; color: var(--cs-ink); }
|
||||
.csmsx-modal__sub { margin: .2rem 0 0; font-size: .78rem; color: var(--cs-muted); line-height: 1.8; }
|
||||
.csmsx-modal__body { padding: 1rem 1.15rem; max-height: min(68vh, 34rem); overflow-y: auto; }
|
||||
.csmsx-modal__foot { display: flex; flex-wrap: wrap; gap: .5rem; justify-content: flex-end;
|
||||
padding: .85rem 1.15rem; border-top: 1px solid var(--cs-line); background: #fcfcfd; }
|
||||
.csmsx-section-title { display: flex; align-items: center; gap: .45rem; margin: 0 0 .6rem;
|
||||
font-size: .82rem; font-weight: 700; color: #4a4a4a; }
|
||||
.csmsx-section + .csmsx-section { margin-top: 1.15rem; padding-top: 1.15rem;
|
||||
border-top: 1px dashed var(--cs-line); }
|
||||
|
||||
/* ردیف هر نوع پیامک داخل مودال تنظیمات */
|
||||
.csmsx-type { display: grid; grid-template-columns: auto minmax(0, 1fr) auto;
|
||||
gap: .6rem .75rem; align-items: center; padding: .6rem .7rem; border-radius: 10px; }
|
||||
.csmsx-type + .csmsx-type { margin-top: .3rem; }
|
||||
.csmsx-type:hover { background: #fafcff; }
|
||||
.csmsx-type.is-off { background: #fcfcfc; }
|
||||
.csmsx-type.is-off .csmsx-type__label { color: #9a9a9a; }
|
||||
.csmsx-type__icon { font-size: 1.15rem; line-height: 1; }
|
||||
.csmsx-type__main { min-width: 0; }
|
||||
.csmsx-type__label { display: block; font-size: .85rem; font-weight: 600;
|
||||
color: var(--cs-ink); overflow-wrap: anywhere; }
|
||||
.csmsx-type__meta { display: flex; flex-wrap: wrap; gap: .3rem; margin-top: .2rem; }
|
||||
.csmsx-tag { font-size: .7rem; padding: .1rem .45rem; border-radius: 999px;
|
||||
border: 1px solid var(--cs-line); background: #fff; color: var(--cs-muted); white-space: nowrap; }
|
||||
.csmsx-tag.is-auto { border-color: #cfe0ff; background: #f4f8ff; color: #2f5fb3; }
|
||||
|
||||
/* کلید فعال/غیرفعال (دکمهٔ submit داخل فرم) */
|
||||
.csmsx-switch { display: inline-flex; align-items: center; gap: .45rem;
|
||||
background: none; border: 0; padding: .25rem; cursor: pointer; min-height: 2.4rem; }
|
||||
.csmsx-switch__track { position: relative; flex: 0 0 auto; width: 44px; height: 25px;
|
||||
border-radius: 13px; background: #dbdbdb; transition: background .18s; }
|
||||
.csmsx-switch__track::after { content: ""; position: absolute; top: 3px;
|
||||
inset-inline-start: 3px; width: 19px; height: 19px; border-radius: 50%;
|
||||
background: #fff; box-shadow: 0 1px 3px rgba(0,0,0,.3); transition: transform .18s; }
|
||||
.csmsx-switch.is-on .csmsx-switch__track { background: var(--cs-ok); }
|
||||
.csmsx-switch.is-on .csmsx-switch__track::after { transform: translateX(-19px); }
|
||||
.csmsx-switch:focus-visible .csmsx-switch__track { outline: 2px solid var(--cs-brand); outline-offset: 2px; }
|
||||
.csmsx-switch__text { font-size: .76rem; color: #4a4a4a; white-space: nowrap; min-width: 3.2rem; text-align: start; }
|
||||
|
||||
/* شمارههای اصلی داخل مودال */
|
||||
.csmsx-phones { display: flex; flex-wrap: wrap; gap: .5rem; }
|
||||
.csmsx-phone { display: inline-flex; align-items: center; gap: .4rem; padding: .3rem .4rem .3rem .65rem;
|
||||
border: 1px solid var(--cs-line); border-radius: 999px; background: #fff; }
|
||||
.csmsx-phone__num { direction: ltr; font-size: .82rem; color: var(--cs-ink); }
|
||||
.csmsx-phone .sms-primary-toggle { border-radius: 999px; font-size: .72rem; height: 1.9rem; padding-inline: .6rem; }
|
||||
.csmsx-empty { font-size: .8rem; color: var(--cs-muted); margin: 0; }
|
||||
|
||||
/* ارسال سفارشی */
|
||||
.csmsx-chips { display: flex; flex-wrap: wrap; gap: .35rem; margin-bottom: .5rem; }
|
||||
.csmsx-chip { font-size: .72rem; padding: .2rem .55rem; border-radius: 999px;
|
||||
border: 1px solid var(--cs-line); background: #fff; color: #4a4a4a; cursor: pointer;
|
||||
direction: ltr; font-family: ui-monospace, Menlo, Consolas, monospace; }
|
||||
.csmsx-chip:hover { border-color: var(--cs-brand); color: var(--cs-brand); }
|
||||
.csmsx-counter { display: flex; justify-content: space-between; gap: .5rem;
|
||||
font-size: .74rem; color: var(--cs-muted); margin-top: .4rem; }
|
||||
|
||||
/* ---------- واکنشگرا ---------- */
|
||||
@media (max-width: 768px) {
|
||||
.csmsx-head { padding: .9rem; }
|
||||
.csmsx-head__actions { width: 100%; }
|
||||
.csmsx-head__actions .button { flex: 1 1 auto; }
|
||||
.csmsx-filters { padding: .75rem; }
|
||||
.csmsx-filters select { min-width: 0; width: 100%; }
|
||||
.csmsx-filters .control { flex: 1 1 45%; }
|
||||
.csmsx-filters__note { margin-inline-start: 0; width: 100%; }
|
||||
.csmsx-modal__body { max-height: 72vh; }
|
||||
|
||||
/* موبایل: جدول به کارت تبدیل میشود. اسکرول افقی روی موبایل، متن پیامک را
|
||||
میبُرد و خواندن سوابق را عملاً غیرممکن میکرد؛ در این حالت هر پیامک یک
|
||||
کارت کامل با برچسب هر خانه است و هیچ اسکرول افقی لازم نیست. */
|
||||
.csmsx-history__scroll { overflow: visible; }
|
||||
.csmsx-history table, .csmsx-history tbody, .csmsx-history tr,
|
||||
.csmsx-history td { display: block; width: auto !important; }
|
||||
.csmsx-history thead { display: none; }
|
||||
.csmsx-history tbody tr { padding: .7rem .85rem; border-bottom: 1px solid var(--cs-line); }
|
||||
.csmsx-history tbody tr:last-child { border-bottom: 0; }
|
||||
.csmsx-history tbody td { display: flex; gap: .5rem; align-items: flex-start;
|
||||
padding: .18rem 0 !important; border: 0 !important; }
|
||||
.csmsx-history tbody td::before { content: attr(data-label); flex: 0 0 5.5rem;
|
||||
font-size: .72rem; color: var(--cs-muted); line-height: 1.9; }
|
||||
.csmsx-history tbody td[data-label=""]::before { content: none; }
|
||||
/* ستون متن: برچسب بالای متن (نه کنارش) تا عرض خواندن کم نشود. */
|
||||
.csmsx-history tbody td[data-label="متن"] { display: block; }
|
||||
.csmsx-history tbody td[data-label="متن"]::before { display: block;
|
||||
flex: none; margin-bottom: .15rem; }
|
||||
.csmsx-history tbody td > * { min-width: 0; }
|
||||
.csmsx-expand { white-space: nowrap; }
|
||||
.csmsx-msg { max-width: none; }
|
||||
.csmsx-col-id, .csmsx-col-sent, .csmsx-col-delivery-check { display: none !important; }
|
||||
}
|
||||
.smshist-meta { font-size: .78rem; line-height: 1.7; }
|
||||
.smshist-badge { white-space: nowrap; }
|
||||
{% endverbatim %}
|
||||
</style>
|
||||
|
||||
<div id="customerSmsHistory" class="column is-12-mobile is-12-desktop" dir="rtl">
|
||||
<h1 class="has-text-centered has-text-weight-bold mb-1">سوابق پیامک مشتری</h1>
|
||||
<p class="has-text-centered has-text-grey smshist-meta mb-3">
|
||||
همهٔ پیامکهای ارسالشده برای شمارههای این مشتری — جمعاً
|
||||
<strong>{{ total_count }}</strong> پیامک.
|
||||
</p>
|
||||
|
||||
<form method="get" class="field is-grouped is-grouped-multiline mb-3">
|
||||
{# ---------- ۱) نوار بالای صفحه: آمار + دکمههای تنظیمات ---------- #}
|
||||
<div class="csmsx-head">
|
||||
<div class="csmsx-head__main">
|
||||
<h1 class="csmsx-title">پیامکهای {{ customer.name_family }}</h1>
|
||||
<p class="csmsx-sub">
|
||||
جمعاً <b>{{ total_count }}</b> پیامک در سابقه ·
|
||||
<b>{{ sms_enabled_count }}</b> از <b>{{ sms_type_total }}</b> نوع پیامک برای این مشتری فعال است.
|
||||
</p>
|
||||
<div class="csmsx-stats">
|
||||
<span class="csmsx-stat is-sent">✅ ارسالشده <b>{{ stat_sent }}</b></span>
|
||||
<span class="csmsx-stat is-pending">⏳ در صف <b>{{ stat_pending }}</b></span>
|
||||
<span class="csmsx-stat is-failed">⚠️ ناموفق <b>{{ stat_failed }}</b></span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="csmsx-head__actions">
|
||||
{% if perms.admin_panel.ManageSMS %}
|
||||
<button type="button" class="button is-link is-light js-csmsx-open" data-target="smsSettingsModal"
|
||||
aria-haspopup="dialog">
|
||||
⚙️ تنظیمات پیامک
|
||||
</button>
|
||||
<button type="button" class="button is-info is-light js-csmsx-open" data-target="smsCustomModal"
|
||||
aria-haspopup="dialog">
|
||||
✉️ ارسال پیامک
|
||||
</button>
|
||||
{% endif %}
|
||||
{% if perms.admin_panel.ManageSMSSettings %}
|
||||
<a class="button is-white" href="{% url 'admin_panel:sms_templates' %}"
|
||||
title="ویرایش متن قالبهای پیامک">🗂️ متن قالبها</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# ---------- ۲) فیلترها ---------- #}
|
||||
<form method="get" class="csmsx-filters">
|
||||
<div class="control">
|
||||
<label for="csmsx-status">وضعیت ارسال</label>
|
||||
<div class="select">
|
||||
<select name="status">
|
||||
<option value="">همهٔ وضعیتهای ارسال</option>
|
||||
<select id="csmsx-status" name="status">
|
||||
<option value="">همهٔ وضعیتها</option>
|
||||
{% for value, label in status_choices %}
|
||||
<option value="{{ value }}" {% if status == value %}selected{% endif %}>{{ label }}</option>
|
||||
{% endfor %}
|
||||
@@ -35,50 +228,72 @@
|
||||
</div>
|
||||
</div>
|
||||
<div class="control">
|
||||
<label for="csmsx-delivery">وضعیت تحویل</label>
|
||||
<div class="select">
|
||||
<select name="delivery">
|
||||
<option value="">همهٔ وضعیتهای تحویل</option>
|
||||
<select id="csmsx-delivery" name="delivery">
|
||||
<option value="">همهٔ وضعیتها</option>
|
||||
{% for value, label in delivery_choices %}
|
||||
<option value="{{ value }}" {% if delivery == value %}selected{% endif %}>{{ label }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="control"><button class="button is-link" type="submit">فیلتر</button></div>
|
||||
<div class="control">
|
||||
<button class="button is-link" type="submit">اعمال فیلتر</button>
|
||||
</div>
|
||||
{% if status or delivery %}
|
||||
<div class="control">
|
||||
<a class="button is-light" href="{% url 'admin_panel:customerSmsHistory' customer.id %}">حذف فیلتر</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
<span class="csmsx-filters__note">
|
||||
{% if status or delivery %}
|
||||
نمایش <b>{{ filtered_count }}</b> از <b>{{ total_count }}</b> پیامک
|
||||
{% else %}
|
||||
نمایش <b>{{ total_count }}</b> پیامک، جدیدترینها ابتدا
|
||||
{% endif %}
|
||||
</span>
|
||||
</form>
|
||||
|
||||
{% if status or delivery %}
|
||||
<p class="mb-2 smshist-meta">نتایج فیلترشده: <strong>{{ filtered_count }}</strong> مورد</p>
|
||||
{% endif %}
|
||||
|
||||
<div class="table-container">
|
||||
<table class="table is-fullwidth is-narrow is-hoverable is-striped is-bordered">
|
||||
{# ---------- ۳) سوابق ---------- #}
|
||||
<div class="csmsx-history">
|
||||
<div class="csmsx-history__scroll">
|
||||
<table class="table is-fullwidth is-narrow is-hoverable is-striped">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>شناسه</th>
|
||||
<th style="min-width: 7rem;">نوع پیامک</th>
|
||||
<th style="min-width: 8rem;">شماره گیرنده</th>
|
||||
<th style="min-width: 18rem;">متن پیامک</th>
|
||||
<th class="csmsx-col-id">شناسه</th>
|
||||
<th style="min-width: 8.5rem;">نوع پیامک</th>
|
||||
<th style="min-width: 8rem;">گیرنده</th>
|
||||
<th style="min-width: 16rem;">متن پیامک</th>
|
||||
<th>وضعیت ارسال</th>
|
||||
<th style="min-width: 8rem;">وضعیت تحویل</th>
|
||||
<th>تاریخ ایجاد</th>
|
||||
<th>تاریخ ارسال</th>
|
||||
<th style="min-width: 7rem;">تاریخ ایجاد</th>
|
||||
<th class="csmsx-col-sent" style="min-width: 7rem;">تاریخ ارسال</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for item in items %}
|
||||
<tr>
|
||||
<td>{{ item.id }}</td>
|
||||
<td>{{ item.get_sms_type_display|default:'—' }}</td>
|
||||
<td dir="ltr">{{ item.phone_number }}</td>
|
||||
<td class="smshist-msg">{{ item.message }}</td>
|
||||
<td>
|
||||
<span class="tag smshist-badge
|
||||
<td class="csmsx-col-id csmsx-meta" data-label="شناسه">{{ item.id }}</td>
|
||||
<td data-label="نوع">
|
||||
{% if item.sms_type %}
|
||||
<span class="csmsx-type-chip">{{ item.get_sms_type_display }}</span>
|
||||
{% else %}
|
||||
<span class="csmsx-type-chip">سفارشی</span>
|
||||
{% endif %}
|
||||
{% if item.attempts %}
|
||||
<div class="csmsx-meta">تلاش: {{ item.attempts }}</div>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="csmsx-meta" dir="ltr" data-label="گیرنده">{{ item.phone_number }}</td>
|
||||
<td data-label="متن">
|
||||
<div class="csmsx-msg {% if item.message|length > 170 %}is-clamped{% endif %}">{{ item.message }}</div>
|
||||
{% if item.message|length > 170 %}
|
||||
<button type="button" class="csmsx-expand js-csmsx-expand" aria-expanded="false">نمایش کامل</button>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td data-label="ارسال">
|
||||
<span class="tag csmsx-badge
|
||||
{% if item.status == 'sent' %}is-success
|
||||
{% elif item.status == 'failed' %}is-danger
|
||||
{% elif item.status == 'pending' %}is-warning
|
||||
@@ -87,13 +302,13 @@
|
||||
{{ item.get_status_display }}
|
||||
</span>
|
||||
{% if item.last_error %}
|
||||
<div class="has-text-danger smshist-meta" title="{{ item.last_error }}">
|
||||
<div class="has-text-danger csmsx-meta" title="{{ item.last_error }}">
|
||||
خطا: {{ item.last_error|truncatechars:80 }}
|
||||
</div>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
<span class="tag smshist-badge
|
||||
<td data-label="تحویل">
|
||||
<span class="tag csmsx-badge
|
||||
{% if item.delivery_status == 'delivered' %}is-success
|
||||
{% elif item.delivery_status == 'not_delivered' %}is-danger
|
||||
{% elif item.delivery_status == 'expired' %}is-danger
|
||||
@@ -102,21 +317,24 @@
|
||||
{{ item.get_delivery_status_display|default:'بدون پیگیری' }}
|
||||
</span>
|
||||
{% if item.delivery_checked_at %}
|
||||
<div class="has-text-grey smshist-meta">استعلام: {{ item.delivery_checked_at|toJalaliDT }}</div>
|
||||
<div class="csmsx-meta csmsx-col-delivery-check">استعلام: {{ item.delivery_checked_at|toJalaliDT }}</div>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="smshist-meta">{{ item.created_at|toJalaliDT }}</td>
|
||||
<td class="smshist-meta">{{ item.sent_at|toJalaliDT|default:'—' }}</td>
|
||||
<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">پیامکی برای این مشتری ثبت نشده است.</td></tr>
|
||||
<tr>
|
||||
<td colspan="8" class="has-text-centered py-5" data-label="">پیامکی برای این مشتری ثبت نشده است.</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if items.has_other_pages %}
|
||||
<nav class="pagination is-centered" role="navigation" aria-label="pagination">
|
||||
<nav class="pagination is-centered mt-4" role="navigation" aria-label="pagination">
|
||||
{% if items.has_previous %}
|
||||
<a class="pagination-previous" href="?page={{ items.previous_page_number }}{% if status %}&status={{ status }}{% endif %}{% if delivery %}&delivery={{ delivery }}{% endif %}">قبلی</a>
|
||||
{% else %}
|
||||
@@ -135,3 +353,235 @@
|
||||
</div>
|
||||
|
||||
{% endblock %}
|
||||
|
||||
{% block BodyCustomer2 %}
|
||||
{% if perms.admin_panel.ManageSMS %}
|
||||
|
||||
{# ---------- مودال ۱: تنظیمات پیامک (نوعها + شمارههای اصلی) ---------- #}
|
||||
<div class="modal csmsx-modal" id="smsSettingsModal" role="dialog" aria-modal="true"
|
||||
aria-labelledby="smsSettingsTitle">
|
||||
<div class="modal-background js-csmsx-close"></div>
|
||||
<div class="modal-content">
|
||||
<div class="box" dir="rtl">
|
||||
<div class="csmsx-modal__head">
|
||||
<div>
|
||||
<h2 class="csmsx-modal__title" id="smsSettingsTitle">⚙️ تنظیمات پیامک این مشتری</h2>
|
||||
<p class="csmsx-modal__sub">
|
||||
هر تغییر بلافاصله ذخیره میشود. خاموشکردن یک نوع، فقط برای همین مشتری است؛
|
||||
متن پیامکها از <b>قالبهای پیامک</b> میآید.
|
||||
</p>
|
||||
</div>
|
||||
<button type="button" class="delete js-csmsx-close" aria-label="بستن"></button>
|
||||
</div>
|
||||
|
||||
<div class="csmsx-modal__body">
|
||||
{% if notice_text and request.GET.open == 'settings' %}
|
||||
<div class="notification {{ notice_kind }} is-light py-2 px-3 mb-3">
|
||||
<strong>{{ notice_text }}</strong>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{# --- بخش الف: شمارههای اصلی پیامک --- #}
|
||||
<section class="csmsx-section">
|
||||
<h3 class="csmsx-section-title">📱 شمارههای اصلی پیامک</h3>
|
||||
{% if phone_entries %}
|
||||
<div class="csmsx-phones">
|
||||
{% for entry in phone_entries %}
|
||||
{% if entry.is_mobile %}
|
||||
<span class="csmsx-phone">
|
||||
<span class="csmsx-phone__num">{{ entry.display }}</span>
|
||||
<button type="button"
|
||||
class="button sms-primary-toggle {% if entry.is_primary %}is-warning{% else %}is-light{% endif %}"
|
||||
data-url="{% url 'admin_panel:sms_primary_toggle' customer.id %}"
|
||||
data-phone="{{ entry.raw }}"
|
||||
data-primary="{% if entry.is_primary %}true{% else %}false{% endif %}"
|
||||
title="{% if entry.is_primary %}برداشتن از شمارههای اصلی پیامک{% else %}تعیین بهعنوان شمارهٔ اصلی پیامک{% endif %}">
|
||||
{% if entry.is_primary %}★ اصلی{% else %}☆ اصلی{% endif %}
|
||||
</button>
|
||||
</span>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
</div>
|
||||
<p class="csmsx-empty mt-2">
|
||||
پیامکها به شمارههای «★ اصلی» میروند؛ اگر هیچ شمارهای اصلی نباشد، شمارهٔ اول مشتری.
|
||||
</p>
|
||||
{% else %}
|
||||
<p class="csmsx-empty">شمارهای برای این مشتری ثبت نشده است.</p>
|
||||
{% endif %}
|
||||
</section>
|
||||
|
||||
|
||||
|
||||
{# --- بخش ب: فعال/غیرفعال کردن هر نوع پیامک --- #}
|
||||
<section class="csmsx-section">
|
||||
<h3 class="csmsx-section-title">
|
||||
🔔 انواع پیامک
|
||||
<span class="csmsx-tag">{{ sms_enabled_count }} از {{ sms_type_total }} فعال</span>
|
||||
</h3>
|
||||
{% for item in sms_toggles %}
|
||||
<form method="post" action="{% url 'admin_panel:sms_customer_toggle' customer.id %}"
|
||||
class="csmsx-type {% if not item.enabled %}is-off{% endif %}">
|
||||
{% csrf_token %}
|
||||
<input type="hidden" name="sms_type" value="{{ item.code }}">
|
||||
<input type="hidden" name="is_enabled" value="{% if item.enabled %}false{% else %}true{% endif %}">
|
||||
<span class="csmsx-type__icon" aria-hidden="true">{{ item.icon }}</span>
|
||||
<div class="csmsx-type__main">
|
||||
<span class="csmsx-type__label">{{ item.label }}</span>
|
||||
<span class="csmsx-type__meta">
|
||||
{% if item.when %}<span class="csmsx-tag">{{ item.when }}</span>{% endif %}
|
||||
<span class="csmsx-tag {% if item.auto_send %}is-auto{% endif %}">
|
||||
{% if item.auto_send %}ارسال خودکار{% else %}نیازمند تأیید مدیر{% endif %}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
<button type="submit" class="csmsx-switch {% if item.enabled %}is-on{% endif %}"
|
||||
role="switch" aria-checked="{% if item.enabled %}true{% else %}false{% endif %}"
|
||||
title="{% if item.enabled %}خاموش کردن{% else %}روشن کردن{% endif %} این نوع پیامک">
|
||||
<span class="csmsx-switch__track" aria-hidden="true"></span>
|
||||
<span class="csmsx-switch__text">{% if item.enabled %}فعال{% else %}غیرفعال{% endif %}</span>
|
||||
</button>
|
||||
</form>
|
||||
{% endfor %}
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<div class="csmsx-modal__foot">
|
||||
<button type="button" class="button is-light js-csmsx-close">بستن</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# ---------- مودال ۲: ارسال پیامک سفارشی ---------- #}
|
||||
<div class="modal csmsx-modal" id="smsCustomModal" role="dialog" aria-modal="true"
|
||||
aria-labelledby="smsCustomTitle">
|
||||
<div class="modal-background js-csmsx-close"></div>
|
||||
<div class="modal-content">
|
||||
<form class="box" dir="rtl" method="post"
|
||||
action="{% url 'admin_panel:sms_send_custom' customer.id %}">
|
||||
{% csrf_token %}
|
||||
<div class="csmsx-modal__head">
|
||||
<div>
|
||||
<h2 class="csmsx-modal__title" id="smsCustomTitle">✉️ ارسال پیامک سفارشی</h2>
|
||||
<p class="csmsx-modal__sub">
|
||||
به شمارههای «★ اصلی» این مشتری ارسال میشود و بیدرنگ در صف قرار میگیرد.
|
||||
</p>
|
||||
</div>
|
||||
<button type="button" class="delete js-csmsx-close" aria-label="بستن"></button>
|
||||
</div>
|
||||
|
||||
<div class="csmsx-modal__body">
|
||||
{% if notice_text and request.GET.open == 'custom' %}
|
||||
<div class="notification {{ notice_kind }} is-light py-2 px-3 mb-3">
|
||||
<strong>{{ notice_text }}</strong>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="csmsx-chips">
|
||||
<button type="button" class="csmsx-chip js-csmsx-token" data-token="{name}">{name}</button>
|
||||
<button type="button" class="csmsx-chip js-csmsx-token" data-token="{balance_sep}">{balance_sep}</button>
|
||||
</div>
|
||||
|
||||
<textarea id="csmsxCustomText" class="textarea" name="text" rows="5"
|
||||
placeholder="متن پیامک…" required
|
||||
data-counter="#csmsxCustomCounter"></textarea>
|
||||
<div class="csmsx-counter">
|
||||
<span>{name} با نام مشتری، {balance_sep} با مانده حساب جایگزین میشود.</span>
|
||||
<span id="csmsxCustomCounter">۰ نویسه</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="csmsx-modal__foot">
|
||||
<button type="button" class="button is-light js-csmsx-close">انصراف</button>
|
||||
<button type="submit" class="button is-info">ارسال پیامک</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// باز/بسته کردن مودالهای این صفحه + درج متغیر در متن + شمارندهٔ نویسه.
|
||||
// بدون کتابخانهٔ جدید؛ کلید Esc و کلیک روی پسزمینه هم مودال را میبندد.
|
||||
(function () {
|
||||
if (window.__csmsxModalInit) { return; }
|
||||
window.__csmsxModalInit = true;
|
||||
|
||||
function openModal(id) {
|
||||
var modal = document.getElementById(id);
|
||||
if (!modal) { return; }
|
||||
modal.classList.add('is-active');
|
||||
var field = modal.querySelector('textarea');
|
||||
if (field) { field.focus(); }
|
||||
}
|
||||
|
||||
function closeModal(modal) {
|
||||
if (modal) { modal.classList.remove('is-active'); }
|
||||
}
|
||||
|
||||
document.addEventListener('click', function (event) {
|
||||
if (!event.target.closest) { return; }
|
||||
|
||||
var opener = event.target.closest('.js-csmsx-open');
|
||||
if (opener) {
|
||||
event.preventDefault();
|
||||
openModal(opener.getAttribute('data-target'));
|
||||
return;
|
||||
}
|
||||
var closer = event.target.closest('.js-csmsx-close');
|
||||
if (closer) {
|
||||
event.preventDefault();
|
||||
closeModal(closer.closest('.modal'));
|
||||
return;
|
||||
}
|
||||
var expand = event.target.closest('.js-csmsx-expand');
|
||||
if (expand) {
|
||||
event.preventDefault();
|
||||
var box = expand.parentNode.querySelector('.csmsx-msg');
|
||||
if (box) {
|
||||
var opened = box.classList.toggle('is-clamped') === false;
|
||||
expand.setAttribute('aria-expanded', opened ? 'true' : 'false');
|
||||
expand.textContent = opened ? 'خلاصه' : 'نمایش کامل';
|
||||
}
|
||||
return;
|
||||
}
|
||||
var chip = event.target.closest('.js-csmsx-token');
|
||||
if (chip) {
|
||||
event.preventDefault();
|
||||
var field = document.getElementById('csmsxCustomText');
|
||||
if (!field) { return; }
|
||||
var token = chip.getAttribute('data-token') || '';
|
||||
var start = field.selectionStart == null ? field.value.length : field.selectionStart;
|
||||
var end = field.selectionEnd == null ? field.value.length : field.selectionEnd;
|
||||
field.value = field.value.slice(0, start) + token + field.value.slice(end);
|
||||
field.selectionStart = field.selectionEnd = start + token.length;
|
||||
field.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
field.focus();
|
||||
}
|
||||
});
|
||||
|
||||
document.addEventListener('keydown', function (event) {
|
||||
if (event.key !== 'Escape' && event.key !== 'Esc') { return; }
|
||||
var active = document.querySelector('#smsSettingsModal.is-active, #smsCustomModal.is-active');
|
||||
closeModal(active);
|
||||
});
|
||||
|
||||
document.addEventListener('input', function (event) {
|
||||
var field = event.target;
|
||||
if (!field || field.id !== 'csmsxCustomText') { return; }
|
||||
var target = document.querySelector(field.getAttribute('data-counter'));
|
||||
if (!target) { return; }
|
||||
// ارقام فارسی، همخوان با بقیهٔ متن صفحه.
|
||||
var count = String(field.value.length)
|
||||
.replace(/[0-9]/g, function (d) { return '۰۱۲۳۴۵۶۷۸۹'.charAt(Number(d)); });
|
||||
target.textContent = count + ' نویسه';
|
||||
});
|
||||
|
||||
// باز شدن خودکار مودال بر اساس ?open= (بعد از ذخیرهٔ تنظیمات یا خطای ارسال).
|
||||
var wanted = new URLSearchParams(window.location.search).get('open');
|
||||
if (wanted === 'settings') { openModal('smsSettingsModal'); }
|
||||
if (wanted === 'custom') { openModal('smsCustomModal'); }
|
||||
})();
|
||||
</script>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
|
||||
@@ -11,9 +11,10 @@ from django.utils import timezone
|
||||
from persiantools.jdatetime import JalaliDate
|
||||
|
||||
from ..models import Action, Customer
|
||||
from ..sms.triggers import notify_visit_scheduled
|
||||
from ..sms.triggers import notify_install_scheduled, notify_visit_scheduled
|
||||
from ..visit_policy import (REASON_CANCEL, REASON_CREATE, REASON_EDIT,
|
||||
ActionDeleteError, as_aware, clear_visit_hold,
|
||||
ActionDeleteError, as_aware,
|
||||
cancel_pending_sms_for_action, clear_visit_hold,
|
||||
default_hold_until, normalize_finish_datetime,
|
||||
normalize_hold_until, refresh_last_bazdid,
|
||||
set_visit_hold, soft_delete_action,
|
||||
@@ -159,6 +160,45 @@ def _apply_visit_hold(customer_obj, payload, *, status, is_new, force_hold=False
|
||||
return result
|
||||
|
||||
|
||||
# نگاشت «نوع اقدام → (تریگر پیامک، نوع پیامک)». تنها منبعِ تصمیم است تا مسیر
|
||||
# «ایجاد» و «ویرایش» هرگز واگرا نشوند (باگ قبلی: ویرایش تریگر را صدا نمیزد).
|
||||
_ACTION_EVENT_SMS = {
|
||||
Action.ActionType.BAZDID: (notify_visit_scheduled, 'visit_created'),
|
||||
Action.ActionType.NASB: (notify_install_scheduled, 'install_created'),
|
||||
}
|
||||
|
||||
|
||||
def _sync_action_event_sms(customer, action, *, previous_type=None, user=None):
|
||||
"""همراستاسازی پیامک رویدادی با نوع اقدام (مشترکِ ایجاد و ویرایش).
|
||||
|
||||
* نوع جدید **رویدادی** است (بازدید/نصب):
|
||||
- اگر نوع عوض شده باشد (یا رکورد تازه باشد) ⇒ تریگر همان نوع صدا زده
|
||||
میشود. تکرار ممکن نیست: کلید یکتاسازی ``{sms_type}:{action.pk}`` است.
|
||||
- اگر نوع همان بوده باشد (ویرایش عادی متن/تاریخ) ⇒ هیچ پیامکی ساخته
|
||||
نمیشود، پس ویرایشهای پیاپی اسپم تولید نمیکنند.
|
||||
* نوع جدید **غیررویدادی** است ولی قبلاً رویدادی بود ⇒ پیامک معلقِ همان رکورد
|
||||
لغو میشود (کاربر عملاً بازدید/نصب را پس گرفته است).
|
||||
|
||||
خروجی: تعداد پیامکهای ساختهشده.
|
||||
"""
|
||||
current = _ACTION_EVENT_SMS.get(action.actionType)
|
||||
previous = _ACTION_EVENT_SMS.get(previous_type) if previous_type else None
|
||||
|
||||
# نوع رویدادی عوض شده (بازدید ↔ نصب) ⇒ پیامک معلقِ نوع قبلی همین رکورد لغو شود،
|
||||
# وگرنه هر دو پیامک در صف میمانند.
|
||||
if previous is not None and previous_type != action.actionType:
|
||||
cancel_pending_sms_for_action(
|
||||
customer, action_id=action.pk, sms_type=previous[1],
|
||||
)
|
||||
|
||||
if current is None:
|
||||
# نوع جدید رویدادی نیست؛ اگر قبلاً بود، پیامکش همین بالا لغو شد.
|
||||
return 0
|
||||
if previous_type == action.actionType:
|
||||
return 0 # ویرایش عادی همان نوع ⇒ پیامک تازهای لازم نیست
|
||||
return current[0](customer, action, created_by=user)
|
||||
|
||||
|
||||
def _jalali_day(value):
|
||||
"""تاریخ شمسی روز (به وقت محلی) برای پیامهای کاربر."""
|
||||
if value is None:
|
||||
@@ -232,9 +272,8 @@ def customerNewAction(request ,c_id):
|
||||
)
|
||||
_report_hold_result(request, hold_result)
|
||||
|
||||
# پیامک «بازدید شما برای تاریخ … ثبت شد» فقط برای اقدام از نوع بازدید.
|
||||
if newAction.actionType == Action.ActionType.BAZDID:
|
||||
notify_visit_scheduled(customer_obj, newAction, created_by=request.user)
|
||||
# پیامک «بازدید/نصب شما برای تاریخ … ثبت شد» (برای اقدام از نوع بازدید یا نصب).
|
||||
_sync_action_event_sms(customer_obj, newAction, user=request.user)
|
||||
|
||||
return redirect('/panel/customer/'+str(c_id)+'/action')
|
||||
|
||||
@@ -270,6 +309,9 @@ def customerEditAction(request ,c_id, action_id):
|
||||
customer_obj = Customer.objects.get(id=int(c_id))
|
||||
newAction = Action.objects.get(id=action_id)
|
||||
was_done = newAction.status == Action.Status.FINISH
|
||||
# نوع **قبلی** رکورد (پیش از بازنویسی) — برای تشخیص گذار «غیربازدید → بازدید»
|
||||
# و برعکس. اگر بعد از ``save()`` خوانده شود، همیشه مقدار جدید است.
|
||||
previous_type = newAction.actionType
|
||||
|
||||
newAction.actionType = payload['actionType']
|
||||
newAction.actionDateTime = payload['actionDateTime']
|
||||
@@ -297,6 +339,15 @@ def customerEditAction(request ,c_id, action_id):
|
||||
)
|
||||
_report_hold_result(request, hold_result)
|
||||
|
||||
# پیامک «بازدید/نصب شما برای تاریخ … ثبت شد».
|
||||
# فقط *گذار* نوع اقدام بررسی میشود، نه هر ویرایش (جزئیات در
|
||||
# ``_sync_action_event_sms``):
|
||||
# * غیررویدادی → بازدید/نصب ⇒ تریگر همان نوع صدا زده میشود.
|
||||
# * بازدید/نصب → نوع دیگر ⇒ پیامک معلقِ همان رکورد لغو میشود.
|
||||
_sync_action_event_sms(
|
||||
customer_obj, newAction, previous_type=previous_type, user=request.user,
|
||||
)
|
||||
|
||||
#return redirect('/panel/customer/'+str(c_id)+'/action')
|
||||
return redirect(request.META.get('HTTP_REFERER', 'redirect_if_referer_not_found'))
|
||||
|
||||
|
||||
@@ -17,7 +17,6 @@ from ..sms.balance import customer_balance
|
||||
from ..sms.phones import (normalize_primary_values,
|
||||
save_primary_normalized)
|
||||
from ..sms.triggers import notify_payment_thanks, notify_welcome
|
||||
from ..sms.rules import customer_toggle_map
|
||||
from ..visit_policy import (SOURCE_COORDINATION, SOURCE_MANUAL, SOURCE_SCHEDULED,
|
||||
appointment_sources_map, as_aware,
|
||||
followups_on_date, last_visit_map, overdue_followups,
|
||||
@@ -164,11 +163,9 @@ def customerNew2(request ,c_id):
|
||||
#all_prd = serializers.serialize("json", all_prda)
|
||||
#return JsonResponse(all_prd,safe = False)
|
||||
dateNow = datetime.datetime.now().date
|
||||
sms_toggles = customer_toggle_map(customer)
|
||||
data = {
|
||||
'customer': customer, 'categorys': categorys, 'dateNow': dateNow,
|
||||
'factorha': factorha, 'mandehesabCs': mandehesabCs,
|
||||
'sms_toggles': sms_toggles,
|
||||
}
|
||||
return render(request, 'admin_panel/customerNew2.html', data)
|
||||
|
||||
@@ -494,8 +491,8 @@ def entezar(request):
|
||||
|
||||
# ---------- نمای «نیازمند پیگیری» (یک تاریخ مشخص) ----------
|
||||
# این نما **مستقل** از «عقبافتاده» است: مشتری سالمی که با او قرار تماس
|
||||
# گذاشته شده هم باید دیده شود. قاعدهٔ حذف «بازدید» در خود
|
||||
# ``followups_on_date`` اعمال میشود (مشتریِ دارای قرار بازدید نمیآید).
|
||||
# گذاشته شده هم باید دیده شود. قاعدهٔ حذفِ «بازدید/نصب» در خود
|
||||
# ``followups_on_date`` اعمال میشود (مشتریِ دارای قرار بازدید یا نصب نمیآید).
|
||||
today_followups = followups_on_date(today_date, now=now)
|
||||
|
||||
# پیگیریهای عقبافتاده: موعدشان گذشته و هنوز تعیین تکلیف نشدهاند.
|
||||
|
||||
+79
-13
@@ -17,6 +17,7 @@ from ..sms.parts import ENCODING_LABELS, describe, parts_count
|
||||
from ..sms.phones import (customer_phone_entries, save_primary_normalized,
|
||||
stored_primary_normalized)
|
||||
from ..sms.render import format_jalali, render_template
|
||||
from ..sms.rules import customer_toggle_map
|
||||
from ..sms.timing import apply_timing, current_timing_value, timing_for
|
||||
|
||||
_manage_required = permission_required("admin_panel.ManageSMS", raise_exception=True)
|
||||
@@ -58,6 +59,16 @@ def _notice(url, code, **extra):
|
||||
def _customer_url(c_id):
|
||||
return reverse("admin_panel:customer", kwargs={"c_id": c_id})
|
||||
|
||||
|
||||
def _customer_sms_url(c_id):
|
||||
"""صفحهٔ «سوابق و تنظیمات پیامک» مشتری — مقصد بازگشتِ عملیات پیامکی.
|
||||
|
||||
تنظیمات پیامک از صفحهٔ فاکتور به این صفحه منتقل شده؛ پس بعد از هر تغییر
|
||||
(تیک نوع پیامک، شمارهٔ اصلی، ارسال سفارشی) کاربر باید همینجا بماند، نه به
|
||||
صفحهٔ اصلی مشتری پرتاب شود.
|
||||
"""
|
||||
return reverse("admin_panel:customerSmsHistory", kwargs={"c_id": c_id})
|
||||
|
||||
# متغیرهای قابل استفاده در متن قالبها (برای راهنمای پنل).
|
||||
TEMPLATE_VARIABLES = [
|
||||
("{name}", "نام مشتری"),
|
||||
@@ -79,6 +90,7 @@ SMS_TYPE_UI = {
|
||||
SMSType.CHEQUE_REGISTRATION: {"icon": "🧾", "when": "چند روز پیش از سررسید چک"},
|
||||
SMSType.VISIT: {"icon": "🔧", "when": "پس از گذشت دورهٔ بازدید دستگاه"},
|
||||
SMSType.VISIT_CREATED: {"icon": "📅", "when": "پس از ثبت اقدام بازدید"},
|
||||
SMSType.INSTALL_CREATED: {"icon": "🛠️", "when": "پس از ثبت اقدام نصب"},
|
||||
SMSType.PAYMENT_THANKS: {"icon": "🙏", "when": "پس از ثبت پرداخت"},
|
||||
SMSType.NESIYE_DUE: {"icon": "💳", "when": "پیش از سررسید نسیه"},
|
||||
}
|
||||
@@ -313,8 +325,12 @@ def sms_customer_toggle(request, c_id):
|
||||
)
|
||||
setting.is_enabled = enabled
|
||||
setting.save()
|
||||
return _notice(_customer_url(c_id), "customer_sms_saved")
|
||||
return redirect("admin_panel:customer", c_id=c_id)
|
||||
# ``open=settings`` ⇒ صفحه با همان مودال تنظیمات باز میشود تا کاربر
|
||||
# چند نوع را پشتسرهم روشن/خاموش کند و هر بار مودال را باز نکند.
|
||||
return _notice(
|
||||
_customer_sms_url(c_id), "customer_sms_saved", open="settings",
|
||||
)
|
||||
return redirect("admin_panel:customerSmsHistory", c_id=c_id)
|
||||
|
||||
|
||||
@login_required(login_url='/panel/p_login/')
|
||||
@@ -336,13 +352,13 @@ def sms_primary_toggle(request, c_id):
|
||||
"",
|
||||
)
|
||||
if not normalized:
|
||||
return _notice(_customer_url(c_id), "primary_not_found")
|
||||
return _notice(_customer_sms_url(c_id), "primary_not_found")
|
||||
current = [n for n in stored_primary_normalized(customer) if n != normalized]
|
||||
if enabled:
|
||||
current.append(normalized)
|
||||
save_primary_normalized(customer, current)
|
||||
return _notice(_customer_url(c_id), "primary_updated")
|
||||
return redirect("admin_panel:customer", c_id=c_id)
|
||||
return _notice(_customer_sms_url(c_id), "primary_updated")
|
||||
return redirect("admin_panel:customerSmsHistory", c_id=c_id)
|
||||
|
||||
|
||||
@login_required(login_url='/panel/p_login/')
|
||||
@@ -358,15 +374,16 @@ def sms_send_custom(request, c_id):
|
||||
text = (request.POST.get("text", "") or "").strip()
|
||||
rendered = render_template(text, {"name": customer.name_family}).strip()
|
||||
if not rendered:
|
||||
return _notice(_customer_url(c_id), "custom_empty")
|
||||
# خطا ⇒ همان مودال ارسال سفارشی دوباره باز شود تا متن از دست نرود.
|
||||
return _notice(_customer_sms_url(c_id), "custom_empty", open="custom")
|
||||
created = enqueue_sms(
|
||||
customer=customer, sms_type="", message=rendered,
|
||||
created_by=request.user,
|
||||
approved=True, # متن را خودِ مدیر نوشته و ارسال را خواسته است.
|
||||
)
|
||||
code = "custom_queued" if created else "custom_no_phone"
|
||||
return _notice(_customer_url(c_id), code)
|
||||
return redirect("admin_panel:customer", c_id=c_id)
|
||||
return _notice(_customer_sms_url(c_id), code)
|
||||
return redirect("admin_panel:customerSmsHistory", c_id=c_id)
|
||||
|
||||
|
||||
@login_required(login_url='/panel/p_login/')
|
||||
@@ -428,11 +445,17 @@ def sms_queue(request):
|
||||
@login_required(login_url='/panel/p_login/')
|
||||
@_manage_required
|
||||
def customer_sms_history(request, c_id):
|
||||
"""سوابق پیامکهای یک مشتری روی صفحهٔ خود مشتری.
|
||||
"""سوابق + تنظیمات پیامک یک مشتری (همه در یک صفحه).
|
||||
|
||||
همهٔ پیامکهایی که برای شمارههای این مشتری در صف قرار گرفته/ارسال شدهاند
|
||||
(هر وضعیتی) با متن دقیق، تاریخ شمسی و وضعیت تحویل (دلیوری) نمایش داده میشوند و
|
||||
فهرست صفحهبندی میشود. با ``?status=`` و ``?delivery=`` میتوان فیلتر کرد.
|
||||
ساختار صفحه عمداً دو لایه است تا **تنظیمات، سوابق را شلوغ نکند**:
|
||||
|
||||
* لایهٔ اول (همیشه دیده میشود): آمار کوتاه، فیلترها و فهرست سوابق.
|
||||
* لایهٔ دوم (فقط با کلیک): تنظیمات پیامک، شمارههای اصلی و ارسال سفارشی،
|
||||
همه داخل مودالهای Bulma.
|
||||
|
||||
تنظیماتی که قبلاً داخل باکس «پیامک» صفحهٔ ایجاد فاکتور بود
|
||||
(``_customer_sms.html``) حالا اینجاست؛ آن باکس حذف شد تا فاکتور و سوابق هر
|
||||
دو تمامعرض بمانند.
|
||||
"""
|
||||
customer = get_object_or_404(Customer, id=c_id)
|
||||
status = request.GET.get("status", "")
|
||||
@@ -446,6 +469,34 @@ def customer_sms_history(request, c_id):
|
||||
qs = qs.filter(delivery_status=delivery)
|
||||
paginator = Paginator(qs, 20)
|
||||
page = paginator.get_page(request.GET.get("page"))
|
||||
|
||||
# --- آمار کارتهای بالای صفحه (کل تاریخچهٔ همین مشتری، بدون فیلتر) ---
|
||||
all_items = SMSQueue.objects.filter(customer=customer)
|
||||
status_counts = dict(
|
||||
all_items.order_by().values_list("status").annotate(n=Count("id"))
|
||||
)
|
||||
total_count = all_items.count()
|
||||
|
||||
# --- تنظیمات: نوعهای پیامک با وضعیت مؤثر همین مشتری + «ارسال خودکار» قالب ---
|
||||
# ``auto_send`` تنظیمی سراسری است (صفحهٔ قالبها)، ولی دانستنش اینجا کمک
|
||||
# میکند کاربر بداند پیامکِ این نوع بیدرنگ میرود یا منتظر تأیید میماند.
|
||||
auto_send = dict(
|
||||
SMSTemplate.objects.values_list("sms_type", "auto_send")
|
||||
)
|
||||
type_labels = dict(SMSType.choices)
|
||||
sms_toggles = [
|
||||
{
|
||||
"code": item["code"],
|
||||
"label": item["label"],
|
||||
"enabled": item["enabled"],
|
||||
"auto_send": bool(auto_send.get(item["code"])),
|
||||
"icon": SMS_TYPE_UI.get(item["code"], {}).get("icon", "✉️"),
|
||||
"when": SMS_TYPE_UI.get(item["code"], {}).get("when", ""),
|
||||
}
|
||||
for item in customer_toggle_map(customer)
|
||||
]
|
||||
enabled_count = sum(1 for item in sms_toggles if item["enabled"])
|
||||
|
||||
data = {
|
||||
"customer": customer,
|
||||
"items": page,
|
||||
@@ -453,8 +504,23 @@ def customer_sms_history(request, c_id):
|
||||
"delivery": delivery,
|
||||
"status_choices": SMSQueue.Status.choices,
|
||||
"delivery_choices": SMSQueue.DeliveryStatus.choices,
|
||||
"total_count": SMSQueue.objects.filter(customer=customer).count(),
|
||||
"total_count": total_count,
|
||||
"filtered_count": paginator.count,
|
||||
# تنظیمات (مودال)
|
||||
"sms_toggles": sms_toggles,
|
||||
"sms_enabled_count": enabled_count,
|
||||
"sms_type_total": len(type_labels),
|
||||
"phone_entries": customer_phone_entries(customer),
|
||||
# آمار (نوار بالای صفحه)
|
||||
"status_counts": status_counts,
|
||||
"stat_sent": status_counts.get(SMSQueue.Status.SENT, 0),
|
||||
# «در صف» = هر چیزی که هنوز ارسال نشده و منتظر اقدام/worker است.
|
||||
"stat_pending": (
|
||||
status_counts.get(SMSQueue.Status.PENDING, 0)
|
||||
+ status_counts.get(SMSQueue.Status.APPROVED, 0)
|
||||
+ status_counts.get(SMSQueue.Status.PROCESSING, 0)
|
||||
),
|
||||
"stat_failed": status_counts.get(SMSQueue.Status.FAILED, 0),
|
||||
}
|
||||
return render(request, "admin_panel/customerSmsHistory.html", data)
|
||||
|
||||
|
||||
+103
-46
@@ -437,36 +437,54 @@ def _valid_manual_holds(holds):
|
||||
# تاریخ آینده). سناریوی رایج «همین حالا تماس گرفتم و قرار دو هفتهٔ بعد
|
||||
# گذاشتم» تاریخ اقدام را «امروز» میذارد؛ اگر شرط آیندهبودن تاریخ اقدام را
|
||||
# بگذاریم، همین مورد (هدف اصلی این قابلیت) بیاثر میشد. پس فقط وضعیت مهم است.
|
||||
coordination_latest = dict(
|
||||
# «چه کسی جدیدتر است» بر اساس (createdDT، id) سنجیده میشود، نه فقط createdDT.
|
||||
# چرا: createdDT در SQLite/ویندوز دقت کافی ندارد و دو اقدام که در یک لحظه ثبت
|
||||
# شوند مقدار یکسان میگیرند؛ با مقایسهٔ صرفاً `>` آنگاه «بازدید بعد از
|
||||
# هماهنگی» تشخیص داده نمیشد و قرارِ مرده معتبر میماند. id یکتای افزایشی
|
||||
# همان ترتیب ثبت را میدهد، پس بهعنوان مرزِ تساوی استفاده میشود.
|
||||
coordination_latest = {
|
||||
cid: (last, last_id)
|
||||
for cid, last, last_id in (
|
||||
Action.objects.filter(
|
||||
customer_id__in=customer_ids,
|
||||
actionType=Action.ActionType.HAMAHANGI,
|
||||
status__in=(Action.Status.WAITE, Action.Status.FINISH),
|
||||
)
|
||||
.values("customer_id")
|
||||
.annotate(last=Max("createdDT"))
|
||||
.values_list("customer_id", "last")
|
||||
.annotate(last=Max("createdDT"), last_id=Max("id"))
|
||||
.values_list("customer_id", "last", "last_id")
|
||||
)
|
||||
}
|
||||
# آخرین فعالیتِ **جایگزینکنندهٔ قرار** (بازدید/نصب) برای هر مشتری.
|
||||
# عمداً فقط این دو نوع: یک سند وصول یا تماس ورودی نباید قرار را باطل کند.
|
||||
other_latest = dict(
|
||||
other_latest = {
|
||||
cid: (last, last_id)
|
||||
for cid, last, last_id in (
|
||||
Action.objects.filter(
|
||||
customer_id__in=customer_ids, actionType__in=_SUPERSEDING_TYPES,
|
||||
)
|
||||
.values("customer_id")
|
||||
.annotate(last=Max("createdDT"))
|
||||
.values_list("customer_id", "last")
|
||||
.annotate(last=Max("createdDT"), last_id=Max("id"))
|
||||
.values_list("customer_id", "last", "last_id")
|
||||
)
|
||||
}
|
||||
|
||||
valid = {}
|
||||
for customer_id, until in holds.items():
|
||||
if until is None:
|
||||
continue
|
||||
coordinated_at = coordination_latest.get(customer_id)
|
||||
if coordinated_at is None:
|
||||
coordination = coordination_latest.get(customer_id)
|
||||
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):
|
||||
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
|
||||
@@ -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`` و نه «هر بازدیدی»؟ بازدیدِ در انتظارِ بسیار قدیمی
|
||||
(backlog بستهنشده) قرار فعال نیست؛ آن مشتری هنوز نیاز به تماس دارد.
|
||||
"""
|
||||
return set(
|
||||
Action.objects.filter(
|
||||
actionType=Action.ActionType.BAZDID,
|
||||
actionType__in=_CYCLE_RESET_TYPES,
|
||||
status=Action.Status.WAITE,
|
||||
)
|
||||
.filter(
|
||||
@@ -547,8 +571,8 @@ def followups_on_date(target_date, *, now=None):
|
||||
امروز یا آینده — را میبیند تا کارشناس بتواند با تقویم عقب/جلو برود.
|
||||
|
||||
خروجی: ``{customer_id: {منبع: تاریخ}}`` مرتب بر اساس زودترین ساعت موعد.
|
||||
مشتریانی که قرار **بازدید** دارند، یا بازدیدشان از همان روز موعد به بعد
|
||||
**انجام شده**، در خروجی نیستند (فهرست، «کارِ باقیمانده» است نه آرشیو).
|
||||
مشتریانی که قرار **بازدید/نصب** دارند، یا بازدید/نصبشان از همان روز موعد به
|
||||
بعد **انجام شده**، در خروجی نیستند (فهرست، «کارِ باقیمانده» است نه آرشیو).
|
||||
"""
|
||||
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:
|
||||
bucket[SOURCE_COORDINATION] = when
|
||||
|
||||
# ۳) حذف مشتریانی که برایشان «بازدید» گذاشته شده است.
|
||||
# ۳) حذف مشتریانی که برایشان «بازدید» یا «نصب» گذاشته شده است.
|
||||
if result:
|
||||
for customer_id in _pending_visit_customer_ids(since=day_start):
|
||||
for customer_id in _pending_appointment_ids(since=day_start):
|
||||
result.pop(customer_id, None)
|
||||
|
||||
# ۴) حذف مشتریانی که بازدیدشان از همان روزِ موعد به بعد **انجام شده** است.
|
||||
# قاعدهٔ کسبوکار: اگر بازدید انجام شده، تماسِ آن روز لازم نیست. قبلاً فقط
|
||||
# بازدیدِ «در انتظار» دیده میشد، پس مشتریِ بازدیدشده باز هم در فهرست
|
||||
# ۴) حذف مشتریانی که بازدید/نصبشان از همان روزِ موعد به بعد **انجام شده** است.
|
||||
# قاعدهٔ کسبوکار: اگر بازدید (یا نصب) انجام شده، تماسِ آن روز لازم نیست. قبلاً
|
||||
# فقط بازدیدِ «در انتظار» دیده میشد، پس مشتریِ بازدیدشده باز هم در فهرست
|
||||
# «نیازمند پیگیری امروز» میماند (۹۲٪ ردیفهای دادهٔ واقعی).
|
||||
# لنگر از :func:`cycle_anchor_map` میآید — یعنی همان دو نوعی که چرخه را از نو
|
||||
# شروع میکنند (``bazdid``/``nasb``). برای ``bazdid`` خروجی عیناً همان مقدار
|
||||
# قبلی (``last_visit_map``) است، پس رفتار موجود تغییر نمیکند و «نصب» هم ردیف
|
||||
# را میبندد. عمداً ``last_visit_map`` گشاد نشد: آن «تاریخ آخرین بازدید» است
|
||||
# (ستون جدول، ``last_bazdid_dt`` و پیامک) و نباید نصب را بازدید حساب کند.
|
||||
# فقط برای امروز/آینده اعمال میشود تا مرور روزهای گذشته (تقویم) سندِ کاریِ
|
||||
# همان روز را با دادههای بعدی بازنویسی نکند.
|
||||
if result:
|
||||
now = _now(now)
|
||||
current_tz = timezone.get_current_timezone()
|
||||
if target_date >= now.astimezone(current_tz).date():
|
||||
visits = last_visit_map(now=now, customer_ids=list(result))
|
||||
for customer_id, visited_at in visits.items():
|
||||
anchors = cycle_anchor_map(now=now, customer_ids=list(result))
|
||||
for customer_id, done_at in anchors.items():
|
||||
bucket = result.get(customer_id)
|
||||
if not bucket or visited_at is None:
|
||||
if not bucket or done_at is None:
|
||||
continue
|
||||
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)
|
||||
|
||||
return result
|
||||
@@ -631,10 +660,10 @@ def overdue_followups(*, now=None, target_date=None):
|
||||
|
||||
* اگر مشتری هماهنگیِ **امروز یا آینده** داشته باشد، عقبافتاده نیست
|
||||
(موعدش تازه است).
|
||||
* اگر برایش **قرار بازدید** ثبت شده باشد، از فهرست خارج میشود — همان قاعدهٔ
|
||||
:func:`followups_on_date` («بازدید گذاشته شده ⇒ تماس لازم نیست»).
|
||||
* اگر بازدیدش **از همان موعد به بعد انجام شده** باشد، از فهرست خارج میشود
|
||||
(پیگیری با بازدید بسته شده است).
|
||||
* اگر برایش **قرار بازدید/نصب** ثبت شده باشد، از فهرست خارج میشود — همان
|
||||
قاعدهٔ :func:`followups_on_date` («بازدید/نصب گذاشته شده ⇒ تماس لازم نیست»).
|
||||
* اگر بازدید یا نصبش **از همان موعد به بعد انجام شده** باشد، از فهرست خارج
|
||||
میشود (پیگیری با بازدید/نصب بسته شده است).
|
||||
* اگر **قرار دستیِ معتبر** داشته باشد («تا تاریخ پیگیری نشود»)، از فهرست
|
||||
خارج میشود؛ چون خودِ کاربر صریحاً خواسته تا آن تاریخ تماس نگیریم.
|
||||
* هماهنگیِ لغوشده (``cancell``) اصلاً دیده نمیشود.
|
||||
@@ -669,10 +698,10 @@ def overdue_followups(*, now=None, target_date=None):
|
||||
if not overdue:
|
||||
return {}
|
||||
|
||||
# حذف مشتریانی که برایشان بازدید گذاشته شده است (قرار بازدید از آن روز).
|
||||
# «از آن روز» = زودترین موعدِ ازدسترفته؛ بازدیدی که بعد از آن گذاشته شده
|
||||
# تکلیف مشتری را روشن کرده است.
|
||||
pending_visits = _pending_visit_customer_ids(
|
||||
# حذف مشتریانی که برایشان بازدید/نصب گذاشته شده است (قرار از آن روز).
|
||||
# «از آن روز» = زودترین موعدِ ازدسترفته؛ بازدید/نصبی که بعد از آن گذاشته
|
||||
# شده تکلیف مشتری را روشن کرده است.
|
||||
pending_visits = _pending_appointment_ids(
|
||||
since=_local_day_bounds(min(overdue.values()))[0]
|
||||
)
|
||||
for customer_id in pending_visits:
|
||||
@@ -680,14 +709,16 @@ def overdue_followups(*, now=None, target_date=None):
|
||||
|
||||
current_tz = timezone.get_current_timezone()
|
||||
|
||||
# بازدیدِ **انجامشده** از همان موعد به بعد ⇒ این پیگیری انجام شده است.
|
||||
# بازدید/نصبِ **انجامشده** از همان موعد به بعد ⇒ این پیگیری انجام شده است.
|
||||
# قبلاً فقط بازدیدِ «در انتظار» دیده میشد و همین، فهرست «از روزهای قبل»
|
||||
# را پر از مشتریانی میکرد که بازدیدشان ثبت شده بود (۸۸٪ دادهٔ واقعی).
|
||||
visits = last_visit_map(now=now, customer_ids=list(overdue))
|
||||
for customer_id, visited_at in visits.items():
|
||||
if visited_at is None:
|
||||
# لنگر همان ``cycle_anchor_map`` است (``bazdid``/``nasb``) تا «نصب» هم مثل
|
||||
# بازدید پیگیری را ببندد.
|
||||
anchors = cycle_anchor_map(now=now, customer_ids=list(overdue))
|
||||
for customer_id, done_at in anchors.items():
|
||||
if done_at is None:
|
||||
continue
|
||||
if visited_at.astimezone(current_tz).date() >= overdue[customer_id]:
|
||||
if done_at.astimezone(current_tz).date() >= overdue[customer_id]:
|
||||
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`` هیچ ارجاعی به رکورد اقدام
|
||||
ندارد (فقط ``dedup_key`` متنی). برای جلوگیری از ارسال پیامکِ بازدیدی که
|
||||
دیگر وجود ندارد، پیامکهای معلق همان مشتری با نوع ``visit_created`` لغو
|
||||
میشوند. پیامکهای **ارسالشده** دست نمیخورند (سابقه است).
|
||||
|
||||
``sms_type`` (اختیاری): نوع پیامک مورد هدف؛ پیشفرض ``visit_created``
|
||||
(رفتار قبلی، که حذف اقدام از آن استفاده میکند). برای «نصب» مقدار
|
||||
``install_created`` داده میشود.
|
||||
|
||||
``action_id`` (اختیاری): فقط پیامکهای همان رکورد اقدام را لغو میکند. کلید
|
||||
یکتاسازی تریگر ``{sms_type}:{action_id}`` (یا با پسوند شماره) است؛ پس
|
||||
«ویرایش یک رکورد از بازدید به نوع دیگر» نباید پیامک بازدیدِ *رکورد دیگر*
|
||||
را از بین ببرد. پیشفرض (``None``) همهٔ پیامکهای معلق همان نوع را لغو
|
||||
میکند.
|
||||
|
||||
خروجی: تعداد رکوردهای لغوشده.
|
||||
"""
|
||||
# ایمپورت محلی: sms.models در زمان ایمپورت این ماژول ممکن است آماده نباشد.
|
||||
@@ -1024,16 +1065,24 @@ def cancel_pending_sms_for_action(customer, *, now=None):
|
||||
if customer is None or customer.pk is None:
|
||||
return 0
|
||||
now = _now(now)
|
||||
sms_type = sms_type or SMSType.VISIT_CREATED
|
||||
pending = (SMSQueue.Status.PENDING, SMSQueue.Status.APPROVED)
|
||||
return (
|
||||
query = (
|
||||
SMSQueue.objects.filter(
|
||||
customer_id=customer.pk,
|
||||
sms_type=SMSType.VISIT_CREATED,
|
||||
sms_type=sms_type,
|
||||
status__in=pending,
|
||||
)
|
||||
.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):
|
||||
@@ -1051,7 +1100,8 @@ def soft_delete_action(action, *, user=None, now=None):
|
||||
۱. ``last_bazdid_dt`` بازمحاسبه میشود (کش کهنه نماند).
|
||||
۲. قرار دستی اگر دیگر مرجعی نداشته باشد، خودکار بیاثر میشود
|
||||
(قاعدهٔ «ستون، مرجع نیست»).
|
||||
۳. پیامکهای **ارسالنشده**ی همان مشتری لغو میشوند.
|
||||
۳. پیامکهای **ارسالنشده**ی رویدادیِ همان مشتری لغو میشوند (``visit_created``
|
||||
و برای اقدام «نصب»، ``install_created``).
|
||||
|
||||
ممنوعیت: اقدامی که نظرسنجی ثبتشده دارد (``nazarDT`` پر است) حذف نمیشود؛
|
||||
زیرا هیچ آرشیو/کپیای وجود ندارد و گزارش رضایتسنجی برای همیشه تغییر میکرد.
|
||||
@@ -1081,6 +1131,13 @@ def soft_delete_action(action, *, user=None, now=None):
|
||||
if customer is not None:
|
||||
refresh_last_bazdid(customer, reason=REASON_CANCEL, user=user, now=now)
|
||||
cancelled = cancel_pending_sms_for_action(customer, now=now)
|
||||
# اقدام «نصب» پیامک رویدادیِ خودش را هم دارد (نوع جدا)؛ حذف آن باید
|
||||
# پیامک معلقش را هم لغو کند، وگرنه پیامک «نصب ثبت شد» بیمرجع میماند.
|
||||
if action.actionType == Action.ActionType.NASB:
|
||||
from .models import SMSType
|
||||
cancelled += cancel_pending_sms_for_action(
|
||||
customer, sms_type=SMSType.INSTALL_CREATED, now=now,
|
||||
)
|
||||
else:
|
||||
cancelled = 0
|
||||
|
||||
|
||||
@@ -158,7 +158,8 @@ customerActionInc.html
|
||||
| نوع | زمان ارسال | تصمیمگیر | دادههای دخیل |
|
||||
|---|---|---|---|
|
||||
| `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`)
|
||||
|
||||
@@ -171,6 +172,7 @@ customerActionInc.html
|
||||
| هماهنگی با تاریخ +۵ روز (بدون تعویق) | ⛔ | — |
|
||||
| هماهنگی «انجامشده» با تاریخ گذشته و بدون تعویق | ✅ ارسال | — |
|
||||
| ثبت اقدام بازدید (هر وضعیتی) | — | ✅ با `{date}` = `actionDateTime` |
|
||||
| ثبت اقدام نصب (هر وضعیتی) | — | ✅ `install_created`، مستقل از `visit_created` |
|
||||
| ثبت اقدام هماهنگی | — | ❌ |
|
||||
| قرار دستی فعال + ثبت بازدید | — | ✅ (فیلد دوم مانع نیست) |
|
||||
|
||||
@@ -181,11 +183,13 @@ customerActionInc.html
|
||||
|
||||
### ۴.۳ چرا «تعویق» روی `visit_created` اثر ندارد؟
|
||||
|
||||
چون این پیامک در `views/actions.py:170-171` فقط برای `actionType == bazdid` صدا زده
|
||||
میشود، و اصلاً از `next_appointment_map` عبور نمیکند. یعنی اگر هماهنگی بگذارید و بعد
|
||||
بازدیدی ثبت کنید، پیامکِ «بازدید شما برای تاریخ … ثبت شد» میرود حتی اگر «تا تاریخ
|
||||
پیگیری نشود» فعال باشد. (اگر رفتار مطلوب چیز دیگری است، باید در `notify_visit_scheduled`
|
||||
یا در شرط فراخوانی آن تغییر کند.)
|
||||
چون این پیامک در ویوهای اقدام (`views/actions.py`، تابع `_sync_action_event_sms`) فقط
|
||||
برای `actionType == bazdid` صدا زده میشود، و اصلاً از `next_appointment_map` عبور
|
||||
نمیکند. یعنی اگر هماهنگی بگذارید و بعد بازدیدی ثبت کنید، پیامکِ «بازدید شما برای
|
||||
تاریخ … ثبت شد» میرود حتی اگر «تا تاریخ پیگیری نشود» فعال باشد. همین قاعده برای
|
||||
`install_created` (اقدام از نوع نصب) هم برقرار است. (اگر رفتار مطلوب چیز دیگری است،
|
||||
باید در `notify_visit_scheduled`/`notify_install_scheduled` یا در شرط فراخوانی آنها
|
||||
تغییر کند.)
|
||||
|
||||
### ۴.۴ جزئیات مهم `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 |
|
||||
| B7 | فنی | هر GET صفحهٔ انتظار، `CacheTable` را مینوشت (حتی بدون تغییر) | `save()` بیقید در ویو | فقط در صورت تغییر + `update_fields` |
|
||||
| B8 | فنی (تست) | تست `test_overdue_column_hidden_only_in_followup_view` به ساعت اجرا وابسته بود | هماهنگی «امروز ساعت ۱۲» میتوانست «آینده» شمرده شود | تست با مشتری بدون قرار، مستقل از ساعت |
|
||||
| B9 | کسبوکار | اقدام **«نصب»** ردیف را از فهرستهای کار (`?view=today` و `?view=today&missed=1`) حذف نمیکرد؛ فقط «بازدید» حذف میکرد | `followups_on_date`/`overdue_followups` در دو مرحلهٔ «قرار در انتظار» و «انجامشده» فقط `bazdid` را میدیدند (`_pending_visit_customer_ids` و `last_visit_map`) | آن دو مرحله به `_pending_appointment_ids` (با `_CYCLE_RESET_TYPES` = `bazdid`/`nasb`) و `cycle_anchor_map` منتقل شدند؛ `last_visit_map` دستنخورده ماند |
|
||||
| B10 | رویداد | تغییر نوع یک اقدام از «هماهنگی» به «بازدید» در فرم **ویرایش**، پیامک `visit_created` نمیفرستاد | تریگر `notify_visit_scheduled` فقط در `customerNewAction` صدا زده میشد (یک فراخوان در کل کد) | در `customerEditAction` گذار نوع بررسی میشود: غیربازدید → بازدید ⇒ تریگر؛ بازدید → غیربازدید ⇒ `cancel_pending_sms_for_action(action_id=…)`. تکرار با `dedup_key=visit_created:{action.pk}` مهار میشود |
|
||||
| F1 | قابلیت | نوع پیامک «ثبت نصب» (اقدام از نوع نصب) وجود نداشت | فرمهای اقدام از نوع نصب هیچ تریگر پیامکی نداشتند | `SMSType.INSTALL_CREATED` + `notify_install_scheduled` + قالب قابل ویرایش در `/panel/sms/templates` + تریگر در هر دو ویوی ایجاد/ویرایش (مهاجرت ۰۰۸۱) |
|
||||
|
||||
> **F1 — قیدهای ایمنی مهاجرت ۰۰۸۱:** مهاجرت فقط `choices` را گسترش میدهد
|
||||
> (`AlterField` = متادیتا، بدون تغییر داده) و **یک** ردیف تازه با
|
||||
> `get_or_create` میسازد. هیچ `update`/`save` روی قالبهای موجود ندارد؛ اگر قالب
|
||||
> `install_created` از قبل باشد هم دست نمیخورد. قالب تازه:
|
||||
> `is_enabled=True` (در پنل دیده شود) و **`auto_send=False`** ⇒ پیامکها در صف
|
||||
> «در انتظار تأیید» میمانند تا مدیر خودش تأیید کند. تستهای نگهبان:
|
||||
> `InstallTemplateMigrationTests` و `test_install_template_is_seeded_with_auto_send_off`
|
||||
> در `tests/test_sms.py`.
|
||||
|
||||
> **B9 — جزئیات (۲۰۲۶-۱۰-۲۷):** «نصب» از قبل در `_SUPERSEDING_TYPES` (ابطال قرار
|
||||
> دستی) و `_CYCLE_RESET_TYPES` (ریست چرخهٔ ۹ ماهه در اسکنر پیامک) بود، ولی فهرست
|
||||
> کار روزانه آن را نمیدید. حالا:
|
||||
>
|
||||
> * `nasb + waite` با تاریخ از آغاز روز مورد نظر به بعد ⇒ ردیف پنهان میشود
|
||||
> (همان قاعدهٔ `bazdid + waite`؛ «قرار امروز» تا پایان امروز معتبر است).
|
||||
> نصبِ در انتظارِ **قدیمیتر** از آن روز، قرار فعال نیست و ردیف را باز نمیگذارد.
|
||||
> * `nasb + finish` با تاریخ مؤثر از موعد به بعد ⇒ ردیف بسته میشود (همان قاعدهٔ
|
||||
> `bazdid + finish`).
|
||||
> * `last_visit_map` (تاریخ آخرین **بازدید**، ستون جدول، `last_bazdid_dt` و پیامک)
|
||||
> عمداً تغییر نکرد؛ «نصب» فقط لنگر چرخه است، نه بازدید.
|
||||
|
||||
### ۹.۳ اثر روی دادهٔ واقعی (همان نسخهٔ پشتیبان)
|
||||
|
||||
|
||||
@@ -64,9 +64,16 @@
|
||||
| حذف اقدام هماهنگی | قرار **خودکار بیاثر** میشود |
|
||||
| لغو هماهنگی (`cancell`) | بیاثر میشود |
|
||||
| ویرایش تاریخ هماهنگی | قرار با تاریخ جدید همراستا میشود |
|
||||
| **ثبت بازدید (یا هر اقدام دیگر) پس از هماهنگی** | قرار **خودکار بیاثر** میشود |
|
||||
| **ثبت بازدید یا نصب پس از هماهنگی** | قرار **خودکار بیاثر** میشود |
|
||||
| ثبت سایر اقدامها (وصول، تماس…) | قرار دستنخورده میماند |
|
||||
| کهنه ماندن ستون | **هیچ اثر مخربی ندارد** |
|
||||
|
||||
> **تکمیل ۲۰۲۶-۱۰-۲۷:** «نصب» در فهرستهای کار هم مثل «بازدید» عمل میکند:
|
||||
> `nasb + waite` با تاریخ از امروز به بعد و `nasb + finish` از موعد به بعد، ردیف را
|
||||
> از `?view=today` و `?view=today&missed=1` خارج میکند
|
||||
> (`visit_policy._pending_appointment_ids` و `cycle_anchor_map`). این دو نوع همان
|
||||
> `_CYCLE_RESET_TYPES` هستند که چرخهٔ ۹ ماهه و قرار دستی را هم بازنشانی میکنند.
|
||||
|
||||
به همین دلیل دیگر خطر «کش کهنه» وجود ندارد: منبع حقیقت همیشه جدول `Action` است.
|
||||
|
||||
---
|
||||
|
||||
@@ -368,6 +368,25 @@ class AppointmentLifetimeSimulationTests(SimulationBase):
|
||||
make_action(self.customer, 'nasb', 'finish', action_dt=NOW, finish_dt=NOW)
|
||||
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):
|
||||
"""فهرست «نیازمند پیگیری امروز»: کارِ باقیمانده، نه آرشیو."""
|
||||
@@ -397,6 +416,45 @@ class FollowupDaySimulationTests(SimulationBase):
|
||||
make_action(self.customer, 'bazdid', 'finish', action_dt=NOW, finish_dt=NOW)
|
||||
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):
|
||||
"""«لیست وظایف امروز»: مرزهای روز به وقت محلی و aware.
|
||||
@@ -744,8 +802,9 @@ class MissedViewSimulationTests(SimulationBase):
|
||||
"""رفتار فهرست ``?view=today&missed=1`` پس از رفع باگ.
|
||||
|
||||
قاعدهٔ کسبوکار: این فهرست فقط «پیگیریهای واقعاً باقیمانده» را نشان میدهد.
|
||||
هر مشتری که (الف) بازدیدش از موعد به بعد انجام شده، یا (ب) قرار دستیِ معتبر
|
||||
دارد، یا (ج) بازدیدِ در انتظار از آن روز دارد، از فهرست خارج میشود.
|
||||
هر مشتری که (الف) بازدید یا نصبش از موعد به بعد انجام شده، یا (ب) قرار
|
||||
دستیِ معتبر دارد، یا (ج) بازدید/نصبِ در انتظار از آن روز دارد، از فهرست
|
||||
خارج میشود.
|
||||
"""
|
||||
|
||||
URL = '/panel/entezar/?view=today&missed=1'
|
||||
@@ -807,6 +866,25 @@ class MissedViewSimulationTests(SimulationBase):
|
||||
ids, _ = self.missed()
|
||||
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):
|
||||
"""بازدیدِ در انتظارِ قدیمیتر از موعد، قرار فعال نیست ⇒ در missed میماند."""
|
||||
make_action(self.customer, 'hamahangi', 'finish',
|
||||
|
||||
+416
-4
@@ -2177,14 +2177,14 @@ class EventTriggerTests(PanelTestCase):
|
||||
mark_primary(customer, '09120000000')
|
||||
return customer
|
||||
|
||||
# ---------- قرارداد: دقیقاً ۷ نوع ----------
|
||||
# ---------- قرارداد: دقیقاً ۸ نوع ----------
|
||||
|
||||
def test_exactly_seven_sms_types(self):
|
||||
self.assertEqual(len(SMSType.choices), 7)
|
||||
def test_exactly_eight_sms_types(self):
|
||||
self.assertEqual(len(SMSType.choices), 8)
|
||||
self.assertEqual(
|
||||
{code for code, _label in SMSType.choices},
|
||||
{'welcome', 'survey', 'cheque_registration', 'visit',
|
||||
'visit_created', 'payment_thanks', 'nesiye_due'},
|
||||
'visit_created', 'install_created', 'payment_thanks', 'nesiye_due'},
|
||||
)
|
||||
|
||||
def test_removed_types_have_no_template(self):
|
||||
@@ -2198,6 +2198,15 @@ class EventTriggerTests(PanelTestCase):
|
||||
seeded = set(SMSTemplate.objects.values_list('sms_type', flat=True))
|
||||
self.assertEqual(seeded, {code for code, _label in SMSType.choices})
|
||||
|
||||
def test_install_template_is_seeded_with_auto_send_off(self):
|
||||
"""قالب «ثبت نصب» فعال است ولی «ارسال خودکار» آن خاموش (نیازمند تأیید)."""
|
||||
template = SMSTemplate.objects.get(sms_type=SMSType.INSTALL_CREATED)
|
||||
self.assertTrue(template.is_enabled)
|
||||
self.assertFalse(template.auto_send)
|
||||
self.assertEqual(template.offset_days, 0)
|
||||
self.assertIn('{name}', template.text)
|
||||
self.assertIn('{date}', template.text)
|
||||
|
||||
# ---------- نظرسنجی + تشکر از پرداخت نقدی روی فاکتور ----------
|
||||
|
||||
def _post_factor(self, customer, payments):
|
||||
@@ -2284,6 +2293,244 @@ class EventTriggerTests(PanelTestCase):
|
||||
SMSQueue.objects.filter(sms_type=SMSType.VISIT_CREATED).exists(),
|
||||
)
|
||||
|
||||
# ---------- تبدیل نوع اقدام در «ویرایش» ----------
|
||||
# باگ گزارششده: تریگر «بازدید ثبت شد» فقط در ویوی ایجاد صدا زده میشد، پس
|
||||
# تبدیل یک اقدام «هماهنگی» به «بازدید» هیچ پیامکی نمیفرستاد.
|
||||
|
||||
def _visit_sms(self, customer):
|
||||
"""همهٔ ردیفهای «ثبت بازدید» (هر وضعیتی) — برای شمارش کل."""
|
||||
return SMSQueue.objects.filter(customer=customer,
|
||||
sms_type=SMSType.VISIT_CREATED)
|
||||
|
||||
def _pending_visit_sms(self, customer):
|
||||
"""فقط ردیفهایی که worker ممکن است بفرستد (در انتظار/تأییدشده)."""
|
||||
return self._visit_sms(customer).filter(
|
||||
status__in=(SMSQueue.Status.PENDING, SMSQueue.Status.APPROVED),
|
||||
)
|
||||
|
||||
def _edit_action(self, customer, action_type, *, is_done=True):
|
||||
"""ویرایش اولین اقدام مشتری با نوع جدید (از مسیر واقعی ویو)."""
|
||||
from admin_panel.models import Action
|
||||
|
||||
action = Action.objects.filter(customer=customer).order_by('id').first()
|
||||
url = reverse('admin_panel:customerEditAction',
|
||||
kwargs={'c_id': customer.id, 'action_id': action.id})
|
||||
payload = {
|
||||
'actionType': action_type,
|
||||
'dateTimeAnjam': '1700000000000',
|
||||
'desc': 'ویرایش', 'userID': self.user.id,
|
||||
'FinishDateTime': '1700000000000',
|
||||
}
|
||||
if is_done:
|
||||
payload['is_done'] = 'on'
|
||||
response = self.client.post(
|
||||
url, payload,
|
||||
HTTP_REFERER=reverse('admin_panel:customerAction',
|
||||
kwargs={'c_id': customer.id}),
|
||||
)
|
||||
action.refresh_from_db()
|
||||
return response, action
|
||||
|
||||
def test_editing_coordination_to_bazdid_enqueues_visit_created(self):
|
||||
"""هماهنگی → بازدید در ویرایش ⇒ پیامک «ثبت بازدید» باید ساخته شود."""
|
||||
customer = self._customer_with_phone()
|
||||
self._post_action(customer, 'hamahangi')
|
||||
self.assertEqual(self._visit_sms(customer).count(), 0)
|
||||
|
||||
response, action = self._edit_action(customer, 'bazdid')
|
||||
self.assertEqual(response.status_code, 302)
|
||||
self.assertEqual(action.actionType, 'bazdid')
|
||||
self.assertEqual(self._visit_sms(customer).count(), 1)
|
||||
# کلید یکتاسازی همان رکورد اقدام است (پس ویرایش دوباره تکرار نمیکند).
|
||||
self.assertEqual(self._visit_sms(customer).first().dedup_key,
|
||||
'visit_created:%d' % action.id)
|
||||
|
||||
def test_editing_to_bazdid_twice_does_not_duplicate_sms(self):
|
||||
"""ویرایش دوبارهٔ همان رکورد نباید پیامک دوم بسازد (dedup روی action)."""
|
||||
customer = self._customer_with_phone()
|
||||
self._post_action(customer, 'hamahangi')
|
||||
self._edit_action(customer, 'bazdid')
|
||||
# برگشت به هماهنگی و تبدیل دوباره به بازدید: پیامک معلق لغو شده، ولی
|
||||
# کلید دفتر dedup خورده است ⇒ پیامک تازه ساخته نمیشود.
|
||||
self._edit_action(customer, 'hamahangi')
|
||||
self._edit_action(customer, 'bazdid')
|
||||
self.assertEqual(self._visit_sms(customer).count(), 1)
|
||||
|
||||
def test_editing_bazdid_to_other_type_cancels_pending_sms(self):
|
||||
"""بازدید → هماهنگی در ویرایش ⇒ پیامک معلق «ثبت بازدید» لغو میشود."""
|
||||
customer = self._customer_with_phone()
|
||||
self._post_action(customer, 'bazdid')
|
||||
self.assertEqual(self._pending_visit_sms(customer).count(), 1)
|
||||
|
||||
self._edit_action(customer, 'hamahangi')
|
||||
# ردیف حذف نمیشود (سابقه میماند) ولی دیگر ارسالشدنی نیست.
|
||||
self.assertEqual(self._pending_visit_sms(customer).count(), 0)
|
||||
self.assertEqual(
|
||||
self._visit_sms(customer).first().status, SMSQueue.Status.CANCELLED,
|
||||
)
|
||||
|
||||
def test_editing_other_fields_of_bazdid_sends_no_new_sms(self):
|
||||
"""ویرایش عادی یک اقدام بازدید (بدون تغییر نوع) پیامک تازه نمیفرستد."""
|
||||
customer = self._customer_with_phone()
|
||||
self._post_action(customer, 'bazdid')
|
||||
self.assertEqual(self._visit_sms(customer).count(), 1)
|
||||
self._edit_action(customer, 'bazdid')
|
||||
self.assertEqual(self._visit_sms(customer).count(), 1)
|
||||
|
||||
def test_editing_bazdid_keeps_other_actions_pending_sms(self):
|
||||
"""لغو پیامک معلق، فقط رکورد ویرایششده را هدف میگیرد (نه بقیهٔ بازدیدها)."""
|
||||
from admin_panel.models import Action
|
||||
from admin_panel.sms.triggers import notify_visit_scheduled
|
||||
|
||||
customer = self._customer_with_phone()
|
||||
self._post_action(customer, 'bazdid') # رکورد ۱ + پیامک
|
||||
first = Action.objects.filter(customer=customer).order_by('id').first()
|
||||
second = Action.objects.create(
|
||||
customer=customer, actionType='bazdid', status='finish',
|
||||
actionDateTime=timezone.now(), finishDateTime=timezone.now(),
|
||||
)
|
||||
notify_visit_scheduled(customer, second, created_by=self.user) # پیامک رکورد ۲
|
||||
self.assertEqual(self._pending_visit_sms(customer).count(), 2)
|
||||
|
||||
# رکورد دوم را از بازدید به هماهنگی تغییر میدهیم ⇒ فقط پیامک رکورد ۲ لغو شود.
|
||||
url = reverse('admin_panel:customerEditAction',
|
||||
kwargs={'c_id': customer.id, 'action_id': second.id})
|
||||
self.client.post(url, {
|
||||
'actionType': 'hamahangi', 'dateTimeAnjam': '1700000000000',
|
||||
'FinishDateTime': '1700000000000', 'desc': '', 'userID': self.user.id,
|
||||
'is_done': 'on',
|
||||
}, HTTP_REFERER=reverse('admin_panel:customerAction',
|
||||
kwargs={'c_id': customer.id}))
|
||||
|
||||
keys = set(self._pending_visit_sms(customer).values_list('dedup_key', flat=True))
|
||||
self.assertEqual(keys, {'visit_created:%d' % first.id})
|
||||
|
||||
# ---------- ثبت اقدام از نوع «نصب» ----------
|
||||
|
||||
def _install_sms(self, customer):
|
||||
"""همهٔ ردیفهای «ثبت نصب» (هر وضعیتی)."""
|
||||
return SMSQueue.objects.filter(customer=customer,
|
||||
sms_type=SMSType.INSTALL_CREATED)
|
||||
|
||||
def _pending_install_sms(self, customer):
|
||||
return self._install_sms(customer).filter(
|
||||
status__in=(SMSQueue.Status.PENDING, SMSQueue.Status.APPROVED),
|
||||
)
|
||||
|
||||
def test_nasb_action_enqueues_install_created(self):
|
||||
"""اقدام «نصب» ⇒ پیامک «ثبت نصب» از قالب install_created."""
|
||||
customer = self._customer_with_phone()
|
||||
response = self._post_action(customer, 'nasb')
|
||||
self.assertIn(response.status_code, (200, 302))
|
||||
item = self._install_sms(customer)
|
||||
self.assertEqual(item.count(), 1)
|
||||
self.assertIn('نصب', item.first().message)
|
||||
# auto_send قالب خاموش است ⇒ ردیف «در انتظار تأیید» میماند، نه ارسال فوری.
|
||||
self.assertEqual(item.first().status, SMSQueue.Status.PENDING)
|
||||
# پیامک بازدید ساخته نمیشود (دو نوع مستقل).
|
||||
self.assertEqual(self._pending_visit_sms(customer).count(), 0)
|
||||
|
||||
def test_nasb_action_sms_has_its_own_dedup_key(self):
|
||||
"""کلید یکتاسازی با پیشوند نوع خودش است (با visit_created قاطی نمیشود)."""
|
||||
from admin_panel.models import Action
|
||||
|
||||
customer = self._customer_with_phone()
|
||||
self._post_action(customer, 'nasb')
|
||||
action = Action.objects.filter(customer=customer).order_by('id').first()
|
||||
self.assertEqual(self._install_sms(customer).first().dedup_key,
|
||||
'install_created:%d' % action.id)
|
||||
|
||||
def test_non_nasb_action_has_no_install_created(self):
|
||||
customer = self._customer_with_phone()
|
||||
self._post_action(customer, 'bazdid')
|
||||
self.assertFalse(self._install_sms(customer).exists())
|
||||
|
||||
def test_editing_to_nasb_enqueues_install_created(self):
|
||||
"""هماهنگی → نصب در ویرایش ⇒ پیامک «ثبت نصب» ساخته میشود."""
|
||||
customer = self._customer_with_phone()
|
||||
self._post_action(customer, 'hamahangi')
|
||||
self.assertEqual(self._install_sms(customer).count(), 0)
|
||||
|
||||
response, action = self._edit_action(customer, 'nasb')
|
||||
self.assertEqual(response.status_code, 302)
|
||||
self.assertEqual(action.actionType, 'nasb')
|
||||
self.assertEqual(self._pending_install_sms(customer).count(), 1)
|
||||
|
||||
def test_editing_nasb_to_other_type_cancels_pending_install_sms(self):
|
||||
"""نصب → هماهنگی در ویرایش ⇒ پیامک معلق «ثبت نصب» لغو میشود."""
|
||||
customer = self._customer_with_phone()
|
||||
self._post_action(customer, 'nasb')
|
||||
self.assertEqual(self._pending_install_sms(customer).count(), 1)
|
||||
|
||||
self._edit_action(customer, 'hamahangi')
|
||||
self.assertEqual(self._pending_install_sms(customer).count(), 0)
|
||||
self.assertEqual(
|
||||
self._install_sms(customer).first().status, SMSQueue.Status.CANCELLED,
|
||||
)
|
||||
|
||||
def test_editing_bazdid_to_nasb_switches_sms_type(self):
|
||||
"""بازدید → نصب ⇒ پیامک بازدید لغو و پیامک نصب ساخته میشود (نه هر دو)."""
|
||||
customer = self._customer_with_phone()
|
||||
self._post_action(customer, 'bazdid')
|
||||
self.assertEqual(self._pending_visit_sms(customer).count(), 1)
|
||||
|
||||
self._edit_action(customer, 'nasb')
|
||||
self.assertEqual(self._pending_visit_sms(customer).count(), 0)
|
||||
self.assertEqual(self._pending_install_sms(customer).count(), 1)
|
||||
|
||||
def test_install_template_can_be_disabled_from_templates_page(self):
|
||||
"""تنظیمات «ثبت نصب» از صفحهٔ قالبها: غیرفعالکردن ⇒ پیامکی ساخته نمیشود."""
|
||||
template = SMSTemplate.objects.get(sms_type=SMSType.INSTALL_CREATED)
|
||||
response = self.client.post(
|
||||
reverse('admin_panel:sms_template_edit', kwargs={'t_id': template.id}),
|
||||
{'text': template.text, 'title': template.title},
|
||||
)
|
||||
self.assertEqual(response.status_code, 302)
|
||||
template.refresh_from_db()
|
||||
self.assertFalse(template.is_enabled)
|
||||
|
||||
customer = self._customer_with_phone()
|
||||
self._post_action(customer, 'nasb')
|
||||
self.assertFalse(self._install_sms(customer).exists())
|
||||
|
||||
def test_install_template_text_is_editable_from_templates_page(self):
|
||||
"""ویرایش متن قالب از پنل ⇒ همان متن در پیامک ساختهشده میآید."""
|
||||
template = SMSTemplate.objects.get(sms_type=SMSType.INSTALL_CREATED)
|
||||
response = self.client.post(
|
||||
reverse('admin_panel:sms_template_edit', kwargs={'t_id': template.id}),
|
||||
{'text': '{name} عزیز، نصب دستگاه انجام شد ({date}).',
|
||||
'title': 'ثبت نصب', 'is_enabled': 'on'},
|
||||
)
|
||||
self.assertEqual(response.status_code, 302)
|
||||
|
||||
customer = self._customer_with_phone()
|
||||
self._post_action(customer, 'nasb')
|
||||
message = self._install_sms(customer).first().message
|
||||
self.assertIn('نصب دستگاه انجام شد', message)
|
||||
self.assertIn(customer.name_family, message)
|
||||
|
||||
def test_install_sms_disabled_per_customer_is_not_created(self):
|
||||
"""opt-out مشتری برای نوع install_created رعایت میشود."""
|
||||
from admin_panel.models import CustomerSMSSetting
|
||||
|
||||
customer = self._customer_with_phone()
|
||||
CustomerSMSSetting.objects.create(
|
||||
customer=customer, sms_type=SMSType.INSTALL_CREATED, is_enabled=False,
|
||||
)
|
||||
self._post_action(customer, 'nasb')
|
||||
self.assertFalse(self._install_sms(customer).exists())
|
||||
|
||||
def test_install_template_can_be_enabled_for_auto_send(self):
|
||||
"""اگر مدیر «ارسال خودکار» را روشن کند، ردیف مستقیماً تأییدشده ساخته میشود."""
|
||||
SMSTemplate.objects.filter(sms_type=SMSType.INSTALL_CREATED).update(
|
||||
auto_send=True,
|
||||
)
|
||||
customer = self._customer_with_phone()
|
||||
self._post_action(customer, 'nasb')
|
||||
self.assertEqual(
|
||||
self._install_sms(customer).first().status, SMSQueue.Status.APPROVED,
|
||||
)
|
||||
|
||||
# ---------- تشکر از پرداخت (وصول چک، نسیه، سند دستی) ----------
|
||||
|
||||
def test_pass_check_enqueues_payment_thanks(self):
|
||||
@@ -2372,6 +2619,85 @@ class EventTriggerTests(PanelTestCase):
|
||||
)
|
||||
|
||||
|
||||
class InstallTemplateMigrationTests(TestCase):
|
||||
"""مهاجرت ۰۰۸۱: قالبهای موجود دستنخورده میمانند، «ثبت نصب» بدون ارسال خودکار.
|
||||
|
||||
توابع مهاجرت مستقیم و با همان ``apps`` واقعی صدا زده میشوند (بدون اجرای
|
||||
schema)، تا در تراکنش تست انجام و بازگردانی شود.
|
||||
"""
|
||||
|
||||
MODULE = 'admin_panel.migrations.0081_sms_type_install_created'
|
||||
FIELDS = ('sms_type', 'title', 'text', 'is_enabled', 'auto_send',
|
||||
'offset_days', 'repeat_interval_days', 'repeat_interval_months')
|
||||
|
||||
class _Apps:
|
||||
"""جایگزین ``schema_editor.apps``: نگاشت به مدلهای واقعی پروژه."""
|
||||
|
||||
@staticmethod
|
||||
def get_model(app_label, model_name):
|
||||
from django.apps import apps as real_apps
|
||||
return real_apps.get_model(app_label, model_name)
|
||||
|
||||
def _migration(self):
|
||||
from importlib import import_module
|
||||
return import_module(self.MODULE)
|
||||
|
||||
def _snapshot(self):
|
||||
return {row['sms_type']: row
|
||||
for row in SMSTemplate.objects.values(*self.FIELDS)}
|
||||
|
||||
def _seed(self):
|
||||
self._migration().seed_install_template(self._Apps, None)
|
||||
|
||||
def test_seed_leaves_existing_templates_untouched(self):
|
||||
SMSTemplate.objects.filter(sms_type=SMSType.INSTALL_CREATED).delete()
|
||||
# مقادیر غیرپیشفرض؛ اگر مهاجرت هر فیلدی را بازنویسی کند دیده میشود.
|
||||
(SMSTemplate.objects.filter(sms_type=SMSType.VISIT).update(
|
||||
title='عنوان دستی', text='متن دستی {name}', is_enabled=False,
|
||||
auto_send=True, offset_days=3, repeat_interval_months=6,
|
||||
))
|
||||
before = self._snapshot()
|
||||
|
||||
self._seed()
|
||||
|
||||
after = self._snapshot()
|
||||
for sms_type, row in before.items():
|
||||
self.assertEqual(after[sms_type], row, msg=sms_type)
|
||||
self.assertIn(SMSType.INSTALL_CREATED, after)
|
||||
|
||||
def test_seeded_install_template_has_auto_send_off(self):
|
||||
SMSTemplate.objects.filter(sms_type=SMSType.INSTALL_CREATED).delete()
|
||||
self._seed()
|
||||
template = SMSTemplate.objects.get(sms_type=SMSType.INSTALL_CREATED)
|
||||
self.assertTrue(template.is_enabled, msg='باید در پنل قالبها دیده شود')
|
||||
self.assertFalse(template.auto_send, msg='نباید بدون تأیید مدیر ارسال شود')
|
||||
self.assertEqual(template.offset_days, 0)
|
||||
self.assertIsNone(template.repeat_interval_days)
|
||||
self.assertIsNone(template.repeat_interval_months)
|
||||
|
||||
def test_seed_is_idempotent_for_an_existing_install_template(self):
|
||||
"""اگر قالب از قبل وجود داشته باشد، متن/تنظیماتش بازنویسی نمیشود."""
|
||||
template = SMSTemplate.objects.get(sms_type=SMSType.INSTALL_CREATED)
|
||||
SMSTemplate.objects.filter(pk=template.pk).update(
|
||||
text='متن ویرایششدهٔ مدیر {name}', auto_send=True, offset_days=5,
|
||||
)
|
||||
before = self._snapshot()
|
||||
|
||||
self._seed()
|
||||
|
||||
self.assertEqual(self._snapshot(), before)
|
||||
|
||||
def test_reverse_only_removes_the_new_type(self):
|
||||
before = self._snapshot()
|
||||
self._migration().drop_install_template(self._Apps, None)
|
||||
after = self._snapshot()
|
||||
|
||||
self.assertNotIn(SMSType.INSTALL_CREATED, after)
|
||||
self.assertEqual(set(after), set(before) - {SMSType.INSTALL_CREATED})
|
||||
for sms_type, row in after.items():
|
||||
self.assertEqual(row, before[sms_type], msg=sms_type)
|
||||
|
||||
|
||||
class CustomerSmsHistoryTests(PanelTestCase):
|
||||
"""تب «سوابق پیامک» در صفحهٔ مشتری: متن دقیق، تاریخ شمسی، وضعیت تحویل، صفحهبندی."""
|
||||
|
||||
@@ -2468,6 +2794,92 @@ class CustomerSmsHistoryTests(PanelTestCase):
|
||||
html = self.client.get(self._url()).content.decode('utf-8')
|
||||
self.assertIn('پیامکی برای این مشتری ثبت نشده است', html)
|
||||
|
||||
# ---------- انتقال تنظیمات پیامک از صفحهٔ فاکتور به این صفحه ----------
|
||||
|
||||
def test_factor_page_no_longer_contains_sms_settings_box(self):
|
||||
"""باکس «پیامک» از صفحهٔ ایجاد فاکتور حذف شده (تنظیمات به تب پیامک رفت)."""
|
||||
html = self.client.get(
|
||||
reverse('admin_panel:customerNew', kwargs={'c_id': self.customer.id}),
|
||||
).content.decode('utf-8')
|
||||
self.assertNotIn(
|
||||
reverse('admin_panel:sms_send_custom', kwargs={'c_id': self.customer.id}),
|
||||
html,
|
||||
)
|
||||
self.assertNotIn(
|
||||
reverse('admin_panel:sms_customer_toggle', kwargs={'c_id': self.customer.id}),
|
||||
html,
|
||||
)
|
||||
|
||||
def test_sms_page_holds_all_settings_controls(self):
|
||||
"""همهٔ کنترلهای تنظیمات روی صفحهٔ پیامک هستند: نوعها، شمارهٔ اصلی، ارسال."""
|
||||
html = self.client.get(self._url()).content.decode('utf-8')
|
||||
self.assertIn(
|
||||
reverse('admin_panel:sms_customer_toggle', kwargs={'c_id': self.customer.id}),
|
||||
html,
|
||||
)
|
||||
self.assertIn(
|
||||
reverse('admin_panel:sms_primary_toggle', kwargs={'c_id': self.customer.id}),
|
||||
html,
|
||||
)
|
||||
self.assertIn(
|
||||
reverse('admin_panel:sms_send_custom', kwargs={'c_id': self.customer.id}),
|
||||
html,
|
||||
)
|
||||
# یک ردیف برای هر نوع پیامک (۸ نوع) و یک کلید for each.
|
||||
self.assertEqual(html.count('name="sms_type"'), len(SMSType.choices))
|
||||
self.assertEqual(html.count('role="switch"'), len(SMSType.choices))
|
||||
|
||||
def test_settings_live_in_modals_not_in_history_flow(self):
|
||||
"""تنظیمات داخل مودال است تا فهرست سوابق شلوغ نشود."""
|
||||
html = self.client.get(self._url()).content.decode('utf-8')
|
||||
self.assertIn('id="smsSettingsModal"', html)
|
||||
self.assertIn('id="smsCustomModal"', html)
|
||||
self.assertIn('js-csmsx-open', html)
|
||||
|
||||
def test_stats_are_computed_for_the_header(self):
|
||||
self._item(status=SMSQueue.Status.SENT)
|
||||
self._item(status=SMSQueue.Status.PENDING)
|
||||
self._item(status=SMSQueue.Status.FAILED)
|
||||
response = self.client.get(self._url())
|
||||
self.assertEqual(response.context['stat_sent'], 1)
|
||||
self.assertEqual(response.context['stat_pending'], 1)
|
||||
self.assertEqual(response.context['stat_failed'], 1)
|
||||
self.assertEqual(response.context['total_count'], 3)
|
||||
|
||||
def test_toggle_redirects_back_to_sms_page_with_open_settings(self):
|
||||
"""پس از تغییر تنظیم، کاربر در همان صفحه/مودال میماند (نه صفحهٔ مشتری)."""
|
||||
response = self.client.post(
|
||||
reverse('admin_panel:sms_customer_toggle', kwargs={'c_id': self.customer.id}),
|
||||
{'sms_type': SMSType.WELCOME, 'is_enabled': 'false'},
|
||||
)
|
||||
self.assertEqual(response.status_code, 302)
|
||||
self.assertTrue(response.url.startswith(self._url()), response.url)
|
||||
self.assertIn('open=settings', response.url)
|
||||
self.assertIn('notice=customer_sms_saved', response.url)
|
||||
|
||||
def test_custom_send_redirects_back_to_sms_page(self):
|
||||
mark_primary(self.customer, '09120000000')
|
||||
response = self.client.post(
|
||||
reverse('admin_panel:sms_send_custom', kwargs={'c_id': self.customer.id}),
|
||||
{'text': 'سلام {name}'},
|
||||
)
|
||||
self.assertEqual(response.status_code, 302)
|
||||
self.assertTrue(response.url.startswith(self._url()), response.url)
|
||||
self.assertIn('notice=custom_queued', response.url)
|
||||
|
||||
def test_primary_toggle_redirects_back_to_sms_page(self):
|
||||
response = self.client.post(
|
||||
reverse('admin_panel:sms_primary_toggle', kwargs={'c_id': self.customer.id}),
|
||||
{'phone': '09120000000', 'is_primary': 'true'},
|
||||
)
|
||||
self.assertEqual(response.status_code, 302)
|
||||
self.assertTrue(response.url.startswith(self._url()), response.url)
|
||||
|
||||
def test_open_settings_query_keeps_modal_available(self):
|
||||
response = self.client.get(self._url() + '?open=settings')
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertIn('id="smsSettingsModal"', response.content.decode('utf-8'))
|
||||
|
||||
|
||||
class SmsPartsTests(TestCase):
|
||||
"""محاسبهٔ «تعداد پیامکِ محاسبهشده» (parts) بر اساس کدگذاری GSM-7 / UCS-2."""
|
||||
|
||||
Reference in new issue
Block a user