diff --git a/PROJECT_TREE.txt b/PROJECT_TREE.txt index 56ec881..eb53e75 100644 --- a/PROJECT_TREE.txt +++ b/PROJECT_TREE.txt @@ -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 diff --git a/admin_panel/migrations/0081_sms_type_install_created.py b/admin_panel/migrations/0081_sms_type_install_created.py new file mode 100644 index 0000000..32cd556 --- /dev/null +++ b/admin_panel/migrations/0081_sms_type_install_created.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), + ] diff --git a/admin_panel/models/sms.py b/admin_panel/models/sms.py index a204899..0ef9d2e 100644 --- a/admin_panel/models/sms.py +++ b/admin_panel/models/sms.py @@ -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", "یادآوری سررسید نسیه" diff --git a/admin_panel/sms/scanner.py b/admin_panel/sms/scanner.py index 048f237..6ed8630 100644 --- a/admin_panel/sms/scanner.py +++ b/admin_panel/sms/scanner.py @@ -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), diff --git a/admin_panel/sms/timing.py b/admin_panel/sms/timing.py index ddb3b90..37ab662 100644 --- a/admin_panel/sms/timing.py +++ b/admin_panel/sms/timing.py @@ -46,6 +46,11 @@ TIMING = { "field": None, "note": "به‌محض ثبت «اقدام از نوع بازدید» ارسال می‌شود؛ تنظیم زمانی ندارد.", }, + SMSType.INSTALL_CREATED: { + "kind": NONE, + "field": None, + "note": "به‌محض ثبت «اقدام از نوع نصب» ارسال می‌شود؛ تنظیم زمانی ندارد.", + }, SMSType.PAYMENT_THANKS: { "kind": NONE, "field": None, diff --git a/admin_panel/sms/triggers.py b/admin_panel/sms/triggers.py index 162da80..8f1b7a3 100644 --- a/admin_panel/sms/triggers.py +++ b/admin_panel/sms/triggers.py @@ -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): """پیامک تشکر از پرداخت؛ پس از ثبت سند پرداخت (بستانکار/وصول). diff --git a/admin_panel/templates/admin_panel/_customer_sms.html b/admin_panel/templates/admin_panel/_customer_sms.html deleted file mode 100644 index 93b4a99..0000000 --- a/admin_panel/templates/admin_panel/_customer_sms.html +++ /dev/null @@ -1,40 +0,0 @@ -{% if perms.admin_panel.ManageSMS %} -
-

پیامک

- -
-
-

فعال/غیرفعال پیامک‌ها برای این مشتری

- {% for item in sms_toggles %} -
- {% csrf_token %} - - -
- -
-
- -
-
- {% endfor %} -
- -
-

ارسال پیامک سفارشی

-
- {% csrf_token %} -
-
- -
-
-

می‌توانید از متغیرهایی مثل {name} (نام مشتری) استفاده کنید.

- -
-
-
-
-{% endif %} diff --git a/admin_panel/templates/admin_panel/customerNew2.html b/admin_panel/templates/admin_panel/customerNew2.html index 05273de..af264ab 100644 --- a/admin_panel/templates/admin_panel/customerNew2.html +++ b/admin_panel/templates/admin_panel/customerNew2.html @@ -180,9 +180,12 @@ - - {% include "./_customer_sms.html" %} +{% comment %} + باکس «پیامک» از این صفحه حذف شد: تنظیمات پیامک به تب «سوابق پیامک» مشتری + (/panel/customer//sms/) منتقل شده تا فرم فاکتور تمام‌عرض بماند و سوابق و + تنظیمات هر دو در یک صفحهٔ اختصاصی دیده شوند. +{% endcomment %} {% endblock %} diff --git a/admin_panel/templates/admin_panel/customerSmsHistory.html b/admin_panel/templates/admin_panel/customerSmsHistory.html index a7afa4e..8bad635 100644 --- a/admin_panel/templates/admin_panel/customerSmsHistory.html +++ b/admin_panel/templates/admin_panel/customerSmsHistory.html @@ -4,30 +4,223 @@ {% block BodyCustomer1 %} +{% comment %} + صفحهٔ «پیامک مشتری» = سوابق + تنظیمات در یک صفحه. + قاعدهٔ طراحی: تنظیمات هرگز روی سوابق نمی‌نشیند؛ فقط آمار + دکمه‌ها بالاست و + تنظیمات/ارسال سفارشی/شماره‌های اصلی داخل مودال باز می‌شوند. + همهٔ کلاس‌ها با پیشوند csmsx و محدود به #customerSmsHistory هستند تا به + صفحات دیگر نشت نکنند. +{% endcomment %} + +
-

سوابق پیامک مشتری

-

- همهٔ پیامک‌های ارسال‌شده برای شماره‌های این مشتری — جمعاً - {{ total_count }} پیامک. -

-
+ {# ---------- ۱) نوار بالای صفحه: آمار + دکمه‌های تنظیمات ---------- #} +
+
+

پیامک‌های {{ customer.name_family }}

+

+ جمعاً {{ total_count }} پیامک در سابقه · + {{ sms_enabled_count }} از {{ sms_type_total }} نوع پیامک برای این مشتری فعال است. +

+
+ ✅ ارسال‌شده {{ stat_sent }} + ⏳ در صف {{ stat_pending }} + ⚠️ ناموفق {{ stat_failed }} +
+
+
+ {% if perms.admin_panel.ManageSMS %} + + + {% endif %} + {% if perms.admin_panel.ManageSMSSettings %} + 🗂️ متن قالب‌ها + {% endif %} +
+
+ + {# ---------- ۲) فیلترها ---------- #} +
+
- + {% for value, label in status_choices %} {% endfor %} @@ -35,88 +228,113 @@
+
- + {% for value, label in delivery_choices %} {% endfor %}
-
+
+ +
{% if status or delivery %} {% endif %} + + {% if status or delivery %} + نمایش {{ filtered_count }} از {{ total_count }} پیامک + {% else %} + نمایش {{ total_count }} پیامک، جدیدترین‌ها ابتدا + {% endif %} +
- {% if status or delivery %} -

نتایج فیلترشده: {{ filtered_count }} مورد

- {% endif %} - -
- - - - - - - - - - - - - - - {% for item in items %} - - - - - - - - - - - {% empty %} - - {% endfor %} - -
شناسهنوع پیامکشماره گیرندهمتن پیامکوضعیت ارسالوضعیت تحویلتاریخ ایجادتاریخ ارسال
{{ item.id }}{{ item.get_sms_type_display|default:'—' }}{{ item.phone_number }}{{ item.message }} - - {{ item.get_status_display }} - - {% if item.last_error %} -
- خطا: {{ item.last_error|truncatechars:80 }} -
- {% endif %} -
- - {{ item.get_delivery_status_display|default:'بدون پیگیری' }} - - {% if item.delivery_checked_at %} -
استعلام: {{ item.delivery_checked_at|toJalaliDT }}
- {% endif %} -
{{ item.created_at|toJalaliDT }}{{ item.sent_at|toJalaliDT|default:'—' }}
پیامکی برای این مشتری ثبت نشده است.
+ {# ---------- ۳) سوابق ---------- #} +
+
+ + + + + + + + + + + + + + + {% for item in items %} + + + + + + + + + + + {% empty %} + + + + {% endfor %} + +
شناسهنوع پیامکگیرندهمتن پیامکوضعیت ارسالوضعیت تحویلتاریخ ایجادتاریخ ارسال
{{ item.id }} + {% if item.sms_type %} + {{ item.get_sms_type_display }} + {% else %} + سفارشی + {% endif %} + {% if item.attempts %} +
تلاش: {{ item.attempts }}
+ {% endif %} +
{{ item.phone_number }} +
{{ item.message }}
+ {% if item.message|length > 170 %} + + {% endif %} +
+ + {{ item.get_status_display }} + + {% if item.last_error %} +
+ خطا: {{ item.last_error|truncatechars:80 }} +
+ {% endif %} +
+ + {{ item.get_delivery_status_display|default:'بدون پیگیری' }} + + {% if item.delivery_checked_at %} +
استعلام: {{ item.delivery_checked_at|toJalaliDT }}
+ {% endif %} +
{{ item.created_at|toJalaliDT }}{{ item.sent_at|toJalaliDT|default:'—' }}
پیامکی برای این مشتری ثبت نشده است.
+
{% if items.has_other_pages %} -
{% endblock %} + +{% block BodyCustomer2 %} +{% if perms.admin_panel.ManageSMS %} + +{# ---------- مودال ۱: تنظیمات پیامک (نوع‌ها + شماره‌های اصلی) ---------- #} + + +{# ---------- مودال ۲: ارسال پیامک سفارشی ---------- #} + + + +{% endif %} +{% endblock %} + diff --git a/admin_panel/views/actions.py b/admin_panel/views/actions.py index d734b42..3b7520b 100644 --- a/admin_panel/views/actions.py +++ b/admin_panel/views/actions.py @@ -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')) diff --git a/admin_panel/views/customers.py b/admin_panel/views/customers.py index 981bb1c..9a80ebb 100644 --- a/admin_panel/views/customers.py +++ b/admin_panel/views/customers.py @@ -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) # پیگیری‌های عقب‌افتاده: موعدشان گذشته و هنوز تعیین تکلیف نشده‌اند. diff --git a/admin_panel/views/sms.py b/admin_panel/views/sms.py index 75e0915..557404b 100644 --- a/admin_panel/views/sms.py +++ b/admin_panel/views/sms.py @@ -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) diff --git a/admin_panel/visit_policy.py b/admin_panel/visit_policy.py index e825861..1a3c6f2 100644 --- a/admin_panel/visit_policy.py +++ b/admin_panel/visit_policy.py @@ -437,38 +437,56 @@ def _valid_manual_holds(holds): # تاریخ آینده). سناریوی رایج «همین حالا تماس گرفتم و قرار دو هفتهٔ بعد # گذاشتم» تاریخ اقدام را «امروز» می‌ذارد؛ اگر شرط آینده‌بودن تاریخ اقدام را # بگذاریم، همین مورد (هدف اصلی این قابلیت) بی‌اثر می‌شد. پس فقط وضعیت مهم است. - coordination_latest = dict( - Action.objects.filter( - customer_id__in=customer_ids, - actionType=Action.ActionType.HAMAHANGI, - status__in=(Action.Status.WAITE, Action.Status.FINISH), + # «چه کسی جدیدتر است» بر اساس (createdDT، id) سنجیده می‌شود، نه فقط createdDT. + # چرا: createdDT در SQLite/ویندوز دقت کافی ندارد و دو اقدام که در یک لحظه ثبت + # شوند مقدار یکسان می‌گیرند؛ با مقایسهٔ صرفاً `>` آن‌گاه «بازدید بعد از + # هماهنگی» تشخیص داده نمی‌شد و قرارِ مرده معتبر می‌ماند. id یکتای افزایشی + # همان ترتیب ثبت را می‌دهد، پس به‌عنوان مرزِ تساوی استفاده می‌شود. + coordination_latest = { + cid: (last, last_id) + for cid, last, last_id in ( + Action.objects.filter( + customer_id__in=customer_ids, + actionType=Action.ActionType.HAMAHANGI, + status__in=(Action.Status.WAITE, Action.Status.FINISH), + ) + .values("customer_id") + .annotate(last=Max("createdDT"), last_id=Max("id")) + .values_list("customer_id", "last", "last_id") ) - .values("customer_id") - .annotate(last=Max("createdDT")) - .values_list("customer_id", "last") - ) + } # آخرین فعالیتِ **جایگزین‌کنندهٔ قرار** (بازدید/نصب) برای هر مشتری. # عمداً فقط این دو نوع: یک سند وصول یا تماس ورودی نباید قرار را باطل کند. - other_latest = dict( - Action.objects.filter( - customer_id__in=customer_ids, actionType__in=_SUPERSEDING_TYPES, + other_latest = { + cid: (last, last_id) + for cid, last, last_id in ( + Action.objects.filter( + customer_id__in=customer_ids, actionType__in=_SUPERSEDING_TYPES, + ) + .values("customer_id") + .annotate(last=Max("createdDT"), last_id=Max("id")) + .values_list("customer_id", "last", "last_id") ) - .values("customer_id") - .annotate(last=Max("createdDT")) - .values_list("customer_id", "last") - ) + } valid = {} for customer_id, until in holds.items(): if until is None: continue - coordinated_at = coordination_latest.get(customer_id) - if coordinated_at is None: - continue - activity_at = other_latest.get(customer_id) - if activity_at is not None and as_aware(activity_at) > as_aware(coordinated_at): - # پس از هماهنگی، اقدام دیگری ثبت شده ⇒ قرار منتفی است. + coordination = coordination_latest.get(customer_id) + if coordination is None: continue + coordinated_at, coordinated_id = coordination + activity = other_latest.get(customer_id) + if activity is not None: + activity_at, activity_id = activity + newer = as_aware(activity_at) > as_aware(coordinated_at) or ( + as_aware(activity_at) == as_aware(coordinated_at) + and activity_id > coordinated_id + ) + if newer: + # پس از هماهنگی، اقدام دیگری ثبت شده ⇒ قرار منتفی است. + continue valid[customer_id] = until return valid @@ -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 diff --git a/docs/entezar_action_fields_report.md b/docs/entezar_action_fields_report.md index 3026cf3..4f145a6 100644 --- a/docs/entezar_action_fields_report.md +++ b/docs/entezar_action_fields_report.md @@ -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` و پیامک) +> عمداً تغییر نکرد؛ «نصب» فقط لنگر چرخه است، نه بازدید. ### ۹.۳ اثر روی دادهٔ واقعی (همان نسخهٔ پشتیبان) diff --git a/docs/visit_hold_entezar.md b/docs/visit_hold_entezar.md index b72fa83..0b1b93d 100644 --- a/docs/visit_hold_entezar.md +++ b/docs/visit_hold_entezar.md @@ -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` است. --- diff --git a/tests/test_entezar_action_simulation.py b/tests/test_entezar_action_simulation.py index 2e1a400..3dcb122 100644 --- a/tests/test_entezar_action_simulation.py +++ b/tests/test_entezar_action_simulation.py @@ -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', diff --git a/tests/test_sms.py b/tests/test_sms.py index 55211f4..dd10eb7 100644 --- a/tests/test_sms.py +++ b/tests/test_sms.py @@ -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."""