diff --git a/PROJECT_TREE.txt b/PROJECT_TREE.txt index c5ed259..06c4862 100644 --- a/PROJECT_TREE.txt +++ b/PROJECT_TREE.txt @@ -1,11 +1,11 @@ Project Root: customerTicket -Generated At: 2026-10-11T08:57:27+00:00 -Python Files: 304 +Generated At: 2026-10-13T12:06:20+00:00 +Python Files: 327 Django Files: 49 -JavaScript/TypeScript Files: 39 -HTML Files: 73 -Total Files: 1412 -Total Directories: 134 +JavaScript/TypeScript Files: 41 +HTML Files: 77 +Total Files: 1582 +Total Directories: 144 customerTicket ├── .agents @@ -144,11 +144,15 @@ customerTicket │ │ │ ├── 0016_announcementjob_requested_by_announcementjob_source_and_more.py │ │ │ ├── 0017_alter_smstemplate_event_type.py │ │ │ ├── 0018_alter_smstemplate_body.py +│ │ │ ├── 0019_alter_smstemplate_event_type.py +│ │ │ ├── 0020_receipt_rejection_template.py +│ │ │ ├── 0021_dispatch_response_opening.py │ │ │ └── __init__.py │ │ ├── __init__.py │ │ ├── admin.py │ │ ├── announcement_worker.py │ │ ├── apps.py +│ │ ├── dispatch_opening.py │ │ ├── models.py │ │ └── services.py │ ├── payments @@ -160,6 +164,7 @@ customerTicket │ │ │ ├── 0005_paymentnotice_deleted_at.py │ │ │ ├── 0006_normalize_deposit_identifiers.py │ │ │ ├── 0007_receipt_alerts.py +│ │ │ ├── 0008_paymentreceiptrevision_depositor_name.py │ │ │ └── __init__.py │ │ ├── __init__.py │ │ ├── access.py @@ -170,6 +175,7 @@ customerTicket │ │ ├── customer_picker.py │ │ ├── deposit_identifier.py │ │ ├── forms.py +│ │ ├── identity.py │ │ ├── link_views.py │ │ ├── middleware.py │ │ ├── models.py @@ -233,15 +239,20 @@ customerTicket │ │ │ ├── 0015_seed_customer_category_catalog.py │ │ │ ├── 0016_scope_legacy_public_tickets_to_units.py │ │ │ ├── 0017_ticketaccessgrant_first_viewed_at_and_more.py +│ │ │ ├── 0018_dispatch_response_opening.py │ │ │ └── __init__.py │ │ ├── __init__.py │ │ ├── admin.py │ │ ├── apps.py │ │ ├── category_catalog.py │ │ ├── customer_urls.py +│ │ ├── dispatch_presentation.py +│ │ ├── dispatch_response_views.py +│ │ ├── dispatch_responses.py │ │ ├── forms.py │ │ ├── models.py │ │ ├── policies.py +│ │ ├── response_admin.py │ │ ├── selectors.py │ │ ├── services.py │ │ ├── sharing.py @@ -276,6 +287,12 @@ customerTicket │ │ ├── payment-receipt.html │ │ ├── payment-services.js │ │ ├── README.md +│ │ ├── receipt-action-main-desktop.png +│ │ ├── receipt-action-main-mobile.png +│ │ ├── receipt-action-original-before-mobile.png +│ │ ├── receipt-action-preview-desktop.png +│ │ ├── receipt-action-preview-mobile.png +│ │ ├── receipt-action-preview.html │ │ ├── receipt-amount.js │ │ ├── receipt-banks.js │ │ ├── receipt-date.js @@ -302,12 +319,14 @@ customerTicket │ ├── CHAHARSU_PAYMENTS_IMPLEMENTATION.md │ ├── CHAHARSU_PAYMENTS_IMPLEMENTATION_PLAN.md │ ├── DEPLOYMENT_COOLIFY.md +│ ├── DISPATCH_RESPONSE_OPENING.md │ ├── MESSAGE_READ_RECEIPTS.md │ ├── ORGANIZATIONAL_TICKETING.md │ ├── ORGANIZATIONAL_TICKETING_GAP_ANALYSIS.md │ ├── PAYMENT_LINKS.md │ ├── PAYMENT_NOTICE_DELETION.md │ ├── PAYMENT_RECEIPT_ALERTS.md +│ ├── PAYMENT_RECEIPT_CUSTOMER_DETAILS_QA.md │ ├── PHONE_CONTACTS_API.md │ ├── PRODUCTION_SEED.md │ ├── QA_PERMISSION_MATRIX.md @@ -388,6 +407,27 @@ customerTicket │ │ ├── graph.json │ │ ├── GRAPH_REPORT.md │ │ └── manifest.json +│ ├── 2026-10-11 +│ │ ├── .graphify_analysis.json +│ │ ├── .graphify_labels.json +│ │ ├── cost.json +│ │ ├── graph.json +│ │ ├── GRAPH_REPORT.md +│ │ └── manifest.json +│ ├── 2026-10-12 +│ │ ├── .graphify_analysis.json +│ │ ├── .graphify_labels.json +│ │ ├── cost.json +│ │ ├── graph.json +│ │ ├── GRAPH_REPORT.md +│ │ └── manifest.json +│ ├── 2026-10-13 +│ │ ├── .graphify_analysis.json +│ │ ├── .graphify_labels.json +│ │ ├── cost.json +│ │ ├── graph.json +│ │ ├── GRAPH_REPORT.md +│ │ └── manifest.json │ ├── before-html-semantic-2026-10-04T06-37-15-050Z │ │ ├── .graphify_analysis.json │ │ ├── .graphify_labels.json @@ -409,6 +449,7 @@ customerTicket │ │ │ ├── 03040dc947a4bba22aafbc6262c9db2c8549826ae5aa1ac4cc1821806f93561f.json │ │ │ ├── 039590a02c184d21a28caec4178b981a824ec4356a225d1ce546cc212d2f550f.json │ │ │ ├── 040cdac326eb1631b97e9aeddca78ac07e236243b5b8ab2d77bac37b50a91cdd.json +│ │ │ ├── 042c2088faaafc0dd7d522f52e043a37d879ccf3886eeb86fd8ee76caee8274c.json │ │ │ ├── 04b84d19d132764512441a31b31294e4f19dc963ed0a6e3abdd3eca7719e8bca.json │ │ │ ├── 057ccb6901a83922bee4265993977ceb0d0ac425c900cb2d2f3181998df71796.json │ │ │ ├── 058b4e36240cd25d158647c300472baad5e7101ebde0319883abb0edc8c9c74d.json @@ -428,10 +469,12 @@ customerTicket │ │ │ ├── 0f0c4146a70cadafffd025d6954e9202626be74ff97539e984c8b27cda5ed662.json │ │ │ ├── 0f28b01b2484ce84b5251ff848065a2c6f7f574665305b5ecf8cb31a755a7982.json │ │ │ ├── 0feebff2f8ac6ee66557b8692e3a537c0834b6bc4c28a6b9da6f3edf619283c1.json +│ │ │ ├── 111fa3c9367ec64a9771decb235bbf09fb0a2bdabe28dd3950016297769be8eb.json │ │ │ ├── 118544d17e045bbbae483669adbc365dbc9b0d707514cff54c8b84ca16042967.json │ │ │ ├── 11d2b71feb1480622fb18ee3795cf070421b146e4273b5016fad81e7cc8e2c7e.json │ │ │ ├── 11ff4da3afbce9cae19102862deefdf725fa363130fad753fa189fa4f6c8c4de.json │ │ │ ├── 12a877e28c12d0651d6487751c6b746afbe8361f9ba7e1b672f09bd596fbcf82.json +│ │ │ ├── 12d6990913d6d9783d6c386522e92c492d1f7e7b18775a88d1fbb36597c0697b.json │ │ │ ├── 12db2ab20d1e0be8080b5edd6cc40cacfdff350dd585126a0e0ce144073ad12f.json │ │ │ ├── 12f95828a2c1818402459627e36481199020e31faa280d214f81700fb9ad520d.json │ │ │ ├── 1436cce90bf2f00f050c9eb67cddf5672ed6a3db185ef663c257ee7936b175ae.json @@ -440,18 +483,22 @@ customerTicket │ │ │ ├── 15539048155c760644fc70dd1fbff3855ceedf11bc6ef7b270d1c7280cb4df48.json │ │ │ ├── 175f840f1587093869a030b52c3b019e020c2fb7cf1223579b251b4857752130.json │ │ │ ├── 17ab5521ea572ea85443db030bd5085f1443c20b20da9cbf808f21ad695eb319.json +│ │ │ ├── 17c16884f2afd6ff3a88f7215e7f3ff60eb1199727ddae073765ed12d625b43d.json │ │ │ ├── 18ba25713068baf53992eb4ea94f95212ecaeb931fdcf6769970fc2fd19b2f93.json +│ │ │ ├── 199589c167d3eeae5d660bade254f9a273e5f39e1110be9c1eef12d320faa80a.json │ │ │ ├── 19ed1276256eb4d44b9cea2dddac4de3801d67a08cf381a55466e82ee6f2bbc6.json │ │ │ ├── 19ee52d79b7d8eec47a4708aff6c85884496a64aaf84591b70505ce03999283c.json │ │ │ ├── 1bfc839bf502b97d0b70b0bacc9781cae7511d27d078ed6ac6978fd438833dd1.json │ │ │ ├── 1c74c48acbde13e6bc5c2f63b204a1c051f22a8f48cd7b40ac54fc95e8809018.json │ │ │ ├── 1c9f81b7412f571aa252ed10230220684b6657e1115f75a8c3a64b792517d41c.json │ │ │ ├── 1d0ab0c5a2f15cd1c5a78495225dc535c02b58e9da10f882f48182e39b3f8b7b.json +│ │ │ ├── 1d35824a27269763b24ce6ed893eed531073cc9e051a8c4f934df5a5a4e2638b.json │ │ │ ├── 1d6b17be6b2c0e7d25b4622b4a8c09ab705cecf9db09e2f86c35a4a564485791.json │ │ │ ├── 1db075565e2fe1027675b7ee03d055150e6b741f3e9865bcd46d9a6113c538a8.json │ │ │ ├── 1e3bef21b169a2020643dc56fc33c665acc164b4b8f07639a9b1f58c37bb4caf.json │ │ │ ├── 1e4a0b7beaeed9f0f2d7088d5944743bf6307504aa956e35ed8ce54bede8ac5a.json │ │ │ ├── 1f440edc6feb5bf8c2a462b1b4a0b44bd22b4a763286fb2b340c797c282b5bcd.json +│ │ │ ├── 1f5b6250ae5014735ba63bb94ef5c36cdee4213584c957e6a1473793cb6ee0c1.json │ │ │ ├── 1f6cfd55d13bc48aa6c68558090ceeaf24a54c7bec690d51d95243c904daf0a6.json │ │ │ ├── 1fabd63a56e826ff351d3edca222f5f958004c01f0bd16aed30080e8b13da33f.json │ │ │ ├── 21f38c2f6a379ab402b4d3bd1b0809129c8554ebb01202a160c42a1e9b49b717.json @@ -464,28 +511,35 @@ customerTicket │ │ │ ├── 244d7b4b6409bf25f038ee2751756e867872e873deb39a2c0319233b208bac01.json │ │ │ ├── 252160e3514d77f06df93fd38cddf31464cc41b33126f89b7cf3732afd80ac0f.json │ │ │ ├── 254ff59f2f43fd16c2028b338ec47a892294021f1a354238db59cb2ae6cbdf4a.json +│ │ │ ├── 25fa532662f496bc59d96370020c7dd625f0ff62b46f0eb1f4c8d1d94d16b76f.json │ │ │ ├── 2609e627752bd20df923f6c69e090173a368757b3dbd2ce418e3cc9f67c697ba.json │ │ │ ├── 26a41d5fda805c545065f2da12215d350587a61aff951937f54d64bb713f0fab.json │ │ │ ├── 26c6832fa65fe38fa62f98bbfd8d544cf6a470cce547579e1d650390e8b9fded.json │ │ │ ├── 26e3b3874f2dc2c3709945e2f6cf050e5c525933de9f09a8c91b244e3bfa8ca7.json │ │ │ ├── 27668af545c6617927904e70fa532e756b5ecdf46e3071256fa379ce3bdc9974.json │ │ │ ├── 2782dd32ec82052ba614fbf227165cf220ac3475521b90ea987f648d080979a5.json +│ │ │ ├── 283dea41a51b90d6d7af34f71843eb53275797d2ba7613073ecf01bb96384f72.json │ │ │ ├── 28cefeff27c9cb5760aead796c37afdb1311bff0a5ff759136f0473ffa0152ff.json +│ │ │ ├── 28f6ba204a62353723042ebaf872ce606630d0a70d1442e02a206a016f6a0fb7.json │ │ │ ├── 298888276da87757f59b0e0280ea46039c0ee05945961c3e00b287abcbc6a2f0.json │ │ │ ├── 29aca949b15e953360acae5ba86aac6c34ba8efddc55636fa05c28bd259ec66e.json +│ │ │ ├── 29ad05a046027ffb50f2f5afac110df1e7296417ee6844041d84181cbfdfb104.json │ │ │ ├── 2a68e54ab0e9bec51e402455f9592be0fb5f1dbf18210f8338f01fc35b54fd2e.json │ │ │ ├── 2b7cc5d0dec7234082c1797ab637b916ac7df39277f8fbf109ae90190fc1e900.json │ │ │ ├── 2c76016c5b2c45bddcb0aec92b2365ea389feafb0667b2367350c756c7c51d87.json │ │ │ ├── 2c8d91fbfca457646cf60edb03fd3a89a2494a6b6612087854edeac5a119739a.json │ │ │ ├── 2cedf41a22ec35c4568262c1fbb02208903d5ae3d91d6bee9192012c136208c6.json +│ │ │ ├── 2d500bf973b697eff7baff105e67f777d7831687b6b552cb786d0d2b6e34c70e.json │ │ │ ├── 2df1edd1fd32c467f237babe4a94f2ed73b67270ede6fa8298b32eab7c3eb80b.json │ │ │ ├── 2e7903ddca9e29c53f1706f2755303e56d60436e494d066d011fdba93e701903.json │ │ │ ├── 2edf2af73b8c2b6f4f195ec878746ca0ecc6575576fec24c2cd5be8d7d30d4a4.json +│ │ │ ├── 2ee3c4ba0f8d0c7b04d9b4e795b38c0ab686690e154a22d83b997fdf84c2b801.json │ │ │ ├── 2f2e2e2f48d6f1c856d699606fbc6705871328d271a8ef3fb7bb9e638449d40f.json │ │ │ ├── 2fd5953e7926f320bdeac2d559967758aef4632bf7dfa26a5ee50ad386fc9b67.json │ │ │ ├── 30a1c433d0992b236232f8d17b7e17bbcfac1fd7385c607d1d09e716ba7da9db.json │ │ │ ├── 3142f9d4517f137483c716dd61985811bbf006b85506791e9a2ee37d58b2f53e.json │ │ │ ├── 314eb1aeeb47d9039980ee994d0682472193ed4e187b497797cd3c51a51b18a4.json +│ │ │ ├── 321c97fc6e1ed25ab2ea85ec70f9b61cc545bc5f17e8534a471aead3a35dca36.json │ │ │ ├── 32a4505d16f4fd1efc5bc0c2ea7c8900419856b21a41b06d988cc424258ea1c9.json │ │ │ ├── 32c40d17e90ccf2511e124b4fcc5b3674a7200dd5215ee8742d319f8090b04ba.json │ │ │ ├── 336d5e1042b05e1f144a030b9f84836283398076b9f90d8cd4dc37622ab00caf.json @@ -493,6 +547,7 @@ customerTicket │ │ │ ├── 345cea9e55c5f1954119121edf5ec28f1514b11d83de6c5bddf80d014bd98c94.json │ │ │ ├── 34b48eae711a65d522f81081262d437469f08182368d46643dc80eadd96bbdb5.json │ │ │ ├── 34cbb71eca44ad5849b04248e513e7cc33c3d3f1a1f66cc27f732666e585bf19.json +│ │ │ ├── 34ee95cf6ae71da6e132fc7523dcf0e14fb271942cdebb9fcea20941e9c8f6d0.json │ │ │ ├── 35087fe77e282f61d742ba82850f71b99fb5a515c98302e214809a662caace81.json │ │ │ ├── 36000938c9c3b13fbd92622f905f8c4f4bfbfdabe66e3357e88c162a4fb198ce.json │ │ │ ├── 360e95dd7f9428a51961a1f4ae7968a6e3101b8df00027432c6c743f64ccf9f8.json @@ -502,6 +557,7 @@ customerTicket │ │ │ ├── 393b2ca571bf671f26f243678a1be73266fe7435c43d7c837f1b53d4dc54cc66.json │ │ │ ├── 3948717314f47afa835e760073df4764ac427f4858f5ec0ee6a2f298ac393d49.json │ │ │ ├── 39712339ec9b29be60c73ea29595c45b9e853f2facc2c4cad9864ecfce375f8b.json +│ │ │ ├── 39ec3a5032b396c043170c52f702a4511e2f50e6b340b01fe4e2ebbaefa7a8c8.json │ │ │ ├── 3b3003c9f49c174331e4f6eff0a43e09b9d64d0813c39e0c24805ca796d0d15a.json │ │ │ ├── 3b9252049dbf8ef67e64a756bb41325a6cf50190004d4442119d438620bacc72.json │ │ │ ├── 3b97506bb601aaab9825ae607b89cebbd3a1eb30a6176cb038baf9898ab0e4fb.json @@ -509,8 +565,10 @@ customerTicket │ │ │ ├── 3ca58a3cda78344757f73fb9dc390d4bb3b45f4a711a1a058c049801282ec1f8.json │ │ │ ├── 3cec8055583c7d94e03ced535376a8bd9d3d335cd6c7e328f38dc2a9e6b1e397.json │ │ │ ├── 3d66d87daf2039a3fcd22d5d844e30c00443bed258dc048fbf939d97258e08d2.json +│ │ │ ├── 3e23ccb6b6b6496c83b1332b2bad75bc7708e3e24e540289368d15d1288206e3.json │ │ │ ├── 3e4a861d0f7c53bd5ec082f319d974a4a34d5943bf2408a639d11e0ce9bfad46.json │ │ │ ├── 3f0cad4a4a6eb440a2b133bf72f2e72d6e7ef3668f4c8dbc2c759dbd1a41eb6f.json +│ │ │ ├── 3ff030d6213f732c2bdc25a4191f8a42cf0d2270fbf07baabff5fa43dcbab6e4.json │ │ │ ├── 401b3df2aa1a5c618d917ae2422e552c59dfbc5220b312fe7977d751168f6987.json │ │ │ ├── 406e12ea55e61f7314b3b46df9e152e549fae319a2f71cbb01e7fe8bc4bde7e0.json │ │ │ ├── 408f6eb568e45e950fdc170776dabf9087829df0db630e16643fba8995318d26.json @@ -520,9 +578,11 @@ customerTicket │ │ │ ├── 42ffbc76dd6afcd90fde30281453fcc765ed07bc0e729fae4eee28d86f840578.json │ │ │ ├── 433946952194be24247d30e4d5ba4d67b64ee2cda27a9212f16b4160a6f3fb71.json │ │ │ ├── 434bb9df51f8929ca10167ce360dbaa9d93fdbfbd27053bdf11f3b171189edb8.json +│ │ │ ├── 43da9af305342f5aa4e621721301e188b3d4116d7564e754253beeb4abfea0cd.json │ │ │ ├── 44c4bd6b78660abe3642da6dfe25e665ac7bed04e759528556f437423fbbfe36.json │ │ │ ├── 478a054f8292cd197743a61b5e3e04487f67f8db76ae658a28b04e4aef670ab2.json │ │ │ ├── 479992e96c0c5fac0a00bd4d9e0822dd52273158f5364c26884649b017e5b874.json +│ │ │ ├── 479f2c5aa963a3f44aa0abfd802dcea79d026cba9c75e5b04fb21231cb555485.json │ │ │ ├── 47fb67d01a9006c7f835ad87b75b95bb1365c27b342144f081d858e07fd309b3.json │ │ │ ├── 485d542ea0237235e2d09999130ffa61c11da13873b62ca3e9228896f29da35f.json │ │ │ ├── 48f2dd332e3e37756440716095b3b0ba781ebd5abae50130c6b8bfee5a0468cd.json @@ -534,9 +594,12 @@ customerTicket │ │ │ ├── 4ae084687e4372e69f349e8bbc5cf603768a032d3d9ee4d2504e6709ed8ee440.json │ │ │ ├── 4ae419f04a3a3b1221faef43b8526910f1958336162fbce432bab0e596ef2aa1.json │ │ │ ├── 4ae7b7ad5faee3292f9987cf047c1b9f484bb316d9e8c177dd85345bdfc861f3.json +│ │ │ ├── 4bdeb74662fa32d7740eab7a45cff522a207ae42c63c9b5873e16e98c8e9ba5e.json │ │ │ ├── 4bf46c6c0f27e14312f7c1311bca1bf5e1bfee29ea5cb35e6f94142141985d23.json +│ │ │ ├── 4c7b260c004c38debe30be2b4f5fe3d5bafd7d2aac7ca6a4c1d250644c8dce19.json │ │ │ ├── 4c924746bf7373f2f4b09200257622c6f97c2ba7fb3edc7f6595709e3c7ce9de.json │ │ │ ├── 4ccefdf6d084d96689cc37f74a4b38f2b6d357fa595c259864b087c2766cf7e6.json +│ │ │ ├── 4cef0397e20a8910f4671f5238b0f10eef4a606ff0d74d3c7410f5637cd583c6.json │ │ │ ├── 4cf64fe0b79aec18c49355f7d82ab04ae66067042a6c364e89842109427ffb5d.json │ │ │ ├── 4d244ac4c7ac7dddfbb2d8ad6e70b2f9ef0d5226d2660b2ef38ef109f19eacc4.json │ │ │ ├── 4e56ef4c75a0ef1d175a72a6980ba047bb4aa51d49b8a17471d8f342a0b779c0.json @@ -546,16 +609,22 @@ customerTicket │ │ │ ├── 508a0054ec5c5b3353e94566a0f728d533ec47721e20e6037aa55e2d665a5143.json │ │ │ ├── 52cd74ad5803b940489587043429b741434ae931fa4d244f5b0dc53582cee050.json │ │ │ ├── 5323b09e3c325ba8c161f5e2cab233b25cfa1b96ff042c6ae79ae1b029e2b1bc.json +│ │ │ ├── 536551cdd087d14f12d75c528f7c0adf9ee3ab6f31d9b7b9f046d8d2d4bf4eb6.json +│ │ │ ├── 548ad74cf54ac9bbfc7a148b65d3fc872223dce345313584ea043538366699da.json │ │ │ ├── 54d567d298ddc3c274327011a29bd4f2f2cb31efaae5ecb3a7a94164a3eb3c1f.json │ │ │ ├── 55322e8bcc443a33b31b8e43e27a92b0c89335d5669d7046d34f58d33edef673.json +│ │ │ ├── 56f2dee4d30586e872b0df42c6a731dc02143d57c517087a25beb3107c59ecc7.json +│ │ │ ├── 5715b58eb1bffef5f32507ec66b2dd71ca9a9b27b3ba31f13c4b1c29b7f42117.json │ │ │ ├── 57bc96866536f0d67300ed29e56bd0f45fa9119c787fac805f228adcab18dc6f.json │ │ │ ├── 57e592c703d95adc50236d5c01fd6314a85061e0969c885789c1045c6d9e3b7a.json +│ │ │ ├── 57e983c63cbc017b431587a44d1dbb894ba49714422fe477a917f8d066953f7b.json │ │ │ ├── 57fe8325b25a8879793a5d96b0ed3d069c07b148f81bfc7c07da75ec353cfe76.json │ │ │ ├── 582c91a8ddabbea5521fc65d724e4fb3672c070f9213fae7b58595fcf62f3c85.json │ │ │ ├── 584447cf308657a696ef8465181b12d2c06f9dc5ff69752bea66bd0b9631feec.json │ │ │ ├── 591ef8879d355b6e3a0f7619bf275b8ab072a6e64b9c5a4b30fd892b792a2695.json │ │ │ ├── 592a03ef799b68757045867944b83c39e0d478ee3cc7eb3707d2cde946728943.json │ │ │ ├── 596a13e9b322f8c3332a3ae214ca7bf368ba6158ff3adcc7db4582f102ea51dc.json +│ │ │ ├── 598faf3f82fa76bcdeb084eec0d3fb6880941e43e5d8c40ec10f73ddd7da100e.json │ │ │ ├── 5a20a0814514ccdb84861bfc664f1659e9fb8f233dda858d580ca95df84c7c0f.json │ │ │ ├── 5a34ded8b2dd4c10c4251d287580918b88690c7058dd2299d2d670170104241d.json │ │ │ ├── 5ab2de631b0d480df2665ade93dae4732a4d6a1f44b809b2051ccff4696515af.json @@ -569,6 +638,7 @@ customerTicket │ │ │ ├── 5fcd0fdc47a301cbd07130dfa7545017b5975c29b9d7458ec65938ed503506a6.json │ │ │ ├── 608fed7bef745c90c049732f5db2efe3982e483670dac36fff2b6e22ff2953fa.json │ │ │ ├── 60ef2d6fded9e070e35cfed119e8325010aa76bce63aa74ca0f25005d8ed9517.json +│ │ │ ├── 62a442e4f632d9f6f6d189a9e2d59b67d6805f5f479dd3034933e59f897ada7b.json │ │ │ ├── 62dab578f0debe93a8df61a1776953980abd254ae6f1eea77afb00dfefd28f15.json │ │ │ ├── 62e55882d2ce81dcfe5df1d58395e767ded55d6e39997d82cc3489c5e02ae910.json │ │ │ ├── 62fba7c993ca0a10198cab8e5a7538f39f88c00face1d324cbf4613effb5ee1b.json @@ -587,6 +657,7 @@ customerTicket │ │ │ ├── 69bef93e5bbc63880e88aca4535dc6a229a57edcfbc65fa829912a3bb847a6ff.json │ │ │ ├── 6a9ce83009b1228569506d7054e15c8c59b4fa9dceb9c15b9cafa288756a2d8c.json │ │ │ ├── 6bd49215f8d2739a9e365dcc8855b95cfa34352c067a9e9ff48a623318c4ddaa.json +│ │ │ ├── 6be67cd5ad404d51ec194da65dc4348d5b444292801207ee360699a1f8bee839.json │ │ │ ├── 6c69494ba124d4bc564bcd3a3df1f33ae0ee0769001a2dfc46825ef8b44dc3e2.json │ │ │ ├── 6d3b094dc56f9b26ce7374ba28e460b85248711223cf516f8e732c5d8f4d9d75.json │ │ │ ├── 6d5685e1562faf37f6c30c614bcc7628edd19cd8181d5791aff416daf9fd5a6c.json @@ -595,6 +666,8 @@ customerTicket │ │ │ ├── 6ee9154979fb497a8ec229a7177660af53a487e1aa17c7b555cdc67797615162.json │ │ │ ├── 7093cbd0a855750457814388b6e527fd3b612163f7a2b898ed6ce470dae74843.json │ │ │ ├── 712474bfe126be3bc31a5e719d9119149235071ef01e34cbe1e71ac49fd991ca.json +│ │ │ ├── 71916a519f47715fb6a1b6d0aa35d4b29876de58d1db8058fdc7411959b1a6cb.json +│ │ │ ├── 724ffe81448b103a20c616e5a5458ab25dd1b7b609cb8ad5062d652e38bdd4fb.json │ │ │ ├── 72ae8a21c9106c639725190cc02f4e07efe7c645896cc4410923ad36751fbb2e.json │ │ │ ├── 72de23f55766289c980e6ccf604ca5d162e0989a222271b7496775ba4d749e3a.json │ │ │ ├── 7379dad88b1741f6f4d999fcea3eb0ac4ffe56f72c07dc11881e94615457bd52.json @@ -608,6 +681,7 @@ customerTicket │ │ │ ├── 7975e2dc7e1d1eb2154b9aba8f19b6207acf85a245cdda0089c9dd0f80463ef5.json │ │ │ ├── 79ede3c31ce20a16af1a767ed248e4a7840803b6d34cf90f1205aa36f3acd7be.json │ │ │ ├── 7a886aec3fe4d432060a7ad6c88a9e0fd810a122ad1d244456af089a828af8c1.json +│ │ │ ├── 7aae245e37fceb3e2773bd83ce355517aea9ff35a05a7ec69b73f54fb0722725.json │ │ │ ├── 7b25ccdfeab6d57509c1b8a9745fb601ab7e8fcae0e73a22932ceb725fd38c9a.json │ │ │ ├── 7b6916f499ae0ceae3eb3d509b51f42a7eaa98e43e161fe87c44b9a5bb01287b.json │ │ │ ├── 7bc90a6d89ccfaea18fd0aa05f2ab506d0639360f33413bfa9385ddf49e6d2fe.json @@ -622,16 +696,20 @@ customerTicket │ │ │ ├── 7fa9734f0b6c5bbde268cb55ce9ba5016e9fd7162daaa665ed305a2d7e999bbe.json │ │ │ ├── 80468d8a995fb4092dfe5e57e25592c06713287d48367b675ae4d00955d4b25f.json │ │ │ ├── 806a3b07b690ca3871b1854aaff6f9042e48cf7aaa9bb9b6f15ab9634eae3b37.json +│ │ │ ├── 80e8fca4176bc01c35ef1fed2dde8b59db8579229320ae3c4aaf328bb2370e8f.json │ │ │ ├── 80f142842848c4420eeec86959e77c24566467dbece6a275dd9c6da15f98551c.json +│ │ │ ├── 81548b27f9188856c3a1dfc9acc5ca22e0671d1c90f84f7ac722d64d132b7a10.json │ │ │ ├── 81b1650d100b8e191c8ef7eef6f8e84e3881c1a2c0d08bed0c35751ee262c405.json │ │ │ ├── 81d5fbc2e91118aac67b18de1ab980f4dd81c8f7804a195e30017626db220ce8.json │ │ │ ├── 8288f4fa57d35d3d5e6b8c39227307c12362b9939e5b5a24447a786416b00362.json │ │ │ ├── 82fcc152dd67510406aa894d1e38939bac365093de9f34db4dfe2485806fda4f.json │ │ │ ├── 833fb89a8d72afc195b8f5bd7338109d8ce7be79f240dac31c4dcd36c8c674f6.json │ │ │ ├── 83ba45b41bd900a8e4b8c4f817e0c2a0098028ca7bbf7d7bc2514505ad25eeba.json +│ │ │ ├── 84036d64b3037781ed2a78fa2313860f63eb93a122e3b816db3c6036b18063ce.json │ │ │ ├── 84579b3014e1a83e4201862df1e93378f5f953fbb547fea30965e335be5e7b1e.json │ │ │ ├── 8493b4822bc3ed0ccd847d04d26598953d76d438e8381c1102922ac8530cd4d4.json │ │ │ ├── 84dedd4165eed360c4051690b2aa84f6ecc08f3f053f4129ca6f3e33c334ba35.json +│ │ │ ├── 84f37ec0d0c9371255a0deb927163c86c757d95aefa9c3e56907bfa89c6a6532.json │ │ │ ├── 853fba8af82971f4fa56d957282d45d335d2e794f8e11001977161265af2ec79.json │ │ │ ├── 85797284a266529c7ce83ed46795c36a9e23deedd8d2676424d2ef2f0272b7ae.json │ │ │ ├── 8595a5d566d43428818ee229e64f6b56453cc9ca61fd68be0b1c3c783d326315.json @@ -644,10 +722,14 @@ customerTicket │ │ │ ├── 899b16856382d32c4e5b39d02cb251bdedaf35c5a81bede5c19bfaff092208e9.json │ │ │ ├── 8ab55a4f4f776ea75d01aaafdff3502c07b5b86e359789b22c590c7f988bf5f4.json │ │ │ ├── 8af38f9544549f250eff9b83a6c2a0e51f86d6ac51de0db24b48b7c654723a39.json +│ │ │ ├── 8b2385018fe75c262a72683cad6bad184f23bb5c2592477a7c03ae3de1e34c6e.json │ │ │ ├── 8b92443bbe1ebe0a8534d065ce886b14e563f6d55f6a3c8b3457c78230d55fa5.json │ │ │ ├── 8e4cb8d07f4d43e171550ff2fc9423efd032b911d7ffee51259e437d4dc32d4f.json +│ │ │ ├── 8eafe8590caa628433866798d33e09db75ad30d406aab7c11ed9f62888176b13.json │ │ │ ├── 8f6a7f00c0a8c66241db8c1450cb431107dea30bf65b8e13783ce7f976d98aac.json │ │ │ ├── 8f763b17555f00e8d20ed1ea8e9a065526f0e1fe942e70a62112c13309d78bae.json +│ │ │ ├── 900c1aa0f3432793946966ccd1705f31d35f396e872f6beaa491cc4aa397fb47.json +│ │ │ ├── 908ce79cabf730090327f55066484254a74c995422c57af507da3ab4e24297b3.json │ │ │ ├── 91c2dc58207c4311391f22d524f461032f7ef3c9443d8f584edb2128966821bb.json │ │ │ ├── 91d83c62f39f219ccb6125502bf8e0a71a587dfc0a66d977a9bb93c8f2dc5dc6.json │ │ │ ├── 91f4a9afed43fafc3d99d7fe5eaa945cd5c89e29d4d6f6448b630c3e58db05e8.json @@ -656,6 +738,7 @@ customerTicket │ │ │ ├── 94ea5a4bac836b2d19b08e446cf90eaea49b9eddc2591d261c384b3513e36f26.json │ │ │ ├── 95dbd272df1d7fd18ba53b86ffda71c7a57046e6a303b88683e323e80ced3a91.json │ │ │ ├── 9697c37cf3a88d2b4eb5419b2381951a9aca966172316db79e7b4c32557d5e87.json +│ │ │ ├── 971bcfb611719b6ecbaa2fab9e54895ab1a7e723f910cf36a7f62bde36a909cc.json │ │ │ ├── 97506546de142970349e57e8a14064280e66a4fcdbced5dac55fc263630a2247.json │ │ │ ├── 97d8ebb860cbcd63be9ef80c6e2094e4a7ccba668b291d3c4815133d5fb8f37e.json │ │ │ ├── 980f1be05d605b9093619724462c9881a8be063a5439567866172a35f8d22316.json @@ -663,12 +746,17 @@ customerTicket │ │ │ ├── 98ca8ab4bab0509a82824f0275ca7d1e9e80f5c8189579ae410a115c62126652.json │ │ │ ├── 9931a5b7d7ab58794d003cce1d0719c201c623a844c7f834f66c88e9390c824e.json │ │ │ ├── 996411678d23c3cf8a0e6c1c5076701e8bf5f3dad21ca907ec4116d15366a668.json +│ │ │ ├── 99893d006aaeff35ca26608c96a0315981c67391546c01bb7d88aa19c5db2734.json │ │ │ ├── 99f94b7b3b60f627c482cc195709b98cd2faa55f14c19c4bf5e0af8a88f34b7a.json +│ │ │ ├── 9a7813a5b55310e424eabea79f20cec77304a5ea2e0825056b243b3333474ea6.json +│ │ │ ├── 9ae4928d33e27747b0808cf3e76cc1d27207ad3fe3ae792683616ff8138dc2fb.json │ │ │ ├── 9b267b0c45b93cecc1294ca1e040e5a18fdc9e0bee69a0ca8571ee11becdee97.json │ │ │ ├── 9b8bd2aff50e08b6f053b74a409c9439db41e692e29aabca5b67057884d945af.json │ │ │ ├── 9be5120f28278ca0713aded1ab546d4d4c7074572b49d9d273a48c0899308a57.json +│ │ │ ├── 9c6a2c8389a2bd803947027830631a4cd8c6135334036f14ac996cd9ce5119a2.json │ │ │ ├── 9cac12079d625e44ef3f62c6e4c9c7848f1cdd4d086c9251b3c81101cf4c114e.json │ │ │ ├── 9ce16f69c6b04f703adb24ec7498a2d2ecbf6f7524c459deb25111247e550698.json +│ │ │ ├── 9ced19a2c0c7eb3f36febda525c01ee787f5e5f3af0933a532074258309b167b.json │ │ │ ├── 9d0e2a1fb14529aabd31c82c8d8f5543c0068366932934038ba93e234f291e22.json │ │ │ ├── 9d3a68c9991464131299b924086faf1e0c474c156019d681ba41e64d8100a7b1.json │ │ │ ├── 9dd5b6a7f155cef3a16a5dc01a1f0cbd458d129e379b93566b68f9c366f21aeb.json @@ -678,12 +766,18 @@ customerTicket │ │ │ ├── a04a8ed5ca0ac9661dda4f6f369248937cf67e61cc982a1f17b24495263d53d6.json │ │ │ ├── a081526ffd8f52690e0834cc71e9d2bf987f949c2d0de68a6eb024063d97e64b.json │ │ │ ├── a130f47959c058751c2afdefa119ce88d7c59fa65883f7e0a69099a022033d4d.json +│ │ │ ├── a14c8d1646ade56cdc65885029fe1219a3ad34721276c86d0086b2c5ba63cf3a.json │ │ │ ├── a1bd5a32741129c967a18413ae1be6bf3e20754251066d342d652a668ef95025.json +│ │ │ ├── a1bf8757307620bdf8ec85dc9ed54370649324109e6fd40075acefe3e304c88a.json │ │ │ ├── a2fea28bcbfaa240ed1524c5f422481c263eb6f010e0e06ddf8e45c7d1d88e6d.json +│ │ │ ├── a31c3a51f3dda4d4231fb5a6d17090edda317ee168e35b23b0c8ffd09e3f1e15.json │ │ │ ├── a3c770875ffabc18c595532501255df82b1a09246ee54b831e7c2221932d4f88.json │ │ │ ├── a3f339fe1ccb99a051c1a3bff70e7de0e2ee6b952db0aa2d6739e97331c29cd9.json +│ │ │ ├── a48597143008bc272cad9fff8c2c09df65d71fe4cec49d76589737d20c2adc3b.json │ │ │ ├── a4a27e7b3842713c361895d6d9c5fc9e4341cce71c5842f014748e1c1c23a197.json │ │ │ ├── a4a568f58efea076f16698bb9bb6279a92ab44167a6e97d424fff44900ff973e.json +│ │ │ ├── a51a42c75dfee2196ae2aab4c061ab07bb30bd1bdab5e6f68ff3e22d7da1f871.json +│ │ │ ├── a51bc493a9af4b7b946f4b9cfeae33bb56c5af747c6ea2454e81e9105361ac0b.json │ │ │ ├── a5231d68fe35e6b02ff4ff70e02a3229c08d9b6c2bae774a1b3227823b95e15a.json │ │ │ ├── a558b874a8c8e3baac9b3c2f6c0aa82abe95057cc86115aa8ba60cf2b0965abf.json │ │ │ ├── a614593a975a3af1612ede797f69fd29220ee9251ab016d34d5e612a04696ce9.json @@ -694,23 +788,29 @@ customerTicket │ │ │ ├── a8274003d8d4ef62b23c66e3baecf14eea260f99a4509411d58506f560c2b474.json │ │ │ ├── a836a06de0905683ff2aee057760fe6e4a3f9161d0cb60544fb7afc9ce782671.json │ │ │ ├── a93b84a0def4d2e0e97ff35032871b0ce64786ff468912aed21fd958b9fe782b.json +│ │ │ ├── aa55d877d67523f73fb5426f1e9de1c3224456e73515121885ae326c6bed434f.json │ │ │ ├── ab1648cc3b4d4bba5754e19eeb80ea160fd20207d5530ac371647168f9ed211a.json │ │ │ ├── ab90a45c416c5da4e2e4506ea08cee8fd010acc608a718fee1985213112bf776.json │ │ │ ├── abaaf1c3e88b939bf1833294f5233fa8e7c94091077495c424a0779298cc885d.json +│ │ │ ├── abf7b1f16f646d3846b5cff929107d533600504756199d90267f299148d00cbf.json │ │ │ ├── ac454f6bfed5913148eb8c9caaa0e30f99bac381936b44612bdee8274bb27b29.json │ │ │ ├── ad1175b041ab42a2615856a41fbab394ae63ef9dd991901c0658c3fb0846ad3f.json │ │ │ ├── ae4d845b7155a0e8a73ab943ab7c4524cd6e73c3f90be008edb478c968d4fd9c.json │ │ │ ├── aeb59c5e1447023f9c49003f6bf4eb26a7b9a34ca5658f7ba2cde1b99e25ec76.json │ │ │ ├── aee78d4e87210ef46180d47e7015345bd66a7763cd140d84cd86539e720e8e67.json +│ │ │ ├── af8910b042526b71500ba7a2e8de889cd8e2fa522b0c8ce45c56b63f206e8da1.json │ │ │ ├── afa89caafcdde5a4d7d85aad1db8c0abaddcebf884d84e8d23c50556b136b1a9.json +│ │ │ ├── b00d36db9926c6e18f7a28ed299a8439188a3597a31dcd2e4c476044631b0808.json │ │ │ ├── b0f297dddb05ea13b6bade486a2e88b6746c3c727b9ddfbd640942776b2e2cfb.json │ │ │ ├── b0f3c37fe7f5327da6c37161e405561df7eb920611513b0f7ec544e30763a312.json │ │ │ ├── b137ac0b92c8567809fb0323cd320fbf60c45ae6fad1bbdfb10d6efb22989f0b.json +│ │ │ ├── b18617b602ceed65a2741da0eb8f93e4a0296233c96e6a4160f735fe1b6d7ee7.json │ │ │ ├── b390f7bae9f80c48e703a6664d9de6ec9c141cdf8ac2f012d67ac2b4d8a25b4e.json │ │ │ ├── b396b497b11e135cfc19ccbee84e3e67df0e401a412f51c0336c89cb3609b5f5.json │ │ │ ├── b3b223f1a3b058d17a3c0a0e8cb95b4ff2cd54bc57bc5f57f0fd7132919e7007.json │ │ │ ├── b3f59496efa1bcbf6d511580880b5dff989b379977f7649d1a59c73566a35532.json │ │ │ ├── b4446ca11a1f4f6ea45256e755ed56d6ba7c330278e15ead8cae2de28d5e18d8.json +│ │ │ ├── b49c628bcd67f1930070ae2de9f370225f2326665544c87bcc9fb443729879cf.json │ │ │ ├── b5b7236fe4db451727eafb0dc2e904fd860f9000b44f898ce55f8ac8709ecee4.json │ │ │ ├── b6e731246dc10854a0fa0455c3c5816e29887b1a8c436d518926e1c2a5e36239.json │ │ │ ├── b7014ccf0ecd3cdccb86d9ac37bd58c5ca00b59d2de58a194eb66ac139e14a5c.json @@ -719,11 +819,13 @@ customerTicket │ │ │ ├── b96845015841d34aa0edcc02a6a51a4ec5f77bd6827d5e9bf8ca6f620c8de3dd.json │ │ │ ├── bb3d09cc5d1bc380e841e2eb19cb497391117101c73c358e6db03db4d6ae51e7.json │ │ │ ├── bb78c0a0d3cbee2cd3238fcc9a1c14cb8bd9ce97a78de60af3aa49f132f966a8.json +│ │ │ ├── bc7ff4cd0f26e1e11e3d5e05323b9ede1f540e452bfd40c7d400931c104ac191.json │ │ │ ├── be227af94c0ffcf8abcd019a59577ad2aa5807dffa85ff8c1e8ef2e8de5ec8a9.json │ │ │ ├── beab35cee5ff6b070f62aef30a8db07392e21e64dc733c643575a0c6a7387ffc.json │ │ │ ├── bf759ce9656625911599b351f64028b9bf0bcdcf4adc084e96e9cccc80bbe50c.json │ │ │ ├── c1060dca2a3f437f386324975be659215fe9f7e689137dc85e823879b462d11f.json │ │ │ ├── c10793a36312ddf2bd8ed9edbae5d67a19f54b78ac3cae01682d38c34f2bdf11.json +│ │ │ ├── c11eda2ccb808eeb7890628c3e9964ef24745f46ac0d9d503348cc5ad161bbdc.json │ │ │ ├── c199fcf03e3df0de15c0e7fa5f89fbd9649f2be4a257c8de9a7ebc69ce0d629a.json │ │ │ ├── c1a36d35264d786572e00bd20a4d62c18000e4a5a744b4bbd585c66ba2b1afa4.json │ │ │ ├── c251d835538bf3c08a96268c8b1feaae8084a9c298d634ce2f2ee3b4b15188ce.json @@ -732,6 +834,7 @@ customerTicket │ │ │ ├── c478d961844d415bd36c75ab4921d7bafdc9419a9e0bf6a1efc44f14130e50fe.json │ │ │ ├── c4878d8d8ae19e705821ae40afc1ede4be1a64b559dfdf89f898cb4383718c93.json │ │ │ ├── c4a56e0890c5b5bdc963949bff54e8a268081652516ec17e155781a93c56b390.json +│ │ │ ├── c4e73c9f9c0baae99d7c40979cdd7f558410145e6251485932cc8bfab0eeaac2.json │ │ │ ├── c6c7b21caf5043dfdf6c25863bf23da656645640cf2ccd914c223f8984a1b3ef.json │ │ │ ├── c8e562a594b215d21999461d5c5fc0894fef0eec4ed7a7ea5b975df305f3d53b.json │ │ │ ├── ca033fb139fdaaaa0d083b9189e67868f0da87d0f954a863d2031c5955075e8e.json @@ -748,12 +851,17 @@ customerTicket │ │ │ ├── cdad1106fd648fc1148b348097481d04bdf5b311be1fe4edd5274162f9348496.json │ │ │ ├── ce52c1ffbf8db0d0b4ba85e0a07055258a8eb179db097f1df874e5be81aae49c.json │ │ │ ├── ce640fd27445864e6f2c38659c24f873a702c7a669a2f0b55df81d62ce26b4ed.json +│ │ │ ├── cfd75231baee1115b6e831e2fcc2667125147027bec8da0809c09a3b3aceb380.json +│ │ │ ├── d00fe3331efb7013ad62827667fc96174d3262c7feee151ec698f9107a1ed211.json │ │ │ ├── d083dc75811629ffa77ea30c1965e67ef3bc2bf65e5b20a1a35cb0f80a46949c.json │ │ │ ├── d0d623b82da6c3e0f37522e8ddea038c4a1415d50059372915009dcee6628ce1.json │ │ │ ├── d188f9185e71f8910607d275d82fea22199ba0e6009a5b1e34e5a20f041f0d71.json +│ │ │ ├── d1d06194e0bbf89054b6372d57be153b1363f032d13be6eb70e35e9e63e42ed0.json │ │ │ ├── d2513d4f5ff7e6088864ff497613f47d4083c1ca378649a3f1db6d45a3f3fb2b.json │ │ │ ├── d26a8fc663e9e5fcf33c87592f8c0456e7fe9ec3390bcb902631144d455c2922.json +│ │ │ ├── d2a5e30e7da9c632d50b38210dae96730a0f7185f67de65215aed18208c76d19.json │ │ │ ├── d449c32636f32d9267c66232be6123566cb92d0352de164bd4b01343c1ce75d7.json +│ │ │ ├── d45a1d1873fe984cb4e7a8ddaaa8f5385d32a4cc0f8e893d0c12f936d6a91b26.json │ │ │ ├── d54677c4320658676f86c09348933f298192a048b637aef94d607eea8a92b474.json │ │ │ ├── d55e8987ca5ff35748b62290dc14150b1df53163c6f5a5cbe19d03058368f1c6.json │ │ │ ├── d561ececcdd4e7ff59595389fa499591f4e7f3c5324a58dbdd749ccf29e90cd1.json @@ -775,6 +883,9 @@ customerTicket │ │ │ ├── dea57fa4a89ef99a815bd9b1bb71b8475727a3d7d88161a9a696ebf636f29ed0.json │ │ │ ├── df2d803fc69084e91df819bda27a7e9f7303a40ff7a5d30d93fbc16daf5e2d45.json │ │ │ ├── dfc9279b0d9023afc1dbe55536fc1cd7a556a6a437775128b9e41615431b4733.json +│ │ │ ├── e00e2e63606d8a49d1669a78789e6be1dc4d2aa78365ce58439e1d7c7975e2bf.json +│ │ │ ├── e247b821b3662d49d7381b743644858c0d14fd5b7bed224964c292f7ce9daad3.json +│ │ │ ├── e2870691d22d4ddbb76733d2050bafd675615cd02645a023b1090f04b499cb11.json │ │ │ ├── e2f4f5c0bcb971ba3cb08a4ba14ed5d1ba6b5899fd36db1f5dcdbd2313cd318e.json │ │ │ ├── e359eb0e19f76b69decc0ec747a7fb4c9843046bda31c5e45841858e01bcb5a7.json │ │ │ ├── e37b6b5144067b3650a3e1045655f367e7027ac2f97a8d8c19ce2e5c5862cea0.json @@ -788,6 +899,7 @@ customerTicket │ │ │ ├── e831be2a985dcf2094a014d182ea02e4ee10b94fbd35b44e3c69a98a7acb3451.json │ │ │ ├── e8f3b285df9ff4d30c78eb1eded3eefdd86cfe325a7c8c29fb8457b6dde5e712.json │ │ │ ├── e90aa709aa08fcf3294352b588ce8c8c902f3ef59a503d4b8d9a292a9c478096.json +│ │ │ ├── e9475289c4f92913d5288a655bc50a16c4145e9d0bc9707af427725445dc0049.json │ │ │ ├── e974cd0006d8160a6ef1316083bac9e699adfd87acc7edb404ae7eba1c799b68.json │ │ │ ├── e99791dc7ccf0fde796771290e06e6ea13ecf4d24044945c735f3e9f470934fd.json │ │ │ ├── e9a8155f1a3546e434485a2aca7a04c6857071bfcfd5a7317a62472d90919b9d.json @@ -795,12 +907,14 @@ customerTicket │ │ │ ├── ea3c12254259478e0d71c386203dd7263d9f836d6a6489481bc0deff9b69dc62.json │ │ │ ├── ea56445d1314587e18b1992d9599da03e1419ea7bc4929854b9039d6947de33b.json │ │ │ ├── ea8564b376c7156927c3f412f1a7173248d6b2fd7dcfc3492c4c9d7c44dae889.json +│ │ │ ├── eaa257debf517058e8edd49576d57df9f7790772c02a633d99cff4aa966fa9b0.json │ │ │ ├── eb1dd65efa5a99811ccca80ff03a73cb155ad86d13635010a44c09c13953604d.json │ │ │ ├── eb782e7a5b5695997a5bf628a22f4d36f5ddb886e5bdf4b2f4ae53ecc46fbca1.json │ │ │ ├── ebc76bd945a2106c6bbe01b3cc09eb40e65b08aedd7c49709a38ad63511082ad.json │ │ │ ├── ec7939ed122c473ca1a4eee50f1c564e04e95ddced18cca56ed83efe5d4071dd.json │ │ │ ├── ecb32b5c560f5a4cad0801cd49c9a21fe4d19d7638eb5382b73610878c360d00.json │ │ │ ├── ed61ddacfc8c05f7392be053291989b55a01c1bfa5a1dbebbc4bc6e706370f53.json +│ │ │ ├── ede449b4cefa55e7ef8bdb457673c28fc3a833184e0e7a0e838e4e658d8ea883.json │ │ │ ├── ee19f3245c11f658fb60d32050a8bc4864ea289071ff5f829717ecacbac1d3e4.json │ │ │ ├── ee89093aa638ec9d38b76ba7adb66c8424d941d79deba2913d3d7085c1a062f7.json │ │ │ ├── effec069fa1e0bba5e3b055368ccbb033f964a968890d7efe7dc3fa9ae4f9c0f.json @@ -1057,6 +1171,7 @@ customerTicket │ ├── db-before-announcement-fix-20261004-115635.sqlite3 │ ├── db-before-deposit-normalization-20261007-113436.sqlite3 │ ├── db-before-payment-links-20261006.sqlite3 +│ ├── db-before-receipt-customer-details-20261011.sqlite3 │ ├── db-before-staff-sharing.sqlite3 │ ├── db-pre-chaharsu.sqlite3 │ ├── deposit-sharing-live-1440.png @@ -1155,6 +1270,7 @@ customerTicket │ │ ├── customer-profile.css │ │ ├── customer-ticket-wizard.css │ │ ├── customer-tickets.css +│ │ ├── dispatch-workspace.css │ │ ├── dispatches.css │ │ ├── enhancements.css │ │ ├── magic-link-confirmation.css @@ -1182,6 +1298,7 @@ customerTicket │ │ │ ├── deposit-identifier.js │ │ │ ├── payments.js │ │ │ ├── receipt-amount.js +│ │ │ ├── receipt-decision.js │ │ │ ├── receipt-form.js │ │ │ └── receipt-upload.js │ │ ├── attachment-uploader.js @@ -1190,6 +1307,7 @@ customerTicket │ │ ├── conversation-read.js │ │ ├── conversation.js │ │ ├── customer-ticket-wizard.js +│ │ ├── dispatch-workspace.js │ │ ├── image-viewer.js │ │ ├── location-picker.js │ │ ├── magic-link-confirmation.js @@ -1227,6 +1345,7 @@ customerTicket │ │ ├── _customer_identity.html │ │ ├── _customer_picker.html │ │ ├── _customer_receipt_form.html +│ │ ├── _daily_summary.html │ │ ├── _field.html │ │ ├── _icon.html │ │ ├── _link_status.html @@ -1255,6 +1374,7 @@ customerTicket │ ├── tickets │ │ ├── _announcement_status.html │ │ ├── _customer_dock.html +│ │ ├── _dispatch_icon.html │ │ ├── _image_viewer.html │ │ ├── _immediate_announcement.html │ │ ├── _messages.html @@ -1273,6 +1393,7 @@ customerTicket │ │ ├── customer_detail.html │ │ ├── customer_list.html │ │ ├── customer_new.html +│ │ ├── dispatch_open_confirm.html │ │ ├── staff_contacts.html │ │ ├── staff_create.html │ │ ├── staff_customers.html @@ -1299,6 +1420,36 @@ customerTicket │ │ ├── tickets-1440.png │ │ ├── tickets-320.png │ │ └── tickets-393.png +│ ├── dispatch-design +│ │ ├── compose-1440.png +│ │ ├── compose-320.png +│ │ ├── compose-390.png +│ │ ├── live-create-1440.png +│ │ ├── live-create-320.png +│ │ ├── live-create-390.png +│ │ ├── live-history-1440.png +│ │ ├── live-report-1440.png +│ │ ├── live-report-320.png +│ │ ├── live-report-390.png +│ │ ├── migrate_local_database.py +│ │ ├── opened-report-1440.png +│ │ ├── opened-report-320.png +│ │ ├── opened-report-390.png +│ │ ├── opening-confirm-1440.png +│ │ ├── opening-confirm-320.png +│ │ ├── opening-confirm-390.png +│ │ ├── quality-delta.xml +│ │ ├── report-1440.png +│ │ ├── report-320.png +│ │ ├── report-390.png +│ │ ├── review-1440.png +│ │ ├── review-320.png +│ │ ├── review-390.png +│ │ ├── review-inquiry-recommendation.png +│ │ ├── sealed-report-1440.png +│ │ ├── sealed-report-320.png +│ │ ├── sealed-report-390.png +│ │ └── test-gate.xml │ ├── e2e │ │ ├── design-prototypes_ticket-new-wizard_test_prototype_e2e.py__test_ticket_wizard_progressive_flow_preview_edit_and_attachment_removal │ │ │ ├── failure.png @@ -1336,6 +1487,18 @@ customerTicket │ │ ├── tests_e2e_test_customer_unit_categories_e2e.py__test_customer_switches_unit_and_sees_its_categories │ │ │ ├── failure.png │ │ │ └── trace.zip +│ │ ├── tests_e2e_test_dispatch_response_opening_e2e.py__test_sealed_report_requires_confirmation_and_opens_answers_together[1440] +│ │ │ ├── failure.png +│ │ │ └── trace.zip +│ │ ├── tests_e2e_test_dispatch_workspace_e2e.py__test_report_filters_search_and_private_reply_navigation[390] +│ │ │ ├── failure.png +│ │ │ └── trace.zip +│ │ ├── tests_e2e_test_dispatch_workspace_e2e.py__test_review_preserves_draft_and_requires_confirmation_before_group_send[320] +│ │ │ ├── failure.png +│ │ │ └── trace.zip +│ │ ├── tests_e2e_test_dispatch_workspace_e2e.py__test_review_preserves_draft_and_requires_confirmation_before_group_send[390] +│ │ │ ├── failure.png +│ │ │ └── trace.zip │ │ ├── tests_e2e_test_magic_link_confirmation_e2e.py__test_cancel_returns_to_logged_in_account │ │ │ ├── failure.png │ │ │ └── trace.zip @@ -1438,6 +1601,12 @@ customerTicket │ │ ├── tests_e2e_test_pwa_versioning.py__test_pwa_registers_versioned_worker_and_matching_cache │ │ │ ├── failure.png │ │ │ └── trace.zip +│ │ ├── tests_e2e_test_receipt_customer_details_e2e.py__test_required_payer_on_every_receipt_entry_point[link] +│ │ │ ├── failure.png +│ │ │ └── trace.zip +│ │ ├── tests_e2e_test_receipt_customer_details_e2e.py__test_staff_can_cancel_then_confirm_rejection_sms +│ │ │ ├── failure.png +│ │ │ └── trace.zip │ │ ├── tests_e2e_test_receipt_upload_e2e.py__test_receipt_upload_progress_processing_and_single_submission[link-320] │ │ │ ├── failure.png │ │ │ └── trace.zip @@ -1468,12 +1637,14 @@ customerTicket │ │ │ ├── customer_new_ticket.py │ │ │ ├── customer_profile.py │ │ │ ├── customer_tickets.py +│ │ │ ├── dispatches.py │ │ │ ├── magic_link.py │ │ │ ├── message_reads.py │ │ │ ├── notice_deletion.py │ │ │ ├── payment_feedback.py │ │ │ ├── payment_link.py │ │ │ ├── payments.py +│ │ │ ├── receipt_customer_details.py │ │ │ ├── receipt_upload.py │ │ │ ├── sharing.py │ │ │ ├── staff_payments.py @@ -1486,17 +1657,21 @@ customerTicket │ │ ├── test_customer_profile_design.py │ │ ├── test_customer_tickets_design.py │ │ ├── test_customer_unit_categories_e2e.py +│ │ ├── test_dispatch_response_opening_e2e.py +│ │ ├── test_dispatch_workspace_e2e.py │ │ ├── test_external_environment_smoke.py │ │ ├── test_magic_link_confirmation_e2e.py │ │ ├── test_message_read_receipts_e2e.py │ │ ├── test_mobile_ticket_viewport.py │ │ ├── test_notice_deletion_e2e.py │ │ ├── test_organization_permissions_e2e.py +│ │ ├── test_payment_daily_summary_e2e.py │ │ ├── test_payment_feedback_e2e.py │ │ ├── test_payment_links_e2e.py │ │ ├── test_payments_e2e.py │ │ ├── test_public_ticket_workflows_e2e.py │ │ ├── test_pwa_versioning.py +│ │ ├── test_receipt_customer_details_e2e.py │ │ ├── test_receipt_upload_e2e.py │ │ ├── test_staff_operations_ui.py │ │ ├── test_staff_payment_sms_e2e.py @@ -1509,6 +1684,9 @@ customerTicket │ ├── test_customer_category_catalog.py │ ├── test_customer_unit_categories.py │ ├── test_deployment.py +│ ├── test_dispatch_opening_postgres.py +│ ├── test_dispatch_presentation.py +│ ├── test_dispatch_response_opening.py │ ├── test_domain.py │ ├── test_env.py │ ├── test_jalali_templates.py @@ -1516,6 +1694,7 @@ customerTicket │ ├── test_message_read_receipts.py │ ├── test_organizational_ticketing.py │ ├── test_payment_concurrency.py +│ ├── test_payment_daily_summary.py │ ├── test_payment_links.py │ ├── test_payment_notice_concurrency.py │ ├── test_payment_notice_deletion.py @@ -1524,6 +1703,7 @@ customerTicket │ ├── test_permission_architecture.py │ ├── test_persian_text.py │ ├── test_production_seed.py +│ ├── test_receipt_customer_details.py │ ├── test_role_workflow_regression.py │ ├── test_staff_inline_sharing.py │ ├── test_staff_payment_sms.py diff --git a/apps/attachments/admin.py b/apps/attachments/admin.py index fb6566f..3174f52 100644 --- a/apps/attachments/admin.py +++ b/apps/attachments/admin.py @@ -54,4 +54,5 @@ class AttachmentPolicyAdmin(admin.ModelAdmin): form = AttachmentPolicyAdminForm -admin.site.register(Attachment) +from apps.tickets.response_admin import RelatedResponseContentAdmin +admin.site.register(Attachment, RelatedResponseContentAdmin) diff --git a/apps/attachments/delivery.py b/apps/attachments/delivery.py index 48c2610..4b66dc4 100644 --- a/apps/attachments/delivery.py +++ b/apps/attachments/delivery.py @@ -19,3 +19,16 @@ def signed_media_redirect(relative_path, *, download=False, lifetime=300): if download: query["download"] = "1" return HttpResponseRedirect(f"{uri}?{urlencode(query)}") + + +def attachment_response(attachment, *, download=False): + """Deliver authorized bytes; confidential responses never use bearer URLs.""" + import mimetypes + from django.http import FileResponse + if settings.USE_NGINX_MEDIA and not attachment.relative_path.startswith("dispatch-responses/"): + return signed_media_redirect(attachment.relative_path, download=download) + content_type = attachment.mime_type or mimetypes.guess_type(attachment.original_name)[0] or "application/octet-stream" + response = FileResponse(attachment.file.open("rb"), content_type=content_type, + as_attachment=download, filename=attachment.original_name) + response["X-Content-Type-Options"] = "nosniff" + return response diff --git a/apps/attachments/models.py b/apps/attachments/models.py index 143d8af..16a740d 100644 --- a/apps/attachments/models.py +++ b/apps/attachments/models.py @@ -5,6 +5,10 @@ from django.db import models from apps.core.models import PublicIdModel def attachment_path(instance,filename): ext=Path(filename).suffix.lower(); t=instance.message.ticket; m=instance.message + from apps.tickets.dispatch_responses import response_control + control = response_control(t.group_dispatch) if t.group_dispatch_id else None + if m.author_id == t.customer_id and control and control.mode == "SEALED": + return f"dispatch-responses/{t.public_id}/{m.public_id}/{uuid.uuid4().hex}{ext}" return f"tickets/{t.created_at:%Y/%m}/{t.public_id}/{m.public_id}/{uuid.uuid4().hex}{ext}" class AttachmentPolicy(models.Model): attachment_type=models.CharField(max_length=20,unique=True); is_enabled=models.BooleanField(default=True); max_file_size=models.PositiveBigIntegerField(); max_files_per_message=models.PositiveSmallIntegerField(default=10); allowed_extensions=models.JSONField(default=list); allowed_mime_types=models.JSONField(default=list); sort_order=models.PositiveIntegerField(default=0); updated_at=models.DateTimeField(auto_now=True) diff --git a/apps/attachments/views.py b/apps/attachments/views.py index 1b4302e..3936a39 100644 --- a/apps/attachments/views.py +++ b/apps/attachments/views.py @@ -1,30 +1,23 @@ -import mimetypes -from django.conf import settings from django.core.exceptions import PermissionDenied -from django.http import FileResponse from django.shortcuts import get_object_or_404 from apps.tickets.policies import TicketPolicy from apps.accounts.magic_access import get_ticket_magic_access -from .delivery import signed_media_redirect +from .delivery import attachment_response from .models import Attachment +from django.views.decorators.cache import never_cache def _authorized_attachment(request,public_id): attachment=get_object_or_404(Attachment.objects.select_related("message__ticket"),public_id=public_id); ticket=attachment.message.ticket magic_link = None if request.user.is_authenticated else get_ticket_magic_access(request,ticket) if not TicketPolicy.can_view(request.user,ticket) and not magic_link: raise PermissionDenied + from apps.tickets.dispatch_responses import can_read_response + actor = ticket.customer if magic_link else request.user + if not can_read_response(actor, attachment.message): raise PermissionDenied if attachment.message.is_internal and (magic_link or getattr(request.user,"account_type",None)=="CUSTOMER"): raise PermissionDenied if attachment.message.deleted_at and not request.user.has_perm("messaging.view_deleted_message_content"): raise PermissionDenied return attachment +@never_cache def view(request,public_id): - attachment=_authorized_attachment(request,public_id) - if settings.USE_NGINX_MEDIA: - return signed_media_redirect(attachment.relative_path) - content_type=attachment.mime_type or mimetypes.guess_type(attachment.original_name)[0] or "application/octet-stream" - response=FileResponse(attachment.file.open("rb"),content_type=content_type) - response["Content-Disposition"]=f'inline; filename="{attachment.original_name}"' - response["X-Content-Type-Options"]="nosniff" - return response + return attachment_response(_authorized_attachment(request, public_id)) +@never_cache def download(request,public_id): - attachment=_authorized_attachment(request,public_id) - if settings.USE_NGINX_MEDIA: - return signed_media_redirect(attachment.relative_path, download=True) - return FileResponse(attachment.file.open("rb"),as_attachment=True,filename=attachment.original_name) + return attachment_response(_authorized_attachment(request, public_id), download=True) diff --git a/apps/core/context_processors.py b/apps/core/context_processors.py index ed0c99b..024d5bf 100644 --- a/apps/core/context_processors.py +++ b/apps/core/context_processors.py @@ -64,7 +64,7 @@ def staff_operations_access(request): "create_ticket": user.has_perm("tickets.add_ticket") or allowed("tickets.add_ticket"), "contacts": True, "contact_groups": allowed("customers.view_contactgroup") or allowed("customers.add_contactgroup"), - "dispatches": allowed("tickets.create_bulk_dispatch") or allowed("tickets.create_inquiry") or allowed("tickets.dispatch_group_tickets"), + "dispatches": any(allowed(permission) for permission in ("tickets.create_bulk_dispatch", "tickets.create_inquiry", "tickets.dispatch_group_tickets", "tickets.view_all_bulk_dispatches", "tickets.view_all_inquiries")), "templates": allowed("notifications.view_smstemplate") or allowed("notifications.add_smstemplate"), "audit": user.has_perm("accounts.view_all_magic_links") or allowed("accounts.view_ticketmagiclink"), }} diff --git a/apps/core/management/commands/import_production_seed.py b/apps/core/management/commands/import_production_seed.py index 066fb38..ad0316c 100644 --- a/apps/core/management/commands/import_production_seed.py +++ b/apps/core/management/commands/import_production_seed.py @@ -16,6 +16,14 @@ from apps.core.production_seed import sha256_file from apps.tickets.models import Ticket +def _load_seed_data(bundle_dir): + # Only the pristine-target guard in handle authorizes this replacement. + call_command("flush", interactive=False, verbosity=0) + call_command("loaddata", str(bundle_dir / "data.json.gz"), verbosity=1) + from apps.notifications.dispatch_opening import ensure_opening_template + ensure_opening_template() + + class Command(BaseCommand): help = "Import a verified production seed bundle into a pristine PostgreSQL database." @@ -80,10 +88,7 @@ class Command(BaseCommand): copied_files = [] try: with transaction.atomic(): - # Migrations contain defaults. Flush those rows before loading - # the authoritative SQLite snapshot; schema/migration history remains. - call_command("flush", interactive=False, verbosity=0) - call_command("loaddata", str(bundle_dir / "data.json.gz"), verbosity=1) + _load_seed_data(bundle_dir) # Old SQLite snapshots may predate unit-scoped queues. A # category already belongs to exactly one unit, so it is a # deterministic and safe source for repairing those rows. diff --git a/apps/core/production_seed.py b/apps/core/production_seed.py index fc759e2..2fb199b 100644 --- a/apps/core/production_seed.py +++ b/apps/core/production_seed.py @@ -16,6 +16,7 @@ SEED_MODEL_LABELS = ( "tickets.SLAConfig", "tickets.TicketTag", "tickets.GroupDispatch", + "tickets.DispatchResponseControl", "tickets.Ticket", "tickets.TicketAccessGrant", "tickets.TicketEvent", diff --git a/apps/core/templatetags/jalali.py b/apps/core/templatetags/jalali.py index 8709dcc..21e3692 100644 --- a/apps/core/templatetags/jalali.py +++ b/apps/core/templatetags/jalali.py @@ -46,6 +46,14 @@ def jalali_date(value): return _persian_digits(converted.strftime("%d %B %Y")) if converted else "" +@register.filter +def jalali_datetime_precise(value): + converted = _jalali(value) + if not isinstance(converted, jdatetime.datetime): + return "" + return _persian_digits(converted.strftime("%d %B %Y، ساعت %H:%M:%S")) + + @register.filter def persian_time(value): """Display local Iran time using Persian digits.""" diff --git a/apps/messaging/admin.py b/apps/messaging/admin.py index dc657c0..4cee63a 100644 --- a/apps/messaging/admin.py +++ b/apps/messaging/admin.py @@ -1,3 +1,5 @@ from django.contrib import admin from .models import Message,MessageMention,MessageRevision -admin.site.register([Message,MessageMention,MessageRevision]) +from apps.tickets.response_admin import ResponseContentAdmin, RelatedResponseContentAdmin +admin.site.register(Message, ResponseContentAdmin) +admin.site.register([MessageMention, MessageRevision], RelatedResponseContentAdmin) diff --git a/apps/messaging/services.py b/apps/messaging/services.py index 3b62e41..2bb1cee 100644 --- a/apps/messaging/services.py +++ b/apps/messaging/services.py @@ -6,6 +6,8 @@ from apps.tickets.policies import TicketPolicy from .models import Message,MessageRevision @transaction.atomic def edit_message(message_id,user,body): + from apps.tickets.dispatch_responses import lock_dispatch_for_ticket + lock_dispatch_for_ticket(Message.objects.values_list("ticket_id", flat=True).get(pk=message_id)) msg=Message.objects.select_for_update().select_related("ticket").get(pk=message_id) if msg.author_id!=user.id or not TicketPolicy.can_view(user,msg.ticket): raise PermissionDenied if not (user.account_type=="CUSTOMER" or user.has_perm("messaging.edit_own_message")): raise PermissionDenied @@ -15,6 +17,8 @@ def edit_message(message_id,user,body): MessageRevision.objects.create(message=msg,body=msg.body,edited_by=user); msg.body=body; msg.edited_at=timezone.now(); msg.save(update_fields=["body","edited_at","updated_at"]); return msg @transaction.atomic def soft_delete_message(message_id,user): + from apps.tickets.dispatch_responses import lock_dispatch_for_ticket + lock_dispatch_for_ticket(Message.objects.values_list("ticket_id", flat=True).get(pk=message_id)) msg=Message.objects.select_for_update().select_related("ticket").get(pk=message_id) may_delete=user.account_type=="CUSTOMER" or user.has_perm("messaging.delete_own_message") if msg.author_id!=user.id or not may_delete or not TicketPolicy.can_view(user,msg.ticket): raise PermissionDenied diff --git a/apps/messaging/views.py b/apps/messaging/views.py index 01b7ec2..2ed7116 100644 --- a/apps/messaging/views.py +++ b/apps/messaging/views.py @@ -23,6 +23,8 @@ def delete(request,public_id): msg=get_object_or_404(Message,public_id=public_id); soft_delete_message(msg.pk,request.user); return redirect(_safe_next(request,"/staff/")) @login_required def revisions(request,public_id): + from apps.tickets.dispatch_responses import can_read_response msg=get_object_or_404(Message,public_id=public_id) + if not can_read_response(request.user, msg): raise PermissionDenied if not request.user.has_perm("messaging.view_message_revisions") or not TicketPolicy.can_view(request.user,msg.ticket): raise PermissionDenied return render(request,"messaging/revisions.html",{"message":msg,"revisions":msg.revisions.select_related("edited_by")}) diff --git a/apps/notifications/dispatch_opening.py b/apps/notifications/dispatch_opening.py new file mode 100644 index 0000000..fd535da --- /dev/null +++ b/apps/notifications/dispatch_opening.py @@ -0,0 +1,42 @@ +"""Isolated worker handler; existing events keep their delivery flow.""" +from django.core.exceptions import ValidationError +from apps.tickets.models import DispatchResponseControl + + +DEFAULT_OPENING_SMS = ( + "لوله گستر گلپایگان با سلام؛ پاسخ استعلام شما بازگشایی شد. جهت صیانت از رقابت عادلانه، تمامی پیشنهادات تا این لحظه کاملاً محرمانه بوده و هیچ‌کس دسترسی به مشاهده آن‌ها نداشته است. اکنون تمامی پاسخ‌ها به‌صورت هم‌زمان بازگشایی شدند تا سلامت فرآیند تضمین گردد.\n" + "از همراهی و اعتماد شما سپاسگزاریم. واحد فناوری اطلاعات لوله گستر گلپایگان" +) + + +def ensure_opening_template(): + """Legacy seed bundles may predate this event; never replace an existing body.""" + from .models import SMSTemplate + return SMSTemplate.objects.get_or_create(unit=None, event_type="DISPATCH_RESPONSES_OPENED", defaults={ + "title": "بازگشایی پاسخ‌های ارسال گروهی", "body": DEFAULT_OPENING_SMS, "allowed_tags": [], "is_active": True, + })[0] + + +def send_opening_sms(job, provider_factory): + control = DispatchResponseControl.objects.get(dispatch_id=job.payload.get("dispatch_id")) + permitted = (control.mode == "SEALED" and control.notify_sms + and control.recipient_count_snapshot <= 15 and control.opened_at + and job.ticket_id and job.ticket.group_dispatch_id == control.dispatch_id + and job.customer_id == job.ticket.customer_id + and str(job.customer.public_id) in control.respondents_snapshot) + if not permitted or job.payload.get("body") != control.sms_body_snapshot: + raise ValidationError("Opening notification does not match its authorized snapshot.") + return provider_factory().send_message(job.customer.mobile, control.sms_body_snapshot) + + +def send_dispatch_reply_bale(job, text, provider_factory): + """Dispatch metadata goes to its creator's private destination, never a unit room.""" + from apps.tickets.policies import DispatchPolicy + dispatch = job.ticket.group_dispatch + owner = dispatch.created_by + profile = getattr(owner, "staff_profile", None) + if not owner.is_active or not profile or not profile.is_active or not profile.bale_notifications_enabled: + return None + if not profile.bale_chat_id.strip() or not DispatchPolicy.can_view(owner, dispatch): + return None + return provider_factory().send_message(profile.bale_chat_id.strip(), text) diff --git a/apps/notifications/management/commands/process_notifications.py b/apps/notifications/management/commands/process_notifications.py index 6d72859..a6939e3 100644 --- a/apps/notifications/management/commands/process_notifications.py +++ b/apps/notifications/management/commands/process_notifications.py @@ -58,6 +58,9 @@ def _bale_ticket_text(ticket, message=None, *, is_reply=False, message_count=1, message = message or ticket.ticket_messages.filter(is_internal=False).order_by("created_at", "id").first() raw_excerpt = " ".join((message.body if message else "").split()) excerpt = f"{raw_excerpt[:277].rstrip()}…" if len(raw_excerpt)>280 else raw_excerpt or "بدون متن" + from apps.tickets.dispatch_responses import response_control + if message and message.author_id == ticket.customer_id and ticket.group_dispatch_id and response_control(ticket.group_dispatch): + excerpt = "پاسخ ثبت شده است؛ محتوا فقط در پنل و با رعایت مجوز بازگشایی قابل مشاهده است." local_created = timezone.localtime(message.created_at if message else ticket.created_at) jalali = jdatetime.datetime.fromgregorian(datetime=local_created, locale=jdatetime.FA_LOCALE).strftime("%d %B، %H:%M") lines=[ @@ -85,6 +88,9 @@ def _prepare_sms(job): def _send_sms(job): + if job.event_type == "DISPATCH_RESPONSES_OPENED": + from apps.notifications.dispatch_opening import send_opening_sms + return send_opening_sms(job, get_sms_provider), None if job.event_type == 'PAYMENT_RECEIPT_RECEIVED': from apps.payments.receipt_alerts import send_receipt_alert return send_receipt_alert(job, sms_provider_factory=get_sms_provider), None @@ -109,6 +115,19 @@ def _send_sms(job): raise ValueError(f"Unsupported SMS event: {job.event_type}") return get_sms_provider().send_message(job.customer.mobile, message), issued +def _send_customer_reply_bale(job, unit_chat_id): + message = job.ticket.ticket_messages.filter(pk=job.payload.get("message_id"), is_internal=False).first() + if not message: + raise ValueError("Customer reply message no longer exists") + text = _bale_ticket_text(job.ticket, message, is_reply=True, + message_count=job.payload.get("message_count", 1), reopened=job.event_type == "CUSTOMER_TICKET_REOPENED") + from apps.tickets.dispatch_responses import response_control + if job.ticket.group_dispatch_id and response_control(job.ticket.group_dispatch): + from apps.notifications.dispatch_opening import send_dispatch_reply_bale + return send_dispatch_reply_bale(job, text, get_bale_provider) + return get_bale_provider().send_message(unit_chat_id, text) if unit_chat_id else None + + def _send_bale(job): if job.event_type == 'PAYMENT_RECEIPT_RECEIVED': from apps.payments.receipt_alerts import send_receipt_alert @@ -117,9 +136,7 @@ def _send_bale(job): if job.event_type == "NEW_CUSTOMER_TICKET": return get_bale_provider().send_message(unit_chat_id, _bale_ticket_text(job.ticket)) if unit_chat_id else None if job.event_type in ["CUSTOMER_TICKET_REPLY","CUSTOMER_TICKET_REOPENED"]: - message = job.ticket.ticket_messages.filter(pk=job.payload.get("message_id"), is_internal=False).first() - if not message: raise ValueError("Customer reply message no longer exists") - return get_bale_provider().send_message(unit_chat_id, _bale_ticket_text(job.ticket,message,is_reply=True,message_count=job.payload.get("message_count",1),reopened=job.event_type=="CUSTOMER_TICKET_REOPENED")) if unit_chat_id else None + return _send_customer_reply_bale(job, unit_chat_id) if not job.staff_user or not hasattr(job.staff_user, "staff_profile"): raise ValueError("Bale recipient is not configured") profile = job.staff_user.staff_profile if not profile.bale_notifications_enabled: return None diff --git a/apps/notifications/migrations/0021_dispatch_response_opening.py b/apps/notifications/migrations/0021_dispatch_response_opening.py new file mode 100644 index 0000000..4b45bcf --- /dev/null +++ b/apps/notifications/migrations/0021_dispatch_response_opening.py @@ -0,0 +1,27 @@ +# Generated by Django 5.1.1 on 2026-10-13 09:53 + +from django.db import migrations, models + + +def seed_opening_template(apps, schema_editor): + apps.get_model('notifications', 'SMSTemplate').objects.using(schema_editor.connection.alias).get_or_create( + unit=None, event_type='DISPATCH_RESPONSES_OPENED', defaults={ + 'title': 'بازگشایی پاسخ‌های ارسال گروهی', 'is_active': True, 'allowed_tags': [], + 'body': 'لوله گستر گلپایگان با سلام؛ پاسخ استعلام شما بازگشایی شد. جهت صیانت از رقابت عادلانه، تمامی پیشنهادات تا این لحظه کاملاً محرمانه بوده و هیچ‌کس دسترسی به مشاهده آن‌ها نداشته است. اکنون تمامی پاسخ‌ها به‌صورت هم‌زمان بازگشایی شدند تا سلامت فرآیند تضمین گردد.\nاز همراهی و اعتماد شما سپاسگزاریم. واحد فناوری اطلاعات لوله گستر گلپایگان', + }) + + +class Migration(migrations.Migration): + + dependencies = [ + ('notifications', '0020_receipt_rejection_template'), + ] + + operations = [ + migrations.SeparateDatabaseAndState(state_operations=[migrations.AlterField( + model_name='smstemplate', + name='event_type', + field=models.CharField(choices=[('DISPATCH_RESPONSES_OPENED', 'بازگشایی پاسخ\u200cهای ارسال گروهی'), ('CUSTOMER_TICKET_CREATED', 'ثبت تیکت توسط مشتری'), ('STAFF_TICKET_CREATED', 'ثبت تیکت توسط کارشناس'), ('STAFF_REPLY', 'پاسخ کارشناس'), ('PAYMENT_NOTICE_CREATED', 'صدور اعلامیه واریز'), ('PAYMENT_RECEIPT_APPROVED', 'تأیید نهایی فیش واریز'), ('PAYMENT_RECEIPT_REJECTED', 'رد فیش واریز'), ('UNIT_SUPERVISOR_NEW_TICKET', 'اطلاع تیکت جدید به سرپرستان واحد')], max_length=50), + )], database_operations=[]), + migrations.RunPython(seed_opening_template, migrations.RunPython.noop), + ] diff --git a/apps/notifications/models.py b/apps/notifications/models.py index 0ddfc16..392925b 100644 --- a/apps/notifications/models.py +++ b/apps/notifications/models.py @@ -10,6 +10,7 @@ def sms_template_tags(): class SMSTemplate(PersianTextNormalizationMixin, models.Model): persian_text_fields={"title":False,"body":True} class Event(models.TextChoices): + DISPATCH_RESPONSES_OPENED="DISPATCH_RESPONSES_OPENED","بازگشایی پاسخ‌های ارسال گروهی" CUSTOMER_TICKET_CREATED="CUSTOMER_TICKET_CREATED","ثبت تیکت توسط مشتری" STAFF_TICKET_CREATED="STAFF_TICKET_CREATED","ثبت تیکت توسط کارشناس" STAFF_REPLY="STAFF_REPLY","پاسخ کارشناس" @@ -29,6 +30,8 @@ class SMSTemplate(PersianTextNormalizationMixin, models.Model): def __str__(self): return self.get_event_type_display() @classmethod def tags_for_event(cls, event_type): + if event_type == "DISPATCH_RESPONSES_OPENED": + return [] if event_type in {'PAYMENT_NOTICE_CREATED', 'PAYMENT_RECEIPT_APPROVED', 'PAYMENT_RECEIPT_REJECTED'}: from apps.payments.notifications import TAGS return list(TAGS) diff --git a/apps/payments/receipt_alerts.py b/apps/payments/receipt_alerts.py index e8fa2c0..bf0e327 100644 --- a/apps/payments/receipt_alerts.py +++ b/apps/payments/receipt_alerts.py @@ -28,7 +28,9 @@ def receipt_alert_context(revision): def queue_receipt_alerts(revision, actor): - if actor.account_type != 'CUSTOMER' or actor.pk != revision.receipt.customer_id: + # Submission/revision services authorize staff before reaching this queue. + # Keep customer submissions restricted to their own receipt. + if actor.account_type != 'STAFF' and (actor.account_type != 'CUSTOMER' or actor.pk != revision.receipt.customer_id): return configuration = PaymentReceiptAlertConfiguration.objects.filter(pk=1).first() if not configuration or not (configuration.bale_enabled or configuration.sms_enabled) or (revision.version > 1 and not configuration.notify_revisions): diff --git a/apps/payments/selectors.py b/apps/payments/selectors.py index ee6b7c4..53068bb 100644 --- a/apps/payments/selectors.py +++ b/apps/payments/selectors.py @@ -1,8 +1,46 @@ """Bounded-query projections use only the current evidence revision for financial totals.""" +from datetime import datetime, time, timedelta + from django.urls import reverse -from django.db.models import BigIntegerField, F, OuterRef, Subquery, Sum, Value +from django.db.models import BigIntegerField, Count, F, OuterRef, Q, Subquery, Sum, Value from django.db.models.functions import Coalesce -from .models import PaymentReceiptRevision +from django.utils import timezone +from .models import PaymentReceipt, PaymentReceiptRevision + + +def daily_payment_summary(receipts, notices): + """Summarize permission-scoped, unfiltered querysets in two aggregate queries. + + Submission means initial receipt creation. Final outcomes use the current + revision's finalization time, including receipts submitted on earlier days. + Superseded revisions and partial votes never count as final outcomes. + """ + local_timezone = timezone.get_default_timezone() + today = timezone.localdate(timezone=local_timezone) + start = timezone.make_aware(datetime.combine(today, time.min), local_timezone) + end = timezone.make_aware(datetime.combine(today + timedelta(days=1), time.min), local_timezone) + current = PaymentReceiptRevision.objects.filter( + receipt_id=OuterRef('pk'), version=OuterRef('current_version')) + receipts = receipts.annotate( + summary_amount=Subquery(current.values('amount')[:1]), + summary_finalized_at=Subquery(current.values('cycle__finalized_at')[:1])) + submitted = Q(created_at__gte=start, created_at__lt=end) + finalized = Q(summary_finalized_at__gte=start, summary_finalized_at__lt=end) + approved = finalized & Q(status=PaymentReceipt.Status.APPROVED) + rejected = finalized & Q(status=PaymentReceipt.Status.REJECTED) + pending = Q(status__in=[PaymentReceipt.Status.PENDING, PaymentReceipt.Status.PARTIAL]) + summary = receipts.aggregate( + submitted_count=Count('pk', filter=submitted), + submitted_amount=Sum('summary_amount', filter=submitted, default=0), + approved_count=Count('pk', filter=approved), + approved_amount=Sum('summary_amount', filter=approved, default=0), + rejected_count=Count('pk', filter=rejected), + rejected_amount=Sum('summary_amount', filter=rejected, default=0), + pending_count=Count('pk', filter=pending), + pending_amount=Sum('summary_amount', filter=pending, default=0)) + summary['notice_count'] = notices.filter(created_at__gte=start, created_at__lt=end).count() + summary['date'] = today + return summary def notice_sms_history(notice): diff --git a/apps/payments/views.py b/apps/payments/views.py index 3c8f4e1..2433e4d 100644 --- a/apps/payments/views.py +++ b/apps/payments/views.py @@ -279,6 +279,7 @@ def desk(request): mine = tab == 'receipts' and (request.GET.get('mine') == '1' or ('tab' not in request.GET and 'mine' not in request.GET)) receipts = selectors.receipts_with_current_values(policies.visible(PaymentReceipt.objects.select_related('customer__customer_profile', 'notice__created_by__staff_profile'), request.user)) notice_items = selectors.notices_with_totals(policies.visible(PaymentNotice.objects.select_related('customer__customer_profile', 'created_by__staff_profile', 'account'), request.user)) + daily_summary = selectors.daily_payment_summary(receipts, notice_items) if tab == 'notices': items = filtered(notice_items, request) state = request.GET.get('notice_state', '') @@ -318,7 +319,8 @@ def desk(request): else: query.pop('mine', None) return render(request, 'payments/desk.html', staff_context(page_obj=page, tab=tab, mine=mine, statuses=PaymentReceipt.Status.choices, - can_create=policies.can_create(request.user), banks=CompanyBankAccount.objects.all(), query_string=query.urlencode())) + can_create=policies.can_create(request.user), banks=CompanyBankAccount.objects.all(), query_string=query.urlencode(), + daily_summary=daily_summary)) @financial_required(staff=True) diff --git a/apps/tickets/admin.py b/apps/tickets/admin.py index 8b2b16e..b9ebecb 100644 --- a/apps/tickets/admin.py +++ b/apps/tickets/admin.py @@ -1,5 +1,5 @@ from django.contrib import admin -from .models import GroupDispatch,SLAConfig,Ticket,TicketAccessGrant,TicketCategory,TicketEvent,TicketReadState,TicketTag +from .models import DispatchResponseControl,GroupDispatch,SLAConfig,Ticket,TicketAccessGrant,TicketCategory,TicketEvent,TicketReadState,TicketTag @admin.register(Ticket) class TicketAdmin(admin.ModelAdmin): list_display=("ticket_number","subject","owning_unit","delivery_type","status","priority","owner","last_activity_at") @@ -7,6 +7,15 @@ class TicketAdmin(admin.ModelAdmin): search_fields=("ticket_number","subject","customer__mobile","customer__customer_profile__organization_name") filter_horizontal=("tags",) + def get_readonly_fields(self, request, obj=None): + if obj and obj.group_dispatch_id and DispatchResponseControl.objects.filter(dispatch_id=obj.group_dispatch_id).exists(): + return ("group_dispatch", "delivery_type", "customer", "created_by") + return super().get_readonly_fields(request, obj) + + def get_queryset(self, request): + from .dispatch_responses import managed_ticket_q + return super().get_queryset(request).filter(managed_ticket_q(request.user)).distinct() + class DispatchTicketInline(admin.TabularInline): model = Ticket @@ -27,6 +36,36 @@ class GroupDispatchAdmin(admin.ModelAdmin): readonly_fields = ("recipient_count", "dispatched_at") inlines = (DispatchTicketInline,) + def get_readonly_fields(self, request, obj=None): + fields = super().get_readonly_fields(request, obj) + if obj and DispatchResponseControl.objects.filter(dispatch_id=obj.pk).exists(): + return fields + ("unit", "operation_type", "created_by", "contact_group") + return fields + + def get_queryset(self, request): + from .policies import DispatchPolicy + from django.db.models import Q + return super().get_queryset(request).filter(Q(response_control__isnull=True) | Q(pk__in=DispatchPolicy.visible_dispatches(request.user))) + + +@admin.register(DispatchResponseControl) +class DispatchResponseControlAdmin(admin.ModelAdmin): + list_display = ("dispatch", "mode", "notify_sms", "opened_at", "opened_by") + readonly_fields = tuple(field.name for field in DispatchResponseControl._meta.fields) + + def get_queryset(self, request): + from .policies import DispatchPolicy + return super().get_queryset(request).filter(dispatch__in=DispatchPolicy.visible_dispatches(request.user)) + + def has_add_permission(self, request): + return False + + def has_change_permission(self, request, obj=None): + return False + + def has_delete_permission(self, request, obj=None): + return False + @admin.register(TicketCategory) class TicketCategoryAdmin(admin.ModelAdmin): diff --git a/apps/tickets/dispatch_presentation.py b/apps/tickets/dispatch_presentation.py new file mode 100644 index 0000000..0ff777d --- /dev/null +++ b/apps/tickets/dispatch_presentation.py @@ -0,0 +1,35 @@ +"""Read-only option metadata for the staff dispatch composer.""" + +from django.db.models import Count, Q + +from .models import GroupDispatch, TicketCategory +from .policies import DispatchPolicy + + +def dispatch_form_options(user, units, groups): + """Expose only permitted units; recipient counts follow the sending service.""" + units = list(units) + eligible = ( + Q(contacts__user__is_active=True, contacts__user__mobile__isnull=False) + & ~Q(contacts__user__mobile="") + ) + return { + "units": [ + { + "id": unit.pk, + "name": unit.name, + "operations": [ + kind for kind in GroupDispatch.OperationType.values + if DispatchPolicy.can_create(user, unit, kind) + ], + } + for unit in units + ], + "groups": list(groups.annotate(recipient_count=Count("contacts", filter=eligible)).values( + "id", "name", "unit_id", "recipient_count", + )), + "categories": list(TicketCategory.objects.filter( + Q(unit_id__in=[unit.pk for unit in units]) | Q(unit__isnull=True), + is_active=True, + ).order_by("title").values("id", "title", "unit_id")), + } diff --git a/apps/tickets/dispatch_response_views.py b/apps/tickets/dispatch_response_views.py new file mode 100644 index 0000000..bb69f9c --- /dev/null +++ b/apps/tickets/dispatch_response_views.py @@ -0,0 +1,45 @@ +from django.core import signing +from django.contrib import messages +from django.core.exceptions import PermissionDenied, ValidationError +from django.shortcuts import get_object_or_404, redirect, render +from django.views.decorators.cache import never_cache +from django.views.decorators.http import require_http_methods + +from .dispatch_responses import can_open_responses, open_dispatch_responses, render_opening_sms, response_control +from .models import GroupDispatch +from apps.notifications.models import SMSTemplate +from .views import staff_required + + +@staff_required +@require_http_methods(["GET", "POST"]) +@never_cache +def open_responses(request, public_id): + dispatch = get_object_or_404(GroupDispatch.objects.select_related("unit"), public_id=public_id) + control = response_control(dispatch) + if not control or control.mode != "SEALED" or not can_open_responses(request.user, dispatch): + raise PermissionDenied + if control.opened_at: + return redirect("staff_tickets:dispatch_detail", public_id=public_id) + if request.method == "POST": + try: + proof = signing.loads(request.POST.get("confirmation", ""), salt="dispatch-opening", max_age=600) + if proof["user"] != request.user.pk or proof["dispatch"] != str(public_id): + raise signing.BadSignature + open_dispatch_responses(dispatch, request.user, proof["sms"]) + except (signing.BadSignature, KeyError): + messages.error(request, "تأیید معتبر نیست یا مهلت آن پایان یافته؛ دوباره بررسی کنید.") + except (ValidationError, ValueError, SMSTemplate.DoesNotExist) as error: + messages.error(request, str(error)) + else: + messages.success(request, "پاسخ‌ها هم‌زمان بازگشایی شدند؛ وضعیت پیامک را در گزارش بررسی کنید.") + return redirect("staff_tickets:dispatch_detail", public_id=public_id) + try: + sms = render_opening_sms(dispatch) if control.notify_sms else "" + except (SMSTemplate.DoesNotExist, ValueError): + messages.error(request, "قالب پیامک بازگشایی معتبر یا فعال نیست؛ تنظیمات قالب را بررسی کنید.") + return redirect("staff_tickets:dispatch_detail", public_id=public_id) + from .dispatch_responses import _respondents + proof = signing.dumps({"user": request.user.pk, "dispatch": str(public_id), "sms": sms}, salt="dispatch-opening") + return render(request, "tickets/dispatch_open_confirm.html", {"dispatch": dispatch, "control": control, + "opening_sms": sms, "respondent_count": len(_respondents(dispatch)), "confirmation": proof}) diff --git a/apps/tickets/dispatch_responses.py b/apps/tickets/dispatch_responses.py new file mode 100644 index 0000000..db584b1 --- /dev/null +++ b/apps/tickets/dispatch_responses.py @@ -0,0 +1,139 @@ +"""Response confidentiality is independent of ordinary ticket permissions.""" +from django.core.exceptions import PermissionDenied, ValidationError +from django.db import transaction +from django.db.models import Count, F, Max, Min, Q +from django.utils import timezone + +from .models import DispatchResponseControl, GroupDispatch +from .policies import DispatchPolicy, TicketPolicy + + +OPENING_EVENT = "DISPATCH_RESPONSES_OPENED" + + +def response_control(dispatch): + if dispatch is None: + return None + return DispatchResponseControl.objects.filter(dispatch_id=dispatch.pk).first() + + +def responses_locked(ticket): + control = response_control(ticket.group_dispatch) if ticket.group_dispatch_id else None + return bool(control and control.mode == control.Mode.SEALED and control.opened_at is None) + + +def sealed_message_q(prefix=""): + return Q(**{ + prefix + "ticket__group_dispatch__response_control__mode": "SEALED", + prefix + "ticket__group_dispatch__response_control__opened_at__isnull": True, + prefix + "author_id": F(prefix + "ticket__customer_id"), + }) + + +def can_read_response(user, message): + if message.author_id != message.ticket.customer_id: + return True + if getattr(user, "account_type", None) == "CUSTOMER" and user.pk == message.author_id: + return True + return not responses_locked(message.ticket) + + +def filter_response_messages(query, user): + if getattr(user, "account_type", None) == "CUSTOMER": + return query.exclude(sealed_message_q() & ~Q(author_id=user.pk)) + return query.exclude(sealed_message_q()) + + +def can_record_ticket_read(ticket, user): + if not TicketPolicy.can_view(user, ticket): + return False + if not responses_locked(ticket): + return True + return not ticket.ticket_messages.filter(author_id=ticket.customer_id, deleted_at__isnull=True).exists() + + +def can_open_responses(user, dispatch): + if not user.is_authenticated or not user.is_active or not DispatchPolicy.can_view(user, dispatch): + return False + profile = getattr(user, "staff_profile", None) + if getattr(user, "account_type", None) != "STAFF" or (profile and not profile.is_active): + return False + return bool(user.is_superuser or dispatch.created_by_id == user.pk or user.has_perm("tickets.open_all_dispatch_responses")) + + +def render_opening_sms(dispatch): + from apps.notifications.management.commands.process_notifications import _render_sms + return _render_sms(OPENING_EVENT, {}, dispatch.unit) + + +def _respondents(dispatch): + # A withdrawn/deleted message is not a current response; attachment-only replies count. + return list(dispatch.tickets.filter(ticket_messages__author_id=F("customer_id"), + ticket_messages__deleted_at__isnull=True).distinct().order_by("pk")) + + +def _queue_opening_sms(control, tickets): + from apps.notifications.services import enqueue + from apps.notifications.models import NotificationJob + for ticket in tickets: + enqueue(NotificationJob.Channel.SMS, OPENING_EVENT, ticket, + f"dispatch:{control.dispatch_id}:opened:{ticket.customer_id}", + customer=ticket.customer, + payload={"dispatch_id": control.dispatch_id, "body": control.sms_body_snapshot}) + + +@transaction.atomic +def open_dispatch_responses(dispatch, user, expected_sms=""): + # Replies take the same control lock before locking their ticket. + control = DispatchResponseControl.objects.select_for_update(of=("self",)).select_related("dispatch__unit").get(dispatch=dispatch) + dispatch = control.dispatch + if not can_open_responses(user, dispatch): + raise PermissionDenied + if control.mode != control.Mode.SEALED: + raise ValidationError("این ارسال در حالت مشاهده جداگانه است.") + if control.opened_at: + return control, False + text = render_opening_sms(dispatch) if control.notify_sms else "" + if text != expected_sms: + raise ValidationError("قالب پیامک تغییر کرده است؛ متن جدید را بررسی و دوباره تأیید کنید.") + tickets = _respondents(dispatch) + control.opened_at = timezone.now() + control.opened_by = user + control.sms_body_snapshot = text + control.respondents_snapshot = [str(ticket.customer.public_id) for ticket in tickets] + control.save(update_fields=["opened_at", "opened_by", "sms_body_snapshot", "respondents_snapshot"]) + if control.notify_sms: + _queue_opening_sms(control, tickets) + return control, True + + +def lock_dispatch_for_ticket(ticket_id): + """Must be called inside the caller's transaction, before its ticket lock.""" + return DispatchResponseControl.objects.select_for_update(of=("self",)).filter(dispatch__tickets__pk=ticket_id).first() + + +def managed_ticket_q(user): + """Explicitly related tickets or dispatch visibility; unit-wide scope alone is insufficient.""" + dispatches = DispatchPolicy.visible_dispatches(user) + direct = Q(created_by=user) | Q(owner=user) | Q(selected_expert=user) + direct |= Q(access_grants__user=user, access_grants__revoked_at__isnull=True) + return Q(group_dispatch__response_control__isnull=True) | direct | Q(group_dispatch__in=dispatches) + + +def populate_report_responses(dispatch, tickets, locked): + """Load report responses in batches; before opening, select metadata only.""" + from apps.messaging.models import Message + responses = Message.objects.filter(ticket__group_dispatch=dispatch, author_id=F("ticket__customer_id"), deleted_at__isnull=True) + metadata = {row["ticket_id"]: row for row in responses.values("ticket_id").annotate( + count=Count("pk"), first=Min("created_at"), last=Max("created_at"))} + by_ticket = {} + if not locked: + for message in responses.order_by("created_at", "pk").prefetch_related("attachments"): + by_ticket.setdefault(message.ticket_id, []).append(message) + for ticket in tickets: + row = metadata.get(ticket.pk, {}) + ticket.response_count = row.get("count", 0) + ticket.first_response_at = row.get("first") + ticket.last_response_at = row.get("last") + ticket.report_customer_responses = by_ticket.get(ticket.pk, []) + ticket.latest_customer_response = ticket.report_customer_responses[-1] if ticket.report_customer_responses else None diff --git a/apps/tickets/forms.py b/apps/tickets/forms.py index dbca2b5..ae2bc8c 100644 --- a/apps/tickets/forms.py +++ b/apps/tickets/forms.py @@ -140,6 +140,8 @@ class StaffCreateTicketForm(forms.Form): class GroupDispatchForm(forms.Form): + response_mode = forms.ChoiceField(label="نحوه مشاهده پاسخ‌ها", choices=[("SEALED", "بازگشایی یکجا"), ("INDIVIDUAL", "مشاهده جداگانه")], initial="SEALED", required=False, widget=forms.RadioSelect) + notify_opening_sms = forms.BooleanField(label="اطلاع‌رسانی بازگشایی با پیامک", initial=True, required=False) operation_type = forms.ChoiceField(label="نوع عملیات", choices=(), required=False) unit = forms.ModelChoiceField(label="واحد ارسال‌کننده", queryset=OrganizationalUnit.objects.none()) contact_group = forms.ModelChoiceField(label="گروه مخاطبان", queryset=ContactGroup.objects.none()) @@ -177,6 +179,11 @@ class GroupDispatchForm(forms.Form): data["operation_type"] = GroupDispatch.OperationType.BULK unit = data.get("unit") group = data.get("contact_group") + data["response_mode"] = data.get("response_mode") or "SEALED" + if data["response_mode"] == "INDIVIDUAL": + data["notify_opening_sms"] = False + if group and group.contacts.filter(user__is_active=True, user__mobile__isnull=False).exclude(user__mobile="").count() > 15: + data["notify_opening_sms"] = False if unit and group and group.unit_id != unit.pk: self.add_error("contact_group", "این گروه متعلق به واحد انتخاب‌شده نیست.") category=data.get("category") diff --git a/apps/tickets/migrations/0018_dispatch_response_opening.py b/apps/tickets/migrations/0018_dispatch_response_opening.py new file mode 100644 index 0000000..fe33967 --- /dev/null +++ b/apps/tickets/migrations/0018_dispatch_response_opening.py @@ -0,0 +1,43 @@ +# Generated by Django 5.1.1 on 2026-10-13 09:53 + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +def preserve_used_response_controls(apps, schema_editor): + from django.db.migrations.exceptions import IrreversibleError + if apps.get_model('tickets', 'DispatchResponseControl').objects.using(schema_editor.connection.alias).exists(): + raise IrreversibleError('Response controls are in use; refusing to delete confidentiality and opening history.') + + +class Migration(migrations.Migration): + + dependencies = [ + ('tickets', '0017_ticketaccessgrant_first_viewed_at_and_more'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='DispatchResponseControl', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('mode', models.CharField(choices=[('SEALED', 'بازگشایی یکجا'), ('INDIVIDUAL', 'مشاهده جداگانه')], default='SEALED', max_length=12)), + ('notify_sms', models.BooleanField(default=True)), + ('recipient_count_snapshot', models.PositiveIntegerField()), + ('opened_at', models.DateTimeField(blank=True, null=True)), + ('sms_body_snapshot', models.TextField(blank=True)), + ('respondents_snapshot', models.JSONField(blank=True, default=list)), + ('dispatch', models.OneToOneField(on_delete=django.db.models.deletion.PROTECT, related_name='response_control', to='tickets.groupdispatch')), + ('opened_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, to=settings.AUTH_USER_MODEL)), + ], + options={ + 'verbose_name': 'تنظیمات و سابقه بازگشایی ارسال', + 'verbose_name_plural': 'تنظیمات و سوابق بازگشایی ارسال‌ها', + 'permissions': [('open_all_dispatch_responses', 'Can open responses of all authorized dispatches')], + 'constraints': [models.CheckConstraint(condition=models.Q(('notify_sms', False), models.Q(('mode', 'SEALED'), ('recipient_count_snapshot__lte', 15)), _connector='OR'), name='dispatch_opening_sms_limit'), models.CheckConstraint(condition=models.Q(('mode__in', ['SEALED', 'INDIVIDUAL'])), name='dispatch_response_mode_valid'), models.CheckConstraint(condition=models.Q(models.Q(('opened_at__isnull', True), ('opened_by__isnull', True)), models.Q(('mode', 'SEALED'), ('opened_at__isnull', False), ('opened_by__isnull', False)), _connector='OR'), name='dispatch_opening_audit_complete')], + }, + ), + migrations.RunPython(migrations.RunPython.noop, preserve_used_response_controls), + ] diff --git a/apps/tickets/models.py b/apps/tickets/models.py index a578753..6af1c6e 100644 --- a/apps/tickets/models.py +++ b/apps/tickets/models.py @@ -176,3 +176,30 @@ class MessageShare(PublicIdModel, models.Model): class TicketReadState(models.Model): ticket=models.ForeignKey(Ticket,on_delete=models.CASCADE,related_name="read_states"); user=models.ForeignKey(settings.AUTH_USER_MODEL,on_delete=models.CASCADE); last_read_at=models.DateTimeField(); last_read_message=models.ForeignKey("messaging.Message",on_delete=models.SET_NULL,null=True,blank=True) class Meta: constraints=[models.UniqueConstraint(fields=["ticket","user"],name="unique_ticket_read_state")] + + +class DispatchResponseControl(models.Model): + """Absent for legacy dispatches: their existing visibility is preserved.""" + + class Mode(models.TextChoices): + SEALED = "SEALED", "بازگشایی یکجا" + INDIVIDUAL = "INDIVIDUAL", "مشاهده جداگانه" + + dispatch = models.OneToOneField(GroupDispatch, on_delete=models.PROTECT, related_name="response_control") + mode = models.CharField(max_length=12, choices=Mode.choices, default=Mode.SEALED) + notify_sms = models.BooleanField(default=True) + recipient_count_snapshot = models.PositiveIntegerField() + opened_at = models.DateTimeField(null=True, blank=True) + opened_by = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.PROTECT, null=True, blank=True) + sms_body_snapshot = models.TextField(blank=True) + respondents_snapshot = models.JSONField(default=list, blank=True) + + class Meta: + verbose_name = "تنظیمات و سابقه بازگشایی ارسال" + verbose_name_plural = "تنظیمات و سوابق بازگشایی ارسال‌ها" + permissions = [("open_all_dispatch_responses", "Can open responses of all authorized dispatches")] + constraints = [ + models.CheckConstraint(condition=Q(notify_sms=False) | Q(mode="SEALED", recipient_count_snapshot__lte=15), name="dispatch_opening_sms_limit"), + models.CheckConstraint(condition=Q(mode__in=["SEALED", "INDIVIDUAL"]), name="dispatch_response_mode_valid"), + models.CheckConstraint(condition=Q(opened_at__isnull=True, opened_by__isnull=True) | Q(mode="SEALED", opened_at__isnull=False, opened_by__isnull=False), name="dispatch_opening_audit_complete"), + ] diff --git a/apps/tickets/policies.py b/apps/tickets/policies.py index 5ccaead..88a27f7 100644 --- a/apps/tickets/policies.py +++ b/apps/tickets/policies.py @@ -70,6 +70,20 @@ class TicketPolicy: } @classmethod def can_view(cls,user,ticket): + from .models import DispatchResponseControl + if ticket.group_dispatch_id and DispatchResponseControl.objects.filter(dispatch_id=ticket.group_dispatch_id).exists(): + if not user.is_authenticated: + return False + if cls.is_customer(user): + return ticket.customer_id == user.pk + if DispatchPolicy.can_view(user, ticket.group_dispatch): + return True + return bool(ticket.created_by_id == user.pk or ticket.owner_id == user.pk or ticket.selected_expert_id == user.pk + or ticket.access_grants.filter(user=user, revoked_at__isnull=True).exists()) + return cls._legacy_can_view(user, ticket) + + @classmethod + def _legacy_can_view(cls,user,ticket): if not user.is_authenticated: return False if cls.is_customer(user): return ticket.customer_id==user.id if user.is_superuser or user.has_perm("tickets.view_all_tickets"): return True @@ -83,6 +97,11 @@ class TicketPolicy: return False @classmethod def viewable_q(cls, user): + from .dispatch_responses import managed_ticket_q + return (cls._legacy_viewable_q(user) | Q(group_dispatch__in=DispatchPolicy.visible_dispatches(user))) & managed_ticket_q(user) + + @classmethod + def _legacy_viewable_q(cls, user): """Database equivalent of can_view; use it for every staff ticket collection.""" if user.is_superuser or user.has_perm("tickets.view_all_tickets"): return Q() @@ -169,5 +188,32 @@ class DispatchPolicy: @classmethod def can_view(cls, user, dispatch): if dispatch.created_by_id == user.id: + from .models import DispatchResponseControl + if DispatchResponseControl.objects.filter(dispatch_id=dispatch.pk).exists(): + return bool(user.is_authenticated and user.is_active and getattr(user, "account_type", None) == "STAFF") return cls.can_create(user, dispatch.unit, dispatch.operation_type) return cls.can_view_all(user, dispatch.operation_type, dispatch.unit) + + @classmethod + def visible_dispatches(cls, user): + from .models import GroupDispatch + if user.is_superuser: + return GroupDispatch.objects.all() + visibility = Q(created_by=user, response_control__isnull=False) + memberships = list(user.unit_memberships.filter(is_active=True, unit__is_active=True).select_related("unit")) + for operation_type in GroupDispatch.OperationType.values: + if cls.can_view_all(user, operation_type): + visibility |= Q(operation_type=operation_type) + continue + view_units = [item.unit_id for item in memberships if cls.can_view_all(user, operation_type, item.unit)] + create_units = [item.unit_id for item in memberships if cls.can_create(user, item.unit, operation_type)] + visibility |= Q(operation_type=operation_type, unit_id__in=view_units) + visibility |= Q(operation_type=operation_type, unit_id__in=create_units, created_by=user) + return GroupDispatch.objects.filter(visibility) + + @classmethod + def can_list(cls, user): + if any(cls.can_view_all(user, kind) for kind in cls.VIEW_ALL_PERMISSIONS): + return True + units = user.unit_memberships.filter(is_active=True, unit__is_active=True).select_related("unit") + return any(cls.can_view_all(user, kind, item.unit) for item in units for kind in cls.VIEW_ALL_PERMISSIONS) diff --git a/apps/tickets/response_admin.py b/apps/tickets/response_admin.py new file mode 100644 index 0000000..2484aba --- /dev/null +++ b/apps/tickets/response_admin.py @@ -0,0 +1,22 @@ +"""Even superusers use the response-content gate in the application admin.""" +from django.contrib import admin +from django.db.models import Q + +from .dispatch_responses import sealed_message_q +from .models import Ticket +from .policies import TicketPolicy + + +class ResponseContentAdmin(admin.ModelAdmin): + message_prefix = "" + + def get_queryset(self, request): + query = super().get_queryset(request).exclude(sealed_message_q(self.message_prefix)) + ticket_path = self.message_prefix + "ticket" + allowed = Ticket.objects.filter(TicketPolicy.viewable_q(request.user)).values("pk") + return query.filter(Q(**{ticket_path + "__group_dispatch__response_control__isnull": True}) + | Q(**{ticket_path + "__in": allowed})).distinct() + + +class RelatedResponseContentAdmin(ResponseContentAdmin): + message_prefix = "message__" diff --git a/apps/tickets/services.py b/apps/tickets/services.py index accf9b5..38efe26 100644 --- a/apps/tickets/services.py +++ b/apps/tickets/services.py @@ -63,6 +63,7 @@ def dispatch_group_tickets(*, user, unit, contact_group, category, subject, body ) if not contacts: raise ValidationError("گروه انتخاب‌شده مخاطب فعال دارای شماره همراه ندارد.") + from .models import DispatchResponseControl dispatch = GroupDispatch.objects.create( unit=unit, operation_type=operation_type, @@ -74,6 +75,9 @@ def dispatch_group_tickets(*, user, unit, contact_group, category, subject, body status=GroupDispatch.Status.QUEUED, recipient_count=len(contacts), ) + DispatchResponseControl.objects.create(dispatch=dispatch, mode="SEALED", + notify_sms=len(contacts) <= 15, + recipient_count_snapshot=len(contacts)) tickets=[] for contact in contacts: for uploaded in files: @@ -97,6 +101,20 @@ def dispatch_group_tickets(*, user, unit, contact_group, category, subject, body return dispatch, tickets +@transaction.atomic +def create_group_dispatch(*, response_mode="SEALED", notify_opening_sms=True, **dispatch_fields): + """Configure responses without changing the existing creation API.""" + from .models import DispatchResponseControl + if response_mode not in DispatchResponseControl.Mode.values: + raise ValidationError("روش مشاهده پاسخ‌ها معتبر نیست.") + dispatch, tickets = dispatch_group_tickets(**dispatch_fields) + control = DispatchResponseControl.objects.get(dispatch=dispatch) + control.mode = response_mode + control.notify_sms = bool(notify_opening_sms and response_mode == "SEALED" and control.recipient_count_snapshot <= 15) + control.save(update_fields=["mode", "notify_sms"]) + return dispatch, tickets + + def refresh_group_dispatch_status(dispatch_id): """Persist delivery state independently of whether a report page is opened.""" dispatch=GroupDispatch.objects.filter(pk=dispatch_id).first() @@ -194,6 +212,8 @@ def revoke_access(ticket,user,target): @transaction.atomic def reply_ticket(*,ticket_id,user,body="",files=(),internal=False,mentioned_users=()): + from .dispatch_responses import lock_dispatch_for_ticket + control = lock_dispatch_for_ticket(ticket_id) ticket=Ticket.objects.select_for_update().get(pk=ticket_id) if not TicketPolicy.can_reply(user,ticket): raise PermissionDenied if internal and (TicketPolicy.is_customer(user) or not user.has_perm("messaging.add_internal_note")): raise PermissionDenied @@ -204,7 +224,8 @@ def reply_ticket(*,ticket_id,user,body="",files=(),internal=False,mentioned_user if not TicketPolicy.can_view(target,ticket): if TicketPolicy.can_share(user,ticket): share_ticket(ticket,user,target) else: raise PermissionDenied("کارشناس Mention‌شده به تیکت دسترسی ندارد.") - MessageMention.objects.get_or_create(message=msg,mentioned_user=target); enqueue(NotificationJob.Channel.BALE,"MENTION",ticket,f"message:{msg.pk}:mention:{target.pk}",staff_user=target,payload={"excerpt":body[:150]}) + excerpt = "پاسخ محرمانه ثبت شد." if control and control.mode == "SEALED" and not control.opened_at and user.pk == ticket.customer_id else body[:150] + MessageMention.objects.get_or_create(message=msg,mentioned_user=target); enqueue(NotificationJob.Channel.BALE,"MENTION",ticket,f"message:{msg.pk}:mention:{target.pk}",staff_user=target,payload={"excerpt":excerpt}) now=timezone.now(); ticket.last_activity_at=now if not internal: if TicketPolicy.is_customer(user): diff --git a/apps/tickets/sharing.py b/apps/tickets/sharing.py index c50f247..48a8ca0 100644 --- a/apps/tickets/sharing.py +++ b/apps/tickets/sharing.py @@ -66,6 +66,9 @@ def share_staff_ticket(ticket, actor, target, level, note=""): @transaction.atomic def share_staff_message(message, actor, target, note=""): + from .dispatch_responses import can_read_response + if not can_read_response(actor, message): + raise PermissionDenied("پاسخ تا بازگشایی قابل اشتراک نیست.") ticket = Ticket.objects.select_for_update().get(pk=message.ticket_id) require_share_authority(actor, ticket) validate_recipient(actor, target) @@ -147,11 +150,12 @@ def staff_message_context(ticket, actor, conversation=()): def sharing_context(ticket, actor): + from .dispatch_responses import sealed_message_q if not TicketPolicy.can_share(actor, ticket): return {} return { "grants": ticket.access_grants.select_related("user__staff_profile", "granted_by__staff_profile", "revoked_by__staff_profile").order_by("-created_at", "-pk"), - "message_shares": MessageShare.objects.filter(message__ticket=ticket).select_related("message", "user__staff_profile", "granted_by__staff_profile", "revoked_by__staff_profile"), + "message_shares": MessageShare.objects.filter(message__ticket=ticket).exclude(sealed_message_q("message__")).select_related("message", "user__staff_profile", "granted_by__staff_profile", "revoked_by__staff_profile"), "sharing_events": sharing_events(ticket), } diff --git a/apps/tickets/sharing_views.py b/apps/tickets/sharing_views.py index c8a2b8c..d430b8c 100644 --- a/apps/tickets/sharing_views.py +++ b/apps/tickets/sharing_views.py @@ -1,17 +1,15 @@ """Routes available only to staff, including isolated message attachment delivery.""" -import mimetypes -from django.conf import settings from django.contrib import messages from django.db import transaction from django.utils import timezone from django.core.exceptions import PermissionDenied, ValidationError -from django.http import FileResponse, JsonResponse +from django.http import JsonResponse from django.shortcuts import get_object_or_404, redirect from django.views.decorators.cache import never_cache from django.views.decorators.http import require_GET, require_POST -from apps.attachments.delivery import signed_media_redirect +from apps.attachments.delivery import attachment_response from apps.attachments.models import Attachment from .models import MessageShare, Ticket, TicketReadState from .policies import TicketPolicy @@ -22,8 +20,11 @@ from .views import staff_required, mark_messages_read def received_message_share(request, share_id): + from .dispatch_responses import can_read_response grant = get_object_or_404(MessageShare.objects.select_related("message__author__staff_profile", "granted_by__staff_profile"), public_id=share_id) profile = getattr(request.user, "staff_profile", None) + if not can_read_response(request.user, grant.message): + raise PermissionDenied if (grant.user_id != request.user.pk or grant.revoked_at or grant.message.deleted_at or not request.user.is_active or not profile or not profile.is_active): raise PermissionDenied @@ -72,6 +73,7 @@ def shared_message(request, share_id): @never_cache @transaction.atomic def read_shared_messages(request, public_id): + from .dispatch_responses import can_record_ticket_read ticket = get_object_or_404(Ticket.objects.select_for_update(), public_id=public_id) allowed = visible_messages(ticket, request.user) try: @@ -86,7 +88,7 @@ def read_shared_messages(request, public_id): if grant.first_viewed_at is None: mark_share_opened(grant, request.user) latest = allowed.filter(deleted_at__isnull=True).order_by('-created_at', '-pk').first() - if latest and latest.public_id in accepted and TicketPolicy.can_view(request.user, ticket): + if latest and latest.public_id in accepted and can_record_ticket_read(ticket, request.user): TicketReadState.objects.update_or_create(ticket=ticket, user=request.user, defaults={'last_read_at': timezone.now(), 'last_read_message': latest}) return JsonResponse({"unread_shares": unread_shares(request.user), "read_ids": [str(value) for value in accepted]}) @@ -105,9 +107,4 @@ def unread_count(request): def shared_attachment(request, share_id, attachment_id, download=False): grant = received_message_share(request, share_id) attachment = get_object_or_404(Attachment, public_id=attachment_id, message_id=grant.message_id) - if settings.USE_NGINX_MEDIA: - return signed_media_redirect(attachment.relative_path, download=download) - content_type = attachment.mime_type or mimetypes.guess_type(attachment.original_name)[0] or "application/octet-stream" - response = FileResponse(attachment.file.open("rb"), content_type=content_type, as_attachment=download, filename=attachment.original_name) - response["X-Content-Type-Options"] = "nosniff" - return response + return attachment_response(attachment, download=download) diff --git a/apps/tickets/staff_conversation.py b/apps/tickets/staff_conversation.py index 3d8a939..6d296d6 100644 --- a/apps/tickets/staff_conversation.py +++ b/apps/tickets/staff_conversation.py @@ -12,7 +12,9 @@ from .sharing import staff_message_context def received_messages(user, ticket=None): + from .dispatch_responses import sealed_message_q grants = MessageShare.objects.filter(user=user, revoked_at__isnull=True, message__deleted_at__isnull=True) + grants = grants.exclude(sealed_message_q("message__")) return grants.filter(message__ticket=ticket) if ticket is not None else grants @@ -28,7 +30,8 @@ def visible_messages(ticket, user): profile = getattr(user, "staff_profile", None) if not user.is_active or (profile is not None and not profile.is_active): raise PermissionDenied - query = ticket.ticket_messages.all() + from .dispatch_responses import filter_response_messages + query = filter_response_messages(ticket.ticket_messages.all(), user) if TicketPolicy.can_view(user, ticket): return query grants = received_messages(user, ticket) diff --git a/apps/tickets/staff_urls.py b/apps/tickets/staff_urls.py index 708804c..ae44173 100644 --- a/apps/tickets/staff_urls.py +++ b/apps/tickets/staff_urls.py @@ -1,9 +1,10 @@ from django.urls import path -from . import views, sharing_views +from . import views, sharing_views, dispatch_response_views app_name="staff_tickets" urlpatterns=[path("",views.staff_list,name="list"),path("customers/",views.staff_customers,name="customers"),path("customers/search/",views.staff_customer_search,name="customer_search"),path("contacts/",views.staff_contacts,name="contacts"),path("contacts/new/",views.staff_contact_create,name="contact_create"),path("contacts//edit/",views.staff_contact_edit,name="contact_edit"),path("contacts//call/",views.staff_contact_call,name="contact_call"),path("contact-groups/",views.staff_groups,name="groups"),path("contact-groups//",views.staff_groups,name="group_edit"),path("magic-links/",views.staff_magic_audit,name="magic_audit"),path("magic-links//revoke/",views.staff_magic_revoke,name="magic_revoke"),path("sms-templates/",views.staff_sms_templates,name="sms_templates"),path("sms-templates//",views.staff_sms_templates,name="sms_template_edit"),path("dispatches/",views.staff_dispatch_create,name="dispatch_create"),path("dispatches//",views.staff_dispatch_detail,name="dispatch_detail"),path("dispatches//retry/",views.staff_dispatch_retry,name="dispatch_retry"),path("dispatches//resolve/",views.staff_dispatch_resolve,name="dispatch_resolve"),path("experts//avatar/",views.expert_avatar,name="expert_avatar"),path("tickets/",views.staff_list,name="tickets"),path("tickets/new/",views.staff_create,name="create"),path("tickets//messages/older/",views.staff_messages,name="messages"),path("tickets//messages/poll/",views.staff_poll,name="poll"),path("tickets//sms//retry/",views.staff_sms_retry,name="sms_retry"),path("tickets//announcement/now/",views.staff_announcement_now,name="announcement_now"),path("tickets//",views.staff_detail,name="detail"),path("tickets//claim/",views.staff_claim,name="claim"),path("tickets//reply/",views.staff_reply,name="reply"),path("tickets//note/",lambda r,public_id:views.staff_reply(r,public_id,note=True),name="note"),path("tickets//transfer/",views.staff_transfer,name="transfer"),path("tickets//share/",views.staff_share,name="share"),path("tickets//revoke//",views.staff_revoke,name="revoke"),path("tickets//status/",views.staff_status,name="status"),path("tickets//priority/",views.staff_priority,name="priority"),path("tickets//delete/",views.staff_delete,name="delete")] urlpatterns += [ + path("dispatches//open-responses/", dispatch_response_views.open_responses, name="dispatch_open_responses"), path("shares/count/", sharing_views.unread_count, name="shares_count"), path("tickets//shares/read/", sharing_views.read_shared_messages, name="read_shares"), path("shares/", sharing_views.inbox, name="shares"), diff --git a/apps/tickets/views.py b/apps/tickets/views.py index d0080db..f53884b 100644 --- a/apps/tickets/views.py +++ b/apps/tickets/views.py @@ -38,7 +38,7 @@ from .sharing import mark_ticket_share_opened, require_share_authority, revoke_s from .policies import DispatchPolicy, TicketPolicy from .staff_conversation import limited_poll, message_context, shared_ticket_rows, visible_messages from .selectors import customer_tickets,dashboard_counts,filter_staff_tickets,staff_ticket_rows -from .services import change_priority,change_status,claim_ticket,create_ticket,dispatch_group_tickets,refresh_group_dispatch_status,reply_ticket,resolve_group_dispatch_tickets,transfer_ticket +from .services import change_priority,change_status,claim_ticket,create_ticket,create_group_dispatch,refresh_group_dispatch_status,reply_ticket,resolve_group_dispatch_tickets,transfer_ticket MESSAGE_PAGE_SIZE=50 SMS_RETRYABLE_DELIVERY_STATUSES=( NotificationJob.DeliveryStatus.UNDELIVERED, @@ -122,11 +122,14 @@ def conversation_version(ticket,latest,include_internal=False): query=ticket.ticket_messages.all() if not include_internal: query=query.filter(is_internal=False) edits=query.aggregate(edited=Max('edited_at'),deleted=Max('deleted_at')) - revision=hashlib.sha256(repr(edits).encode()).hexdigest()[:20] + from .dispatch_responses import response_control + control = response_control(ticket.group_dispatch) if ticket.group_dispatch_id else None + revision=hashlib.sha256(repr((edits, control.opened_at if control else None)).encode()).hexdigest()[:20] source=f"{ticket.updated_at.isoformat()}:{latest.public_id if latest else ''}:{latest.updated_at.isoformat() if latest else ''}" return hashlib.sha256(source.encode()).hexdigest()[:20]+':'+revision def conversation_poll_response(request,ticket,actor,include_internal=False): - qs=ticket.ticket_messages.all() + from .dispatch_responses import filter_response_messages + qs=filter_response_messages(ticket.ticket_messages.all(), actor) if not include_internal: qs=qs.filter(is_internal=False) latest=qs.order_by("-created_at","-id").only("id","public_id","updated_at").first() version=conversation_version(ticket,latest,include_internal=include_internal) @@ -458,7 +461,7 @@ def staff_detail(request,public_id): conversation,has_older,older_cursor=conversation_page(visible.select_related("author").prefetch_related("attachments","read_receipts__user__staff_profile","read_receipts__user__customer_profile")) mark_ticket_share_opened(ticket,request.user) message_sharing=message_context(ticket,request.user,conversation) - history=Ticket.objects.filter(customer=ticket.customer).exclude(pk=ticket.pk).select_related("owner__staff_profile","selected_expert__staff_profile").order_by("-last_activity_at")[:5] + history=Ticket.objects.filter(TicketPolicy.viewable_q(request.user), customer=ticket.customer).exclude(pk=ticket.pk).select_related("owner__staff_profile","selected_expert__staff_profile").order_by("-last_activity_at").distinct()[:5] sms_jobs=list(ticket.notificationjob_set.filter(channel=NotificationJob.Channel.SMS).exclude(event_type="UNIT_SUPERVISOR_NEW_TICKET").order_by("-created_at")) announcement_jobs=list(AnnouncementJob.objects.filter(Q(message_id__in=visible.values("pk")) | Q(ticket=ticket)).select_related("message").order_by("-created_at", "-pk")) can_retry_sms=TicketPolicy.can_reply(request.user,ticket) @@ -469,6 +472,7 @@ def staff_detail(request,public_id): sms_job.sms_retryable=can_retry_sms and _sms_job_is_retryable(sms_job) latest=conversation[-1] if conversation else None group_dispatch = ticket.group_dispatch + message_sharing["can_view_dispatch"] = bool(group_dispatch and DispatchPolicy.can_view(request.user, group_dispatch)) can_resolve_dispatch = bool(group_dispatch and DispatchPolicy.can_view(request.user, group_dispatch) and (request.user.has_perm("tickets.change_ticket") or TicketPolicy.has_unit_permission(request.user, group_dispatch.unit, "tickets.change_ticket"))) group_open_count = group_dispatch.tickets.exclude(status__in=(Ticket.Status.RESOLVED, Ticket.Status.CLOSED)).count() if group_dispatch else 0 customer_notices = payment_selectors.notices_with_totals( @@ -613,10 +617,19 @@ def staff_delete(request, public_id): @staff_required @require_http_methods(["GET", "POST"]) def staff_dispatch_create(request): + from .dispatch_presentation import dispatch_form_options + + create_unit_ids = { + unit.pk for unit in OrganizationalUnit.objects.filter(pk__in=TicketPolicy.unit_ids(request.user), is_active=True) + if any(DispatchPolicy.can_create(request.user, unit, kind) for kind in GroupDispatch.OperationType.values) + } + base_dispatches = _visible_dispatches(request.user) + if not create_unit_ids and (request.method == "POST" or not (DispatchPolicy.can_list(request.user) or base_dispatches.exists())): + raise PermissionDenied form = GroupDispatchForm(request.POST or None, request.FILES or None, user=request.user) if request.method == "POST" and form.is_valid(): try: - dispatch, _ = dispatch_group_tickets( + dispatch, _ = create_group_dispatch( user=request.user, unit=form.cleaned_data["unit"], contact_group=form.cleaned_data["contact_group"], @@ -624,6 +637,8 @@ def staff_dispatch_create(request): subject=form.cleaned_data["subject"], body=form.cleaned_data["body"], operation_type=form.cleaned_data["operation_type"], + response_mode=form.cleaned_data["response_mode"], + notify_opening_sms=form.cleaned_data["notify_opening_sms"], tags=form.cleaned_data["tags"], files=request.FILES.getlist("files"), ) @@ -631,13 +646,6 @@ def staff_dispatch_create(request): return redirect("staff_tickets:dispatch_detail", public_id=dispatch.public_id) except (PermissionDenied, ValidationError) as exc: form.add_error(None, str(exc)) - create_unit_ids = { - unit.pk for unit in OrganizationalUnit.objects.filter(pk__in=TicketPolicy.unit_ids(request.user), is_active=True) - if any(DispatchPolicy.can_create(request.user, unit, kind) for kind in GroupDispatch.OperationType.values) - } - if not create_unit_ids: - raise PermissionDenied - base_dispatches = _visible_dispatches(request.user) metrics_row = base_dispatches.aggregate(total=Count("id"), recipients=Sum("recipient_count")) metrics = { "total": metrics_row["total"] or 0, @@ -672,19 +680,27 @@ def staff_dispatch_create(request): return render(request, "tickets/staff_dispatch_create.html", { "form": form, "dispatches": page.object_list, "page": page, "metrics": metrics, "units": units, "groups": groups, "dispatch_statuses": GroupDispatch.Status.choices, + "dispatch_options": dispatch_form_options(request.user, units, groups), + "can_create_dispatch": bool(create_unit_ids), + "history_units": OrganizationalUnit.objects.filter(pk__in=base_dispatches.values("unit_id")), + "history_groups": ContactGroup.objects.filter(pk__in=base_dispatches.values("contact_group_id")), "active_staff_section": "dispatches", "active_tab_title": "ارسال گروهی و ردیابی", }) @staff_required def staff_dispatch_detail(request, public_id): + from .dispatch_responses import response_control, can_open_responses, populate_report_responses dispatch = get_object_or_404( GroupDispatch.objects.select_related("unit", "contact_group", "created_by"), public_id=public_id, ) if not DispatchPolicy.can_view(request.user, dispatch): raise PermissionDenied - tickets = list(dispatch.tickets.select_related("customer__customer_profile").prefetch_related("magic_links", "ticket_messages", "notificationjob_set").order_by("customer_id")) + control = response_control(dispatch) + locked = bool(control and control.mode == "SEALED" and not control.opened_at) + tickets = list(dispatch.tickets.select_related("customer__customer_profile").prefetch_related("magic_links", "notificationjob_set").order_by("customer_id")) + populate_report_responses(dispatch, tickets, locked) delivery_failures = { NotificationJob.DeliveryStatus.UNDELIVERED, NotificationJob.DeliveryStatus.EXPIRED, @@ -696,7 +712,6 @@ def staff_dispatch_detail(request, public_id): for ticket in tickets: ticket.sms_job = next((job for job in ticket.notificationjob_set.all() if job.channel == NotificationJob.Channel.SMS and job.event_type == SMSTemplate.Event.STAFF_TICKET_CREATED), None) ticket.sms_retryable = bool(ticket.sms_job and (ticket.sms_job.status == NotificationJob.Status.FAILED or (ticket.sms_job.delivery_status in delivery_failures and not ticket.sms_job.delivery_next_check_at))) - ticket.latest_customer_response = next((message for message in reversed(list(ticket.ticket_messages.all())) if message.author_id == ticket.customer_id), None) links = list(ticket.magic_links.all()) ticket.magic_total_views = sum(link.view_count for link in links) ticket.magic_link_count = len(links) @@ -704,17 +719,17 @@ def staff_dispatch_detail(request, public_id): ticket.magic_last_used_at = max((link.used_at for link in links if link.used_at), default=None) job = ticket.sms_job if not job: - ticket.sms_state_title, ticket.sms_state_hint, ticket.sms_state_tone = "در صف ثبت نشده", "برای این مخاطب job پیامک پیدا نشد.", "neutral" + ticket.sms_state_title, ticket.sms_state_hint, ticket.sms_state_tone = "در صف ثبت نشده", "برای این مخاطب سابقه ارسال پیامک ثبت نشده است.", "neutral" elif job.status == NotificationJob.Status.FAILED: - ticket.sms_state_title, ticket.sms_state_hint, ticket.sms_state_tone = "ارسال ناموفق", f"ارسال خودکار پس از {1 + settings.SMS_MAX_RETRIES} تلاش متوقف شده است.", "danger" + ticket.sms_state_title, ticket.sms_state_hint, ticket.sms_state_tone = "ارسال ناموفق", "ارسال خودکار متوقف شده است؛ علت خطا و امکان تلاش مجدد را در ادامه بررسی کنید.", "danger" elif job.status == NotificationJob.Status.RETRYING: - ticket.sms_state_title, ticket.sms_state_hint, ticket.sms_state_tone = "در انتظار تلاش مجدد", "worker پیامک را در موعد بعدی دوباره ارسال می‌کند.", "warning" + ticket.sms_state_title, ticket.sms_state_hint, ticket.sms_state_tone = "در انتظار تلاش مجدد", "سامانه پیامک را در موعد بعدی دوباره ارسال می‌کند.", "warning" elif job.status in (NotificationJob.Status.PENDING, NotificationJob.Status.PROCESSING): ticket.sms_state_title, ticket.sms_state_hint, ticket.sms_state_tone = "در صف ارسال", "پیامک هنوز به درگاه تحویل نشده است.", "info" elif job.delivery_status == NotificationJob.DeliveryStatus.DELIVERED: ticket.sms_state_title, ticket.sms_state_hint, ticket.sms_state_tone = "تحویل به گوشی تأیید شد", "اپراتور دریافت پیامک توسط مخاطب را تأیید کرده است.", "success" elif job.delivery_status in (NotificationJob.DeliveryStatus.PENDING, NotificationJob.DeliveryStatus.UNKNOWN, NotificationJob.DeliveryStatus.UNDELIVERED, NotificationJob.DeliveryStatus.CHECK_FAILED) and job.delivery_next_check_at: - ticket.sms_state_title, ticket.sms_state_hint, ticket.sms_state_tone = "در حال پیگیری تحویل", "نتیجه فعلی قطعی نیست و worker استعلام را ادامه می‌دهد.", "warning" + ticket.sms_state_title, ticket.sms_state_hint, ticket.sms_state_tone = "در حال پیگیری تحویل", "نتیجه فعلی قطعی نیست؛ پیگیری خودکار تحویل ادامه دارد.", "warning" elif job.delivery_status == NotificationJob.DeliveryStatus.BLOCKED: ticket.sms_state_title, ticket.sms_state_hint, ticket.sms_state_tone = "دریافت پیامک مسدود است", "شماره مخاطب دریافت پیامک تبلیغاتی را بسته است.", "danger" elif job.delivery_status == NotificationJob.DeliveryStatus.EXPIRED: @@ -732,14 +747,15 @@ def staff_dispatch_detail(request, public_id): tracking = sms_jobs.filter(delivery_next_check_at__isnull=False).count() opened = TicketMagicLink.objects.filter(ticket__group_dispatch=dispatch, view_count__gt=0).values("ticket_id").distinct().count() total_views = TicketMagicLink.objects.filter(ticket__group_dispatch=dispatch).aggregate(total=Sum("view_count"))["total"] or 0 - replied = dispatch.tickets.filter(last_customer_message_at__isnull=False).count() + replied = sum(bool(ticket.response_count) for ticket in tickets) refresh_group_dispatch_status(dispatch.pk); dispatch.refresh_from_db(fields=["status"]) pending = sum(sms_counts.get(status, 0) for status in (NotificationJob.Status.PENDING, NotificationJob.Status.PROCESSING, NotificationJob.Status.RETRYING)) delivery_percent = round((delivered / dispatch.recipient_count) * 100) if dispatch.recipient_count else 0 metrics = {"sent": sent, "delivered": delivered, "failed": failed, "retrying": retrying, "tracking": tracking, "pending": pending, "opened": opened, "not_opened": max(dispatch.recipient_count-opened,0), "total_views": total_views, "replied": replied, "delivery_percent": delivery_percent} open_ticket_count = sum(ticket.status not in (Ticket.Status.RESOLVED, Ticket.Status.CLOSED) for ticket in tickets) can_resolve_dispatch = request.user.has_perm("tickets.change_ticket") or TicketPolicy.has_unit_permission(request.user, dispatch.unit, "tickets.change_ticket") - return render(request, "tickets/staff_dispatch_detail.html", {"dispatch": dispatch, "tickets": tickets, "metrics": metrics, "open_ticket_count": open_ticket_count, "can_resolve_dispatch": can_resolve_dispatch, "sms_max_attempts": 1 + settings.SMS_MAX_RETRIES, "active_staff_section": "dispatches"}) + opening_jobs = NotificationJob.objects.filter(ticket__group_dispatch=dispatch, event_type="DISPATCH_RESPONSES_OPENED").values("status").annotate(count=Count("pk")) + return render(request, "tickets/staff_dispatch_detail.html", {"dispatch": dispatch, "tickets": tickets, "metrics": metrics, "open_ticket_count": open_ticket_count, "can_resolve_dispatch": can_resolve_dispatch, "sms_max_attempts": 1 + settings.SMS_MAX_RETRIES, "active_staff_section": "dispatches", "response_control": control, "responses_locked": locked, "can_open_responses": can_open_responses(request.user, dispatch), "opening_jobs": list(opening_jobs)}) @staff_required @@ -782,19 +798,7 @@ def _unit_ids_with_permission(user, permission): def _visible_dispatches(user): - if user.is_superuser: - return GroupDispatch.objects.all() - visibility = Q(pk__in=[]) - memberships = list(user.unit_memberships.filter(is_active=True, unit__is_active=True).select_related("unit")) - for operation_type in GroupDispatch.OperationType.values: - if DispatchPolicy.can_view_all(user, operation_type): - visibility |= Q(operation_type=operation_type) - continue - view_all_units = [item.unit_id for item in memberships if DispatchPolicy.can_view_all(user, operation_type, item.unit)] - create_units = [item.unit_id for item in memberships if DispatchPolicy.can_create(user, item.unit, operation_type)] - visibility |= Q(operation_type=operation_type, unit_id__in=view_all_units) - visibility |= Q(operation_type=operation_type, unit_id__in=create_units, created_by=user) - return GroupDispatch.objects.filter(visibility) + return DispatchPolicy.visible_dispatches(user) CONTACT_AUDIT_FIELDS = { diff --git a/config/urls.py b/config/urls.py index c02ffa4..b2dc384 100644 --- a/config/urls.py +++ b/config/urls.py @@ -67,7 +67,7 @@ urlpatterns = [ def debug_media(request, path): import posixpath - if posixpath.normpath(path.replace("\\", "/")).lstrip("/").startswith("payments/"): + if posixpath.normpath(path.replace("\\", "/")).lstrip("/").startswith(("payments/", "dispatch-responses/")): from django.http import Http404 raise Http404 return serve(request, path, document_root=settings.MEDIA_ROOT) diff --git a/design-prototypes/chaharsu/receipt-action-main-desktop.png b/design-prototypes/chaharsu/receipt-action-main-desktop.png new file mode 100644 index 0000000..55db781 Binary files /dev/null and b/design-prototypes/chaharsu/receipt-action-main-desktop.png differ diff --git a/design-prototypes/chaharsu/receipt-action-main-mobile.png b/design-prototypes/chaharsu/receipt-action-main-mobile.png new file mode 100644 index 0000000..0a8d2b8 Binary files /dev/null and b/design-prototypes/chaharsu/receipt-action-main-mobile.png differ diff --git a/design-prototypes/chaharsu/receipt-action-original-before-mobile.png b/design-prototypes/chaharsu/receipt-action-original-before-mobile.png new file mode 100644 index 0000000..19c63d4 Binary files /dev/null and b/design-prototypes/chaharsu/receipt-action-original-before-mobile.png differ diff --git a/design-prototypes/chaharsu/receipt-action-preview-desktop.png b/design-prototypes/chaharsu/receipt-action-preview-desktop.png new file mode 100644 index 0000000..31fbc45 Binary files /dev/null and b/design-prototypes/chaharsu/receipt-action-preview-desktop.png differ diff --git a/design-prototypes/chaharsu/receipt-action-preview-mobile.png b/design-prototypes/chaharsu/receipt-action-preview-mobile.png new file mode 100644 index 0000000..6db0750 Binary files /dev/null and b/design-prototypes/chaharsu/receipt-action-preview-mobile.png differ diff --git a/design-prototypes/chaharsu/receipt-action-preview.html b/design-prototypes/chaharsu/receipt-action-preview.html new file mode 100644 index 0000000..3d40679 --- /dev/null +++ b/design-prototypes/chaharsu/receipt-action-preview.html @@ -0,0 +1,71 @@ + + + + + +پیش‌نمایش اقدام ارسال فیش · چهارسو + + + +
پیش‌نمایش پیشنهادی · تمام داده‌ها ساختگی‌اند · پرداخت یا ارسال واقعی انجام نمی‌شود
+ +
لوله گستر گلپایگان
چهارسومرکز خدمات مشتریان
خدمات مشتریان
+
+ +

جزئیات واریز

اطلاعات حساب را بردارید؛ پس از واریز، فیش را از همین صفحه ارسال کنید.

+
+
واریز بابت سفارش لوله و اتصالاتاعلامیهٔ نمونه ۱۰۴۲ · ۲۰ مهر ۱۴۰۵
۲۵۰٬۰۰۰٬۰۰۰ ریال
هنوز فیشی ارسال نشده است
+
+
حساب نمونهٔ شرکت · بانک نمونهصاحب حساب: شرکت نمونه
+
+
مبلغ (ریال)
۲۵۰٬۰۰۰٬۰۰۰
+
شماره حساب · ساختگی
0000 0000 0000
+
شبا · ساختگی
IR00 0000 0000 0000 0000 0000 00
+
شناسه واریز · ضروری
000000000
+
+

هنگام واریز، حتماً شناسهٔ واریز را در فرم بانکی وارد کنید؛ واریز بدون این شناسه معتبر نخواهد بود.

+ +

پس از واریز

تصویر فیش را برای بررسی این واریز ارسال کنید.

+

اطلاعات تکمیلی و توضیحات

فاکتور: نمونه ۱۲۳۴

این توضیح نمونه برای نمایش جایگاه اطلاعات تکمیلی پس از بخش ارسال فیش است.

+
تأییدشده: ۰ ریال · باقی‌مانده: ۲۵۰٬۰۰۰٬۰۰۰ ریال
+
+
+ + +
+ + + diff --git a/docker/nginx.conf b/docker/nginx.conf index 0b54d60..a433ce5 100644 --- a/docker/nginx.conf +++ b/docker/nginx.conf @@ -27,6 +27,11 @@ server { return 404; } + # These files require an authenticated application check on every request. + location ^~ /protected-media/dispatch-responses/ { + return 404; + } + location /protected-media/ { alias /app/media/; secure_link $arg_md5,$arg_expires; diff --git a/docs/DISPATCH_RESPONSE_OPENING.md b/docs/DISPATCH_RESPONSE_OPENING.md new file mode 100644 index 0000000..3d3bca7 --- /dev/null +++ b/docs/DISPATCH_RESPONSE_OPENING.md @@ -0,0 +1,69 @@ +# بازگشایی پاسخ‌های ارسال گروهی + +ارسال‌های جدید پیش‌فرض بازگشایی یکجا دارند. ارسال‌های قدیمی بدون رکورد +`DispatchResponseControl` رفتار قبلی را حفظ می‌کنند و پیامک بازگشایی ندارند. +در حالت یکجا، کارکنان و superuser تا بازگشایی متن، پیوست و تاریخچهٔ پاسخ +مخاطب را نمی‌بینند. مخاطب به پاسخ خودش دسترسی دارد. + +## مجوزها در Django Admin + +- مشاهدهٔ همه ارسال‌های گروهی: `tickets.view_all_bulk_dispatches` +- مشاهدهٔ همه استعلام‌ها: `tickets.view_all_inquiries` +- بازگشایی همه ارسال‌های قابل مشاهده: `tickets.open_all_dispatch_responses` + +مجوز بازگشایی به‌تنهایی مجوز مشاهده ایجاد نمی‌کند. ایجادکنندهٔ ارسال خودش +می‌تواند بازگشایی کند. حساب باید فعال باشد؛ کاربران پنل staff با پروفایل +غیرفعال مجاز نیستند. عنوان شغلی یا نقش جدیدی به‌صورت خودکار مجوز نمی‌گیرد. + +## پیامک + +رویداد `DISPATCH_RESPONSES_OPENED` در SMSTemplate قابل ویرایش است؛ قالب +اختصاصی واحد بر قالب عمومی اولویت دارد. این رویداد تگ جایگزین ندارد. +متن دقیق هنگام تأیید ثبت می‌شود و تغییر بعدی قالب بر پیامک‌های صف‌شده اثر +نمی‌گذارد. تغییر قالب میان پیش‌نمایش و تأیید، نیاز به بررسی دوباره دارد. + +فقط پاسخ‌دهندگان با پاسخ حذف‌نشده در لحظهٔ بازگشایی در صف پیامک قرار +می‌گیرند. پاسخ صرفاً دارای پیوست نیز معتبر است. سقف ۱۵ بر اساس گیرندگان +واجد شرایط هنگام ایجاد است و با تغییر بعدی گروه عوض نمی‌شود. حالت +جداگانه و ارسال بیش از ۱۵ گیرنده پیامک بازگشایی ندارند. + +بازگشایی یک‌بار است. پاسخ و ویرایش بعدی فوراً برای کاربران مجاز قابل مشاهده +است و پیامک بازگشایی دیگری ایجاد نمی‌کند. بازگشایی با رسیدِ خواندن پیام +تفاوت دارد. صف و تحویل پیامک نیز جدا از بازگشایی گزارش می‌شوند. + +اعلان پاسخ ارسال‌های جدید در بله، با اطلاعات وضعیت و لینک پنل، به مقصد +خصوصی بلهٔ ایجادکننده ارسال می‌شود؛ تنظیم مقصد و فعال بودن اعلان در پروفایل +کارشناس لازم است. متن پیشنهاد در اعلان قرار نمی‌گیرد. اعلان‌های تیکت‌های +عادی و ارسال‌های قدیمی مسیر موجود خود را حفظ می‌کنند. + +## انتشار و migration + +1. از دیتابیس production پشتیبان قابل بازیابی بگیرید و migrationها را ابتدا + روی کپی آزمایشی بررسی کنید. این تغییر production را خودکار migrate نمی‌کند. +2. migrationهای جدید: `tickets.0018_dispatch_response_opening` و + `notifications.0021_dispatch_response_opening`. اولی جدول جدید می‌سازد؛ + دومی choices را فقط در وضعیت Django تغییر می‌دهد و قالب مفقود را با + `get_or_create` اضافه می‌کند. رکورد یا قالب قبلی بازنویسی نمی‌شود. +3. کد برنامه و worker را هماهنگ منتشر و worker پیامک را با نسخهٔ جدید + راه‌اندازی مجدد کنید؛ worker قدیمی رویداد تازه را نمی‌شناسد. +4. تنظیم جدید Nginx برای `/protected-media/dispatch-responses/` را نیز منتشر + کنید. فایل‌های این مسیر فقط از endpoint مجاز برنامه تحویل داده می‌شوند. + فایل‌های قبلی جابه‌جا نمی‌شوند. +5. مجوز مشاهده و بازگشایی را فقط به کاربران منتخب بدهید. هیچ مجوزی قفل + پاسخ را دور نمی‌زند. تنظیمات و سابقه بازگشایی در Admin فقط خواندنی‌اند. + +پس از استفادهٔ قابلیت، rollback جدول کنترل پاسخ ممنوع است تا محرمانگی و +سابقه بازگشایی حذف نشود. بازگشت به کد قدیمی نیز قفل جدید را نمی‌شناسد؛ برای +بازگشت از نسخه، از برنامهٔ بازیابی هماهنگ کد و دیتابیس استفاده کنید. + +این محدودیت در برنامه و Django Admin اعمال می‌شود؛ دسترسی مستقیم مدیر +زیرساخت به دیتابیس یا فایل سرور، رمزنگاری نشده است. + +## تست‌ها + +`tests/test_dispatch_response_opening.py` دسترسی، پیوست، Admin، قالب، سقف +مخاطب، تراکنش صف و worker را بررسی می‌کند. +`tests/test_dispatch_opening_postgres.py` هم‌زمانی واقعی و حفظ داده در +migration را بررسی می‌کند. تست‌های هم‌زمانی باید روی PostgreSQL اجرا شوند. +سناریوهای رابط کاربری در `tests/e2e/test_dispatch_response_opening_e2e.py` +با fixture مرورگر پروژه، در عرض‌های ۱۴۴۰، ۳۹۰ و ۳۲۰ اجرا می‌شوند. diff --git a/docs/PAYMENT_RECEIPT_ALERTS.md b/docs/PAYMENT_RECEIPT_ALERTS.md index 1879e5e..8d4c7bf 100644 --- a/docs/PAYMENT_RECEIPT_ALERTS.md +++ b/docs/PAYMENT_RECEIPT_ALERTS.md @@ -1,6 +1,8 @@ # اطلاع‌رسانی فیش مشتری -پس از ثبت موفق فیش مشتری، اپ `payments` اعلان‌ها را در صف `NotificationJob` قرار می‌دهد. ثبت از حساب مشتری و لینک کوتاه پرداخت هر دو پشتیبانی می‌شوند. فیشی که کارشناس به نمایندگی از مشتری ثبت می‌کند اعلان ندارد. تصویر هر نسخه مستقل است؛ گزینه «اطلاع‌رسانی فیش اصلاح‌شده مشتری» تعیین می‌کند نسخه‌های اصلاحی نیز اعلان داشته باشند. +پس از ثبت موفق فیش مشتری، اپ `payments` اعلان‌ها را در صف `NotificationJob` قرار می‌دهد. ثبت از حساب مشتری، لینک کوتاه پرداخت و «ثبت فیش برای مشتری» در پنل کارشناس همگی پشتیبانی می‌شوند. ثبت دستی کارشناس نیز با همان تنظیمات به مدیران فعال پیامک می‌فرستد و تصویر فیش را به همان گروه بله ارسال می‌کند؛ اطلاعات پیام مربوط به مشتری و واریزکننده است. تصویر هر نسخه مستقل است؛ گزینه «اطلاع‌رسانی فیش اصلاح‌شده مشتری» تعیین می‌کند نسخه‌های اصلاحی نیز، چه توسط مشتری و چه کارشناس مجاز ثبت شوند، اعلان داشته باشند. + +این اصلاح migration یا تنظیمات جداگانه نمی‌خواهد. برای اعمال آن، سرور برنامه و Worker موجود `process_notifications` را با نسخه جدید کد بازراه‌اندازی کنید. اعلان‌های مربوط به ثبت‌های قبلی که صفی برای آن‌ها ساخته نشده، به‌صورت خودکار ارسال نمی‌شوند. ## راه‌اندازی diff --git a/static/css/chaharsu.css b/static/css/chaharsu.css index 2dd3590..81e8c3a 100644 --- a/static/css/chaharsu.css +++ b/static/css/chaharsu.css @@ -35,6 +35,13 @@ .chaharsu-dock a.active{background:#0b4c8c}.chaharsu-dock a{min-height:52px;color:#cadbea} /* Staff payment workspace: one reading order across desktop and mobile. */ +.pay-day-summary{--day-ink:var(--color-ink,#08376b);--day-line:#dce5ed;--day-muted:#526c86;margin-block:0 16px;padding:12px;border:1px solid var(--day-line);border-radius:20px;background:var(--color-paper,#fff);color:var(--day-ink)} +.pay-day-head{display:flex;align-items:baseline;justify-content:space-between;flex-wrap:wrap;gap:4px 12px;margin-bottom:8px}.pay-day-head h2{display:flex;align-items:baseline;flex-wrap:wrap;gap:4px 12px;margin:0;font-size:1rem;font-weight:800}.pay-day-head time,.pay-day-head>span{font-size:.75rem;font-weight:400;color:var(--day-muted)} +.pay-day-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:8px;margin:0}.pay-day-metric{min-width:0;padding:8px 12px;border-radius:12px;background:#f6f8fa}.pay-day-metric dt{font-size:.875rem;font-weight:700;line-height:1.7}.pay-day-metric dd{margin:0;overflow-wrap:anywhere}.pay-day-count{font-size:1.375rem;font-weight:900;line-height:1.6;font-variant-numeric:tabular-nums}.pay-day-count span{font-size:.75rem;font-weight:400;color:var(--day-muted)}.pay-day-amount{font-size:.875rem;font-weight:700;line-height:1.8;font-variant-numeric:tabular-nums}.pay-day-amount bdi{overflow-wrap:anywhere}.pay-day-amount span{font-weight:400}.pay-day-note{font-size:.75rem;line-height:1.8;color:var(--day-muted)} +.pay-day-approved dt{color:#216345}.pay-day-rejected dt{color:#9f1f1f}.pay-day-pending{background:#fff8eb}.pay-day-pending dt{color:#805300}.pay-day-notices{grid-column:1/-1;display:grid;grid-template-columns:minmax(0,1fr) auto;align-items:center;column-gap:12px}.pay-day-notices dt{grid-column:1;grid-row:1}.pay-day-notices .pay-day-count{grid-column:2;grid-row:1/3}.pay-day-notices .pay-day-note{grid-column:1} +.pay-day-definitions{margin-top:4px}.pay-day-definitions summary{width:fit-content;min-height:44px;display:flex;align-items:center;gap:8px;cursor:pointer;font-size:.875rem;font-weight:700;list-style:none}.pay-day-definitions summary::-webkit-details-marker{display:none}.pay-day-definitions summary::after{content:'+'}.pay-day-definitions[open] summary::after{content:'−'}.pay-day-definitions summary:focus-visible{outline:3px solid #1977ce;outline-offset:3px;border-radius:4px}.pay-day-definitions p{margin:0 0 8px;font-size:.875rem;line-height:1.8;color:var(--day-muted)} +@media(min-width:640px){.pay-day-summary{padding:16px}.pay-day-grid{grid-template-columns:repeat(3,minmax(0,1fr))}.pay-day-notices{grid-column:auto;display:block}} +@media(min-width:1000px){.pay-day-grid{grid-template-columns:repeat(5,minmax(0,1fr))}} .pay-workspace{max-width:1200px}.pay-page-head{display:flex;justify-content:space-between;align-items:center;gap:24px;flex-wrap:wrap;margin-bottom:24px} .pay-page-title{display:flex;align-items:center;gap:14px;min-width:0;flex:1}.pay-page-title>div{min-width:0}.pay-page-title h1{font-size:24px;line-height:1.6;margin:0}.pay-page-title p{margin:4px 0 0;color:#526c86;font-size:14px} .pay-page-icon{display:grid;place-items:center;width:52px;height:52px;flex-shrink:0;border:1px solid #cde6f7;border-radius:16px;background:#e5f4ff;color:#08376b}.pay-page-icon .cs-icon{width:26px;height:26px} diff --git a/static/css/dispatch-workspace.css b/static/css/dispatch-workspace.css new file mode 100644 index 0000000..65f1852 --- /dev/null +++ b/static/css/dispatch-workspace.css @@ -0,0 +1,263 @@ +/* Scoped to group dispatch pages; the staff shell and ticket conversation keep their own styles. */ +.dispatch-response-banner { padding: 24px; margin: 16px 0; background: #fff; border: 1px solid #dce5ed; border-radius: 16px; color: #08376b; } +.dispatch-response-banner p, .dispatch-response-options p, .opening-sms-preview { font-size: 14px; line-height: 1.9; overflow-wrap: anywhere; } +.dispatch-response-options fieldset { margin: 16px; padding: 16px; border: 1px solid #dce5ed; border-radius: 12px; } +.dispatch-response-options fieldset label, .dispatch-sms-choice { display: flex; align-items: center; gap: 10px; min-height: 44px; padding: 8px; } +.dispatch-response-options input[type="radio"], .dispatch-response-options input[type="checkbox"] { width: 20px; height: 20px; min-height: 20px; flex: 0 0 auto; } +.dispatch-response-options > p, .dispatch-response-options > small, .dispatch-sms-choice { margin: 12px 16px; } +.dispatch-open-confirm { max-width: 900px; margin: auto; } +.opening-sms-preview { padding: 20px; margin: 16px 0; background: #f6fbff; border-inline-start: 3px solid #1977ce; } +.dispatch-opening-actions { display: flex; gap: 12px; flex-wrap: wrap; } +.dispatch-workspace { + --dispatch-ink: #08376b; + --dispatch-muted: #526c86; + --dispatch-line: #dce5ed; + --dispatch-soft: #f6f9fc; + color: var(--dispatch-ink); + font-size: 1rem; + gap: 1.25rem; + padding-bottom: 1rem; +} +.dispatch-workspace *, .dispatch-workspace *::before, .dispatch-workspace *::after { box-sizing: border-box; } +.dispatch-workspace [hidden] { display: none !important; } +.dispatch-workspace h1, .dispatch-workspace h2, .dispatch-workspace h3, .dispatch-workspace p { overflow-wrap: anywhere; } +.dispatch-workspace :is(span, small, dt, dd) { overflow-wrap: anywhere; } +.dispatch-workspace .dispatch-icon { width: 1.25rem; height: 1.25rem; flex: none; vertical-align: middle; } +.dispatch-workspace a, .dispatch-workspace button, .dispatch-workspace input, .dispatch-workspace select, .dispatch-workspace textarea, .dispatch-workspace summary { -webkit-tap-highlight-color: transparent; } +.dispatch-workspace :is(a, button, input, select, textarea, summary):focus-visible { outline: 3px solid #1977ce; outline-offset: 3px; } +.dispatch-workspace .dispatch-eyebrow { margin: 0 0 .35rem; color: var(--dispatch-muted); font-size: .75rem; font-weight: 700; } +.dispatch-page-heading { display: flex; align-items: center; justify-content: space-between; gap: 1.5rem; } +.dispatch-page-heading h1 { margin: 0; font-size: 1.75rem; font-weight: 900; line-height: 1.5; } +.dispatch-page-heading p:not(.dispatch-eyebrow) { margin: .45rem 0 0; color: var(--dispatch-muted); font-size: .875rem; line-height: 1.8; } +.dispatch-privacy { display: flex; align-items: center; gap: .6rem; max-width: 18rem; border: 1px solid #c9e2d5; border-radius: .75rem; padding: .75rem; color: #166347; background: #f2faf6; font-size: .75rem; line-height: 1.8; } +.dispatch-summary-strip { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); border: 1px solid var(--dispatch-line); border-radius: 1.25rem; background: #fff; padding: 1rem 0; } +.dispatch-summary-strip > div { display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: .5rem; padding: 0 1.25rem; } +.dispatch-summary-strip > div + div { border-inline-start: 1px solid var(--dispatch-line); } +.dispatch-summary-strip span { color: var(--dispatch-muted); font-size: .75rem; line-height: 1.7; } +.dispatch-summary-strip strong { font-size: 1.5rem; font-weight: 900; } +.dispatch-view-switch { display: flex; flex-wrap: wrap; align-items: center; gap: .5rem; border-bottom: 1px solid #c6d4e0; padding-bottom: .75rem; } +.dispatch-view-switch a { display: inline-flex; align-items: center; gap: .5rem; min-height: 2.75rem; border: 1px solid transparent; border-radius: .75rem; padding: .6rem 1rem; color: var(--dispatch-ink); font-size: .875rem; font-weight: 800; text-decoration: none; } +.dispatch-view-switch a[aria-current="true"] { border-color: var(--dispatch-ink); background: var(--dispatch-ink); color: #fff; } +.dispatch-view-switch a:not([aria-current="true"]):hover { background: #fff; border-color: var(--dispatch-line); } +.dispatch-view-switch a > span { border-radius: 1rem; padding: .1rem .5rem; background: #e5f4ff; color: var(--dispatch-ink); font-size: .75rem; } +.dispatch-compose-layout { display: grid; grid-template-columns: minmax(0, 1fr) 17rem; align-items: start; gap: 1.25rem; } +.dispatch-workspace .dispatch-composer { gap: 1rem; min-width: 0; } +.dispatch-workspace .dispatch-composer > section { border-color: var(--dispatch-line); border-radius: 1.25rem; box-shadow: none; } +.dispatch-workspace .dispatch-composer > section > header { padding: 1.25rem; background: #fff; } +.dispatch-workspace .dispatch-composer > section > header > span { width: 2.25rem; height: 2.25rem; background: #e5f4ff; color: var(--dispatch-ink); font-size: .875rem; } +.dispatch-workspace .dispatch-composer h2 { font-size: 1.125rem; font-weight: 900; } +.dispatch-workspace .dispatch-composer header p { margin: .3rem 0 0; color: var(--dispatch-muted); font-size: .875rem; line-height: 1.7; } +.dispatch-workspace .editor-grid { gap: 1rem; padding: 1.25rem; } +.dispatch-workspace .editor-grid label, .dispatch-workspace .message-editor { gap: .5rem; font-size: .875rem; font-weight: 700; color: var(--dispatch-ink); } +.dispatch-workspace .editor-grid em, .dispatch-workspace .message-editor em { margin-inline-start: .4rem; color: var(--dispatch-muted); font-size: .75rem; font-weight: 400; } +.dispatch-workspace :is(.editor-grid input, .editor-grid select, .message-editor textarea, .dispatch-tags select, .dispatch-filter input, .dispatch-filter select, .dispatch-recipient-tools input, .dispatch-recipient-tools select) { width: 100%; min-height: 2.875rem; border: 1px solid #aabed0; border-radius: .75rem; background: #fff; padding: .65rem .75rem; color: var(--dispatch-ink); font: inherit; font-size: 1rem; box-shadow: none; } +.dispatch-workspace select { cursor: pointer; } +.dispatch-workspace select:disabled { background: #f2f5f8; color: var(--dispatch-muted); cursor: default; } +.dispatch-workspace .editor-grid small, .dispatch-workspace .message-editor small { color: var(--dispatch-muted); font-size: .75rem; line-height: 1.8; font-weight: 400; } +.dispatch-workspace .message-editor { margin: 0 1.25rem 1.25rem; } +.dispatch-workspace .message-editor textarea { min-height: 9rem; height: 10rem; resize: vertical; line-height: 1.9; } +.dispatch-workspace .dispatch-tags { margin: 0 1.25rem 1.25rem; border: 1px solid var(--dispatch-line); border-radius: .75rem; } +.dispatch-workspace summary { min-height: 2.75rem; padding: .65rem .8rem; color: var(--dispatch-ink); font-size: .875rem; font-weight: 700; cursor: pointer; } +.dispatch-tags summary span { color: var(--dispatch-muted); font-size: .75rem; font-weight: 400; margin-inline-start: .5rem; } +.dispatch-tags label { display: grid; gap: .5rem; padding: 0 .8rem .8rem; font-size: .875rem; } +.dispatch-tags small { color: var(--dispatch-muted); font-size: .75rem; line-height: 1.8; } +.dispatch-workspace .dispatch-tags select { min-height: 5rem; } +.dispatch-workspace .dispatch-attachment-picker { margin: 0 1.25rem 1.25rem; padding: 1rem; border-radius: 1rem; background: #f6fbff; } +.dispatch-workspace .dispatch-attachment-picker b { color: var(--dispatch-ink); font-size: .875rem; } +.dispatch-workspace .dispatch-attachment-picker b small { display: inline; margin-inline-start: .5rem; } +.dispatch-workspace .dispatch-attachment-picker small { color: var(--dispatch-muted); font-size: .75rem; line-height: 1.8; } +.dispatch-workspace .media-picker-button { display: flex; justify-content: center; align-items: center; min-height: 2.75rem; border: 1px solid #9ebbd1; border-radius: .75rem; padding: .6rem .8rem; background: #fff; color: var(--dispatch-ink); font-size: .875rem; font-weight: 800; cursor: pointer; } +.dispatch-workspace .media-picker-button:has(+ input:focus-visible) { outline: 3px solid #1977ce; outline-offset: 3px; } +.dispatch-workspace .media-picker-button:focus-visible { outline: 3px solid #1977ce; outline-offset: 3px; } +.dispatch-workspace .file-chip { max-width: 100%; box-shadow: none; } +.dispatch-workspace .file-chip small { overflow-wrap: anywhere; } +.dispatch-workspace .file-chip button { min-width: 2.75rem; min-height: 2.75rem; font-size: 1.25rem; } +.dispatch-workspace .dispatch-composer > footer { padding: 1rem 1.25rem; border-radius: 1.25rem; box-shadow: none; } +.dispatch-workspace .dispatch-composer footer b { font-size: .875rem; } +.dispatch-workspace .dispatch-composer footer small { color: var(--dispatch-muted); font-size: .75rem; line-height: 1.8; } +.dispatch-workspace .dispatch-primary, .dispatch-workspace .dispatch-secondary, .dispatch-workspace .dispatch-composer footer button, .dispatch-workspace .dispatch-filter button, .dispatch-workspace .dispatch-reply-link { display: inline-flex; align-items: center; justify-content: center; gap: .5rem; min-height: 2.75rem; border: 1px solid var(--dispatch-ink); border-radius: .75rem; padding: .65rem 1rem; background: var(--dispatch-ink); color: #fff; font: inherit; font-size: .875rem; font-weight: 800; text-decoration: none; cursor: pointer; box-shadow: none; line-height: 1.7; } +.dispatch-workspace .dispatch-primary:hover, .dispatch-workspace .dispatch-composer footer button:hover { background: #0b4c8c; transform: none; } +.dispatch-workspace .dispatch-secondary { border-color: #aabed0; background: #fff; color: var(--dispatch-ink); } +.dispatch-workspace .dispatch-secondary:hover { border-color: var(--dispatch-ink); background: var(--dispatch-soft); transform: none; } +.dispatch-workspace button:disabled { cursor: wait; opacity: .65; } +.dispatch-compose-guide { border: 1px solid var(--dispatch-line); border-radius: 1.25rem; background: #f8fbfe; padding: 1.25rem; } +.dispatch-guide-icon { display: grid; place-items: center; width: 2.75rem; height: 2.75rem; border-radius: .875rem; background: #e5f4ff; } +.dispatch-compose-guide h2 { margin: .85rem 0 .4rem; font-size: 1.125rem; font-weight: 900; } +.dispatch-compose-guide p { font-size: .875rem; line-height: 1.9; color: var(--dispatch-muted); } +.dispatch-compose-guide dl { display: grid; gap: 1rem; margin: 1.25rem 0; } +.dispatch-compose-guide dt, .dispatch-review-meta dt, .dispatch-operation-details dt { color: var(--dispatch-muted); font-size: .75rem; } +.dispatch-compose-guide dd, .dispatch-review-meta dd, .dispatch-operation-details dd { margin: .25rem 0 0; font-size: .875rem; font-weight: 800; overflow-wrap: anywhere; } +.dispatch-compose-guide .dispatch-guide-note { display: flex; align-items: flex-start; gap: .5rem; margin: 0; border-top: 1px solid var(--dispatch-line); padding-top: 1rem; font-size: .75rem; } +.dispatch-workspace .dispatch-history { position: static; border-color: var(--dispatch-line); border-radius: 1.25rem; } +.dispatch-workspace .dispatch-history > header { gap: 1rem; padding: 1.25rem; } +.dispatch-workspace .dispatch-history h2, .dispatch-workspace .stream-head h2 { margin: 0; font-size: 1.125rem; font-weight: 900; } +.dispatch-workspace .dispatch-history header p, .dispatch-workspace .stream-head p { color: var(--dispatch-muted); margin: .35rem 0 0; font-size: .875rem; line-height: 1.8; } +.dispatch-workspace .dispatch-history header > span { display: block; width: auto; height: auto; flex: none; padding: .3rem .75rem; background: #e5f4ff; font-size: .75rem; border-radius: 1rem; } +.dispatch-workspace .dispatch-filter { grid-template-columns: minmax(12rem, 2fr) minmax(10rem, 1fr); align-items: end; gap: 1rem; padding: 1.25rem; background: var(--dispatch-soft); } +.dispatch-workspace .dispatch-filter label, .dispatch-workspace .dispatch-recipient-tools label { display: grid; gap: .4rem; font-size: .875rem; font-weight: 700; color: var(--dispatch-ink); } +.dispatch-workspace .dispatch-filter > footer { justify-content: flex-start; grid-column: 1 / -1; } +.dispatch-workspace .dispatch-filter button { flex: none; } +.dispatch-workspace .dispatch-filter footer a { display: inline-flex; align-items: center; min-height: 2.75rem; padding: .5rem; color: var(--dispatch-ink); font-size: .875rem; } +.dispatch-more-filters { grid-column: 1 / -1; } +.dispatch-more-filters > div { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 1rem; margin-top: .5rem; } +.dispatch-workspace .dispatch-results { max-height: none; overflow: visible; } +.dispatch-workspace .dispatch-history-card { grid-template-columns: minmax(0, 2fr) auto auto minmax(10rem, 1fr) auto; gap: 1rem; padding: 1.25rem; text-decoration: none; } +.dispatch-workspace .dispatch-history-card b { white-space: normal; overflow: visible; font-size: 1rem; color: var(--dispatch-ink); } +.dispatch-workspace .dispatch-history-card small, .dispatch-workspace .dispatch-history-card time, .dispatch-history-count { color: var(--dispatch-muted); font-size: .75rem; line-height: 1.8; } +.dispatch-history-open { display: flex; align-items: center; gap: .4rem; color: var(--dispatch-ink); font-size: .875rem; font-weight: 800; } +.dispatch-workspace .operations-pagination { padding: 1rem 1.25rem; font-size: .875rem; } +.dispatch-workspace .operations-pagination a { display: inline-flex; min-height: 2.75rem; align-items: center; padding: .5rem .85rem; color: var(--dispatch-ink); } +.dispatch-empty { padding: 2rem 1rem; text-align: center; } +.dispatch-empty > svg { width: 2rem; height: 2rem; color: var(--dispatch-muted); } +.dispatch-empty h3 { margin: .65rem 0; font-size: 1.125rem; } +.dispatch-empty p { color: var(--dispatch-muted); font-size: .875rem; line-height: 1.8; } +.dispatch-error-summary { border: 1px solid #dfa5a5; border-radius: .875rem; padding: 1rem; background: #fff6f6; color: #9f1f1f; font-size: .875rem; line-height: 1.9; } +.dispatch-error-summary a { color: #9f1f1f; text-decoration: underline; } +.dispatch-workspace .errorlist { color: #9f1f1f; font-size: .875rem; line-height: 1.8; } +.dispatch-workspace [aria-invalid="true"] { border-color: #9f1f1f; } +.dispatch-review-dialog { width: min(42rem, calc(100% - 1.5rem)); max-height: calc(100dvh - 2rem); margin: auto; border: 1px solid var(--dispatch-line); border-radius: 1.25rem; padding: 1.5rem; color: var(--dispatch-ink); background: #fff; overflow-y: auto; box-shadow: 0 1.5rem 5rem #08376b33; } +.dispatch-review-dialog::backdrop { background: #08284880; } +.dispatch-review-dialog header { display: flex; justify-content: space-between; align-items: flex-start; gap: 1rem; } +.dispatch-review-dialog h2 { margin: 0; font-size: 1.25rem; font-weight: 900; } +.dispatch-workspace .dispatch-dialog-close { display: grid; place-items: center; flex: none; min-width: 2.75rem; height: 2.75rem; border: 1px solid var(--dispatch-line); border-radius: .75rem; background: #fff; color: var(--dispatch-ink); font-size: 1.5rem; padding: 0; box-shadow: none; } +.dispatch-review-meta { display: grid; grid-template-columns: 1fr 1fr; gap: 1rem; margin: 1.5rem 0; padding: 1rem; background: var(--dispatch-soft); border-radius: .875rem; } +.dispatch-review-dialog h3 { margin: 1rem 0 .5rem; font-size: 1.125rem; } +.dispatch-review-body { white-space: pre-wrap; font-size: 1rem; line-height: 1.9; } +.dispatch-review-warning { padding: 1rem; border: 1px solid #efd8ad; border-radius: .75rem; background: #fff8eb; color: #805300; font-size: .875rem; line-height: 1.9; } +.dispatch-inquiry-recommendation { padding: 1rem; border: 1px solid #f2b5bb; border-inline-start: 4px solid #b42332; border-radius: .75rem; background: #fff2f3; color: #a11727; font-size: .875rem; line-height: 1.9; } +.dispatch-inquiry-recommendation p { margin: .5rem 0 0; } +.dispatch-review-dialog footer { display: flex; flex-wrap: wrap; justify-content: flex-end; gap: .75rem; margin-top: 1.25rem; } +.dispatch-breadcrumb { display: flex; flex-wrap: wrap; align-items: center; gap: 1rem; font-size: .75rem; color: var(--dispatch-muted); } +.dispatch-breadcrumb a { display: inline-flex; align-items: center; gap: .4rem; min-height: 2.75rem; color: var(--dispatch-ink); font-size: .875rem; } +.dispatch-workspace .dispatch-report-hero { align-items: center; border: 0; border-radius: 0; padding: 0; background: transparent; box-shadow: none; } +.dispatch-workspace .dispatch-report-title { min-width: 0; } +.dispatch-workspace .dispatch-report-title h1 { color: var(--dispatch-ink); font-size: 1.75rem; line-height: 1.6; font-weight: 900; } +.dispatch-workspace .dispatch-title-line { flex-wrap: wrap; } +.dispatch-workspace .report-status { padding: .35rem .7rem; font-size: .75rem; font-weight: 800; background: #eaf0f5; color: #405d75; } +.dispatch-workspace .report-status.status-sent { background: #e2f5ea; color: #166347; } +.dispatch-workspace .report-status.status-partial { background: #fff0e1; color: #805300; } +.dispatch-workspace .dispatch-meta-list span { border: 0; background: transparent; padding: 0; color: var(--dispatch-muted); font-size: .75rem; flex-wrap: wrap; } +.dispatch-workspace .dispatch-meta-list span + span { border-inline-start: 1px solid #c6d4e0; padding-inline-start: .75rem; } +.dispatch-workspace .dispatch-kpi-grid { display: grid; grid-template-columns: repeat(5, minmax(0, 1fr)); gap: .75rem; overflow: visible; } +.dispatch-workspace .dispatch-kpi { min-width: 0; align-items: flex-start; gap: .5rem; border-radius: 1rem; padding: 1rem; box-shadow: none; } +.dispatch-workspace .dispatch-kpi > svg { color: var(--dispatch-ink); margin-top: .25rem; } +.dispatch-workspace .dispatch-kpi small { color: var(--dispatch-ink); font-size: .875rem; } +.dispatch-workspace .dispatch-kpi strong { color: var(--dispatch-ink); font-size: 1.5rem; font-weight: 900; } +.dispatch-workspace .dispatch-kpi p { color: var(--dispatch-muted); font-size: .75rem; line-height: 1.8; } +.dispatch-workspace .delivery-overview { border-color: var(--dispatch-line); border-radius: 1rem; padding: 1rem 1.25rem; } +.dispatch-workspace .delivery-progress-copy { flex-wrap: wrap; } +.dispatch-workspace .delivery-progress-copy strong { color: var(--dispatch-ink); font-size: 1rem; } +.dispatch-workspace .delivery-progress-copy p, .dispatch-workspace .delivery-overview-note span { color: var(--dispatch-muted); font-size: .75rem; } +.dispatch-workspace .delivery-progress-copy span, .dispatch-workspace .delivery-overview-note b { font-size: .875rem; color: var(--dispatch-ink); } +.dispatch-workspace .delivery-progress-track { height: .375rem; background: #e7edf2; box-shadow: none; } +.dispatch-workspace .delivery-progress-track span { background: #16815c; } +.dispatch-workspace .dispatch-original-message { gap: .85rem; padding: 1.25rem; border-color: var(--dispatch-line); border-radius: 1.25rem; background: #fff; box-shadow: none; } +.dispatch-workspace .dispatch-original-message h2 { color: var(--dispatch-ink); font-size: 1.125rem; } +.dispatch-workspace .dispatch-original-message header > span { direction: rtl; color: var(--dispatch-muted); font-size: .75rem; } +.dispatch-workspace .dispatch-original-message blockquote { border-inline-start: 3px solid #1977ce; border-right: 0; background: var(--dispatch-soft); color: var(--dispatch-ink); font-size: 1rem; line-height: 1.9; padding: .75rem 1rem; overflow-wrap: anywhere; } +.dispatch-operation-details { border-top: 1px solid var(--dispatch-line); } +.dispatch-operation-details dl { display: flex; flex-wrap: wrap; gap: 1rem 2rem; padding: 0 .8rem; margin: .4rem 0; } +.dispatch-workspace .dispatch-recipient-report { border: 1px solid var(--dispatch-line); border-radius: 1.25rem; background: #fff; overflow: hidden; } +.dispatch-workspace .stream-head { align-items: flex-start; gap: 1rem; padding: 1.25rem; background: #fff; } +.dispatch-workspace .stream-head > span { flex: none; color: var(--dispatch-muted); font-size: .75rem; } +.dispatch-recipient-tools { display: grid; grid-template-columns: minmax(0, 1.5fr) minmax(0, 1fr) auto; gap: .75rem; align-items: end; border-block: 1px solid var(--dispatch-line); padding: 1rem 1.25rem; background: var(--dispatch-soft); } +.dispatch-recipient-tools > p { grid-column: 1 / -1; margin: 0; color: var(--dispatch-muted); font-size: .75rem; } +.dispatch-workspace .table-scroll { overflow: visible; } +.dispatch-workspace .dispatch-detail-table { width: 100%; min-width: 0; table-layout: fixed; font-size: .875rem; } +.dispatch-workspace .dispatch-detail-table th { padding: .8rem 1rem; color: var(--dispatch-muted); font-size: .75rem; background: var(--dispatch-soft); text-align: right; } +.dispatch-detail-table th:nth-child(1) { width: 20%; } +.dispatch-detail-table th:nth-child(2) { width: 26%; } +.dispatch-detail-table th:nth-child(3) { width: 14%; } +.dispatch-detail-table th:nth-child(4) { width: 40%; } +.dispatch-workspace .dispatch-detail-table td { padding: 1rem; min-width: 0; overflow-wrap: anywhere; } +.dispatch-workspace .recipient-cell { min-width: 0; } +.dispatch-workspace .recipient-cell strong, .dispatch-workspace .ticket-reference strong, .dispatch-workspace .sms-state-card strong, .dispatch-workspace .engagement-state strong { color: var(--dispatch-ink); font-size: .875rem; } +.dispatch-workspace .recipient-cell small, .dispatch-workspace .sms-state-card p, .dispatch-workspace .sms-state-card small, .dispatch-workspace .engagement-state small { color: var(--dispatch-muted); font-size: .75rem; line-height: 1.8; } +.dispatch-workspace .ticket-reference { min-height: 2.75rem; align-content: center; margin-top: .25rem; } +.dispatch-workspace .ticket-reference strong { white-space: normal; overflow-wrap: anywhere; font-weight: 800; } +.dispatch-workspace .ticket-state-pill { color: var(--dispatch-ink); font-size: .75rem; white-space: normal; } +.dispatch-workspace .dispatch-sms-state { min-width: 0; } +.dispatch-workspace .sms-state-card { padding: .75rem; } +.dispatch-workspace .sms-state-card > div { min-width: 0; } +.dispatch-workspace .sms-state-icon { width: 1.5rem; height: 1.5rem; background: transparent; } +.dispatch-workspace .sms-technical-details, .dispatch-workspace .sms-error-panel { font-size: .875rem; } +.dispatch-workspace .sms-technical-details dt { color: var(--dispatch-muted); font-size: .75rem; } +.dispatch-workspace .sms-technical-details dd { font-size: .875rem; } +.dispatch-workspace .inline-retry-form button { min-height: 2.75rem; border-radius: .75rem; padding: .5rem; color: #9f1f1f; font-size: .875rem; } +.dispatch-workspace .engagement-state { min-width: 0; gap: .5rem; } +.dispatch-workspace .engagement-state > svg { color: #526c86; } +.dispatch-workspace .engagement-state.has-engagement > svg { color: #5c369d; } +.dispatch-workspace .recipient-reply { min-width: 0; margin-top: .75rem; padding: .875rem; border: 1px solid var(--dispatch-line); border-radius: .75rem; background: #f6fbff; } +.dispatch-workspace .recipient-reply p { color: var(--dispatch-ink); font-size: .875rem; line-height: 1.9; overflow-wrap: anywhere; } +.dispatch-workspace .recipient-reply time { color: var(--dispatch-muted); font-size: .75rem; } +.dispatch-response-files { display: flex; flex-wrap: wrap; gap: .5rem; margin-top: .5rem; } +.dispatch-response-files a { font-size: .8rem; overflow-wrap: anywhere; text-decoration: underline; } +.dispatch-workspace .recipient-reply small, .dispatch-workspace .no-reply-state { color: var(--dispatch-muted); font-size: .75rem; line-height: 1.8; } +.dispatch-workspace .no-reply-state { margin: 0; } +.dispatch-workspace .dispatch-reply-link { margin-top: .75rem; width: 2.25rem; min-height: 2.25rem; padding: .4rem; border-color: var(--dispatch-line); background: #f6fbff; color: var(--dispatch-ink); } +.dispatch-management { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); align-items: start; gap: 1.25rem; border: 1px solid var(--dispatch-line); border-radius: 1.25rem; padding: 1.25rem; background: #fff; } +.dispatch-management h2 { margin: 0; font-size: 1.125rem; font-weight: 900; } +.dispatch-management p { margin: .4rem 0 0; color: var(--dispatch-muted); font-size: .875rem; line-height: 1.9; } +.dispatch-management-actions { display: grid; gap: .75rem; } +.dispatch-management form { margin: 0; } +.dispatch-management button { width: 100%; } +.dispatch-management small { display: block; margin-top: .4rem; color: var(--dispatch-muted); font-size: .75rem; line-height: 1.8; } +.dispatch-management .dispatch-complete { display: flex; align-items: flex-start; gap: .5rem; margin: 0; color: #166347; } +.dispatch-sr-only { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; border: 0; } +@media (max-width: 1200px) { + .dispatch-workspace .dispatch-history-card { grid-template-columns: minmax(0, 1fr) auto; gap: .6rem 1rem; } + .dispatch-history-main { grid-column: 1; } + .dispatch-history-card time { grid-column: 1; } + .dispatch-workspace .dispatch-kpi-grid { grid-template-columns: repeat(3, minmax(0, 1fr)); } + .dispatch-workspace .dispatch-detail-table thead { display: none; } + .dispatch-workspace .dispatch-detail-table, .dispatch-workspace .dispatch-detail-table tbody { display: block; } + .dispatch-workspace .dispatch-detail-table tr { display: grid; grid-template-columns: 1fr 1fr; border-bottom: .5rem solid var(--dispatch-soft); } + .dispatch-workspace .dispatch-detail-table td { display: block; width: auto; border-bottom: 1px solid var(--dispatch-line); } + .dispatch-workspace .dispatch-detail-table td::before { display: block; margin-bottom: .6rem; content: attr(data-label); color: var(--dispatch-muted); font-size: .75rem; font-weight: 700; } +} +@media (max-width: 1000px) { + .dispatch-compose-layout { grid-template-columns: minmax(0, 1fr); } + .dispatch-compose-guide { padding: 1rem; } + .dispatch-compose-guide dl { grid-template-columns: repeat(3, minmax(0, 1fr)); } + .dispatch-more-filters > div { grid-template-columns: repeat(2, minmax(0, 1fr)); } +} +@media (max-width: 700px) { + .dispatch-workspace { gap: 1rem; } + .dispatch-page-heading { align-items: stretch; flex-direction: column; gap: .75rem; } + .dispatch-page-heading h1, .dispatch-workspace .dispatch-report-title h1 { font-size: 1.5rem; } + .dispatch-privacy { max-width: none; } + .dispatch-summary-strip { grid-template-columns: repeat(2, minmax(0, 1fr)); padding: 0; } + .dispatch-summary-strip > div { min-height: 4rem; padding: .75rem; } + .dispatch-summary-strip > div:nth-child(3) { border-inline-start: 0; } + .dispatch-summary-strip > div:nth-child(n+3) { border-top: 1px solid var(--dispatch-line); } + .dispatch-view-switch a { flex: 1; justify-content: center; padding-inline: .5rem; } + .dispatch-workspace .dispatch-composer > section > header, .dispatch-workspace .editor-grid { padding: 1rem; } + .dispatch-workspace .editor-grid { grid-template-columns: minmax(0, 1fr); } + .dispatch-workspace .message-editor, .dispatch-workspace .dispatch-tags, .dispatch-workspace .dispatch-attachment-picker { margin-inline: 1rem; } + .dispatch-workspace .dispatch-composer > footer { align-items: stretch; flex-direction: column; gap: .75rem; padding: 1rem; } + .dispatch-workspace .dispatch-composer footer button { width: 100%; } + .dispatch-compose-guide dl { grid-template-columns: 1fr; } + .dispatch-workspace .dispatch-filter { grid-template-columns: minmax(0, 1fr); padding: 1rem; } + .dispatch-workspace .dispatch-history > header { flex-wrap: wrap; padding: 1rem; } + .dispatch-workspace .dispatch-history-card { padding: 1rem; } + .dispatch-more-filters > div { grid-template-columns: minmax(0, 1fr); } + .dispatch-review-dialog { padding: 1rem; } + .dispatch-review-dialog footer { flex-direction: column-reverse; } + .dispatch-review-meta { grid-template-columns: minmax(0, 1fr); } + .dispatch-workspace .dispatch-report-hero { display: grid; } + .dispatch-workspace .dispatch-meta-list span + span { border: 0; padding: 0; } + .dispatch-workspace .dispatch-meta-list { display: grid; gap: .4rem; } + .dispatch-workspace .dispatch-kpi-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); } + .dispatch-workspace .dispatch-kpi { padding: .85rem; } + .dispatch-workspace .dispatch-kpi > svg { display: none; } + .dispatch-workspace .dispatch-original-message { padding: 1rem; } + .dispatch-workspace .dispatch-original-message header { display: grid; gap: .5rem; } + .dispatch-workspace .stream-head { flex-wrap: wrap; padding: 1rem; } + .dispatch-recipient-tools { grid-template-columns: minmax(0, 1fr); padding: 1rem; } + .dispatch-workspace .dispatch-detail-table tr { grid-template-columns: minmax(0, 1fr); } + .dispatch-workspace .dispatch-detail-table td { padding: 1rem; } + .dispatch-management { grid-template-columns: minmax(0, 1fr); padding: 1rem; } +} +@media (prefers-reduced-motion: reduce) { + .dispatch-workspace *, .dispatch-workspace *::before, .dispatch-workspace *::after { transition: none; animation: none; scroll-behavior: auto; } +} diff --git a/static/css/payment-link.css b/static/css/payment-link.css index 227a7c7..62cd824 100644 --- a/static/css/payment-link.css +++ b/static/css/payment-link.css @@ -9,3 +9,13 @@ .customer-receipt-form #selected-payment .pay-deposit-warning{margin-top:12px;color:#9f1f1f;font-size:14px}.customer-receipt-form #id_deposit_id.pay-deposit-emphasis{color:#9f1f1f;border-color:#d39090;font-weight:700} .pay-link-message{max-width:620px;margin:48px auto}.pay-link-message .pay-card{padding:32px;border-radius:23px}.pay-link-message h1{margin:20px 0 12px;font-size:24px}.pay-link-message p{color:#526c86;font-size:16px;line-height:1.9;margin-bottom:24px}.pay-link-message .pay-actions{gap:12px;flex-wrap:wrap} @media(max-width:639px){.pay-link-message{margin:24px auto}.pay-link-message .pay-card{padding:24px 18px}.pay-link-status{padding:12px 14px;margin-bottom:18px}.pay-link-session-label{display:none}.pay-link-message .pay-button{width:100%}} + +/* Receipt action is emphasized only on the customer's individual notice page. */ +.customer-notice-detail .pay-receipt-guidance{margin:0 0 20px;font-size:14px;line-height:1.8;color:inherit} +.customer-notice-detail .pay-receipt-next-step{display:flex;align-items:center;justify-content:space-between;gap:20px;margin-block:20px;padding:18px 20px;border:1px solid #cde6f7;border-radius:16px;background:#f6fbff} +.customer-notice-detail .pay-receipt-next-step>div{min-width:0} +.customer-notice-detail .pay-receipt-next-step h2{margin:0 0 4px;font-size:18px;font-weight:800} +.customer-notice-detail .pay-receipt-next-step p{margin:0;color:#526c86;font-size:14px;line-height:1.8} +.customer-notice-detail .pay-receipt-action{gap:10px;min-height:52px;padding:12px 20px;font-size:16px;flex-shrink:0} +.customer-notice-detail .pay-receipt-action .cs-icon{width:22px;height:22px} +@media(max-width:639px){.customer-notice-detail .pay-receipt-next-step{flex-direction:column;align-items:stretch;gap:16px;padding:16px}.customer-notice-detail .pay-receipt-action{width:100%}} diff --git a/static/js/dispatch-workspace.js b/static/js/dispatch-workspace.js new file mode 100644 index 0000000..1746867 --- /dev/null +++ b/static/js/dispatch-workspace.js @@ -0,0 +1,254 @@ +/* Progressive enhancement: native forms and private-ticket links work without this script. */ +(() => { + 'use strict'; + const number = new Intl.NumberFormat('fa-IR'); + const displayCount = count => `${number.format(count)} نفر`; + + function initSections(root) { + const tabs = Array.from(root.querySelectorAll('[data-dispatch-tab]')); + const panels = Array.from(root.querySelectorAll('[data-dispatch-panel]')); + const hasErrors = Boolean(root.querySelector('.dispatch-error-summary')); + const filters = new URLSearchParams(location.search); + const hasFilters = ['q', 'status', 'unit', 'group', 'created_from', 'created_to', 'page'].some(key => filters.get(key)); + const selectedSection = () => { + if (hasErrors) return 'compose'; + if (location.hash === '#dispatch-history-panel') return 'history'; + if (location.hash === '#dispatch-compose-panel') return 'compose'; + return hasFilters ? 'history' : 'compose'; + }; + const show = section => { + tabs.forEach(tab => { + const active = tab.dataset.dispatchTab === section; + tab.setAttribute('aria-selected', String(active)); + tab.setAttribute('aria-current', String(active)); + tab.tabIndex = active ? 0 : -1; + }); + panels.forEach(panel => { panel.hidden = panel.dataset.dispatchPanel !== section; }); + }; + root.querySelector('.dispatch-view-switch').setAttribute('role', 'tablist'); + tabs.forEach((tab, index) => { + tab.setAttribute('role', 'tab'); + tab.setAttribute('aria-controls', `dispatch-${tab.dataset.dispatchTab}-panel`); + tab.addEventListener('click', event => { + event.preventDefault(); + history.pushState(null, '', tab.getAttribute('href')); + show(tab.dataset.dispatchTab); + }); + tab.addEventListener('keydown', event => { + let next; + if (event.key === 'ArrowLeft') next = (index + 1) % tabs.length; + if (event.key === 'ArrowRight') next = (index + tabs.length - 1) % tabs.length; + if (event.key === 'Home') next = 0; + if (event.key === 'End') next = tabs.length - 1; + if (next === undefined) return; + event.preventDefault(); + tabs[next].click(); + tabs[next].focus(); + }); + }); + panels.forEach(panel => panel.setAttribute('role', 'tabpanel')); + window.addEventListener('popstate', () => show(selectedSection())); + show(selectedSection()); + } + + function fillOptions(select, items, labelKey, placeholder, autoSelect = false) { + const previous = select.value; + select.replaceChildren(new Option(placeholder, '')); + items.forEach(item => select.add(new Option(item[labelKey], String(item.id)))); + if (items.some(item => String(item.id) === previous)) select.value = previous; + else if (autoSelect && items.length === 1) select.value = String(items[0].id); + } + + function initRecipients(root, form, data) { + const operation = form.elements.operation_type; + const unit = form.elements.unit; + const group = form.elements.contact_group; + const category = form.elements.category; + const groupHint = root.querySelector('[data-dispatch-group-hint]'); + const text = (selector, value) => { root.querySelector(selector).textContent = value; }; + const updateSummary = () => { + const selected = data.groups.find(item => String(item.id) === group.value); + updateResponseOptions(root, form, selected); + text('[data-dispatch-unit-summary]', unit.value ? unit.selectedOptions[0].textContent : 'انتخاب نشده'); + text('[data-dispatch-group-summary]', selected ? selected.name : 'انتخاب نشده'); + text('[data-dispatch-count-summary]', selected ? displayCount(selected.recipient_count) : 'پس از انتخاب گروه'); + group.setCustomValidity(selected && !selected.recipient_count ? 'این گروه مخاطب فعال دارای شماره همراه ندارد؛ گروه دیگری انتخاب کنید.' : ''); + groupHint.textContent = selected ? `${displayCount(selected.recipient_count)} فعال دارای شماره همراه؛ تعداد نهایی هنگام ثبت بررسی می‌شود.` : 'فقط گروه‌های واحد انتخاب‌شده نمایش داده می‌شوند.'; + }; + const updateGroupOptions = () => { + const hasUnit = Boolean(unit.value); + const groups = data.groups.filter(item => String(item.unit_id) === unit.value); + const categories = data.categories.filter(item => String(item.unit_id) === unit.value); + const applicableCategories = categories.length ? categories : data.categories.filter(item => item.unit_id === null); + fillOptions(group, hasUnit ? groups : [], 'name', hasUnit ? 'گروه مخاطبان را انتخاب کنید' : 'ابتدا واحد را انتخاب کنید'); + fillOptions(category, hasUnit ? applicableCategories : [], 'title', hasUnit ? 'دسته‌بندی را انتخاب کنید' : 'ابتدا واحد را انتخاب کنید'); + group.disabled = !hasUnit; + category.disabled = !hasUnit; + updateSummary(); + }; + const updateUnitOptions = () => { + const units = data.units.filter(item => item.operations.includes(operation.value)); + fillOptions(unit, units, 'name', 'واحد ارسال‌کننده را انتخاب کنید', !root.querySelector('.dispatch-error-summary')); + updateGroupOptions(); + }; + operation.addEventListener('change', updateUnitOptions); + unit.addEventListener('change', updateGroupOptions); + group.addEventListener('change', updateSummary); + updateUnitOptions(); + return () => data.groups.find(item => String(item.id) === group.value); + } + + function updateResponseOptions(root, form, group) { + const sms = form.elements.notify_opening_sms; + const individual = form.elements.response_mode.value === 'INDIVIDUAL'; + const oversized = Boolean(group && group.recipient_count > 15); + if (sms.dataset.openingPreference === undefined) sms.dataset.openingPreference = String(sms.checked); + sms.disabled = individual || oversized; + sms.checked = !sms.disabled && sms.dataset.openingPreference === 'true'; + root.querySelector('[data-opening-sms-hint]').textContent = individual + ? 'پیامک بازگشایی فقط در حالت یکجا مجاز است.' + : oversized ? 'بیش از ۱۵ گیرندهٔ واجد شرایط؛ اطلاع‌رسانی بازگشایی با پیامک مجاز نیست.' + : 'پس از بازگشایی فقط پاسخ‌دهندگان پیامک دریافت می‌کنند؛ حداکثر ۱۵ گیرنده مجاز است.'; + } + + function initReview(root, form, selectedGroup) { + const dialog = root.querySelector('[data-dispatch-review]'); + if (typeof dialog.showModal !== 'function') return; + const send = form.querySelector('[data-dispatch-send]'); + const confirm = dialog.querySelector('[data-dispatch-confirm]'); + let confirmed = false; + send.textContent = 'بررسی و ادامه'; + const setText = (selector, value) => { dialog.querySelector(selector).textContent = value; }; + const review = () => { + const group = selectedGroup(); + if (!form.reportValidity() || !group) return; + // The shared uploader empties picker inputs only after image preparation completes. + if (Array.from(form.querySelectorAll('[data-gallery-input], [data-document-input]')).some(input => input.files.length)) { + root.querySelector('[data-compression-status]').textContent = 'آماده‌سازی پیوست‌ها ادامه دارد؛ پس از پایان دوباره بررسی و ادامه را بزنید.'; + return; + } + setText('[data-review-unit]', form.elements.unit.selectedOptions[0].textContent); + setText('[data-review-group]', group.name); + setText('[data-review-count]', displayCount(group.recipient_count)); + setText('[data-review-response-policy]', `${form.elements.response_mode.value === 'SEALED' ? 'بازگشایی یکجا' : 'مشاهده جداگانه'}؛ پیامک بازگشایی: ${form.elements.notify_opening_sms.checked && !form.elements.notify_opening_sms.disabled ? 'فعال' : 'غیرفعال'}`); + dialog.querySelector('[data-review-inquiry-recommendation]').hidden = form.elements.response_mode.value === 'SEALED' && form.elements.notify_opening_sms.checked && !form.elements.notify_opening_sms.disabled; + dialog.querySelector('[data-review-inquiry-limit]').hidden = group.recipient_count <= 15; + const files = Array.from(form.elements.files.files); + setText('[data-review-files]', files.length ? files.map(file => file.name).join('، ') : 'بدون پیوست'); + setText('[data-review-subject]', form.elements.subject.value); + setText('[data-review-body]', form.elements.body.value); + dialog.showModal(); + dialog.querySelector('[data-dispatch-review-close]').focus(); + }; + // Capture before shared uploader/UI listeners: reviewing must not disable submission controls. + form.addEventListener('submit', event => { + if (confirmed) return; + event.preventDefault(); + event.stopImmediatePropagation(); + review(); + }, true); + dialog.querySelectorAll('[data-dispatch-review-close]').forEach(button => button.addEventListener('click', () => dialog.close())); + dialog.addEventListener('close', () => { if (!confirmed) send.focus(); }); + confirm.addEventListener('click', () => { + if (confirmed || !form.reportValidity()) return; + confirmed = true; + confirm.disabled = true; + confirm.textContent = 'در حال ثبت…'; + dialog.close(); + form.requestSubmit(send); + }); + } + + function initErrors(root, form) { + form.querySelectorAll('.errorlist').forEach((list, index) => { + const control = list.parentElement.querySelector('input, select, textarea'); + if (!control) return; + list.id = `dispatch-error-${index}`; + control.setAttribute('aria-invalid', 'true'); + control.setAttribute('aria-describedby', list.id); + }); + const summary = root.querySelector('.dispatch-error-summary'); + if (!summary) return; + summary.querySelectorAll('a').forEach(link => link.addEventListener('click', event => { + const control = document.getElementById(link.hash.slice(1)); + if (!control) return; + event.preventDefault(); + const disclosure = control.closest('details'); + if (disclosure) disclosure.open = true; + control.focus(); + })); + summary.focus(); + } + + function initComposer(root) { + const form = root.querySelector('#dispatch-compose-form'); + const data = JSON.parse(root.querySelector('#dispatch-options').textContent); + initSections(root); + const selectedGroup = initRecipients(root, form, data); + form.elements.notify_opening_sms.addEventListener('change', event => { + event.target.dataset.openingPreference = String(event.target.checked); + }); + form.querySelectorAll('[name="response_mode"]').forEach(input => input.addEventListener('change', () => updateResponseOptions(root, form, selectedGroup()))); + initReview(root, form, selectedGroup); + initErrors(root, form); + // Visible, keyboard-operable file labels retain the shared uploader's original inputs. + root.querySelectorAll('.media-picker-button').forEach(label => { + label.tabIndex = 0; + label.setAttribute('role', 'button'); + label.addEventListener('keydown', event => { + if (event.key !== 'Enter' && event.key !== ' ') return; + event.preventDefault(); + document.getElementById(label.htmlFor).click(); + }); + }); + } + + function normalizeSearch(value) { + return value.toLowerCase().replace(/[يى]/g, 'ی').replace(/ك/g, 'ک') + .replace(/[۰-۹]/g, digit => String(digit.charCodeAt(0) - 1776)) + .replace(/[٠-٩]/g, digit => String(digit.charCodeAt(0) - 1632)) + .replace(/[\s\u200c-]+/g, ''); + } + + function initReport(root) { + const rows = Array.from(root.querySelectorAll('[data-recipient-row]')); + if (!rows.length) return; + const tools = root.querySelector('[data-recipient-tools]'); + const search = tools.querySelector('[data-recipient-search]'); + const filter = tools.querySelector('[data-recipient-filter]'); + const status = tools.querySelector('[data-recipient-result-count]'); + const empty = root.querySelector('[data-recipient-empty]'); + const matches = { + all: () => true, + answered: row => row.dataset.answered === 'true', + unanswered: row => row.dataset.answered === 'false', + opened: row => row.dataset.opened === 'true', + unopened: row => row.dataset.opened === 'false', + retryable: row => row.dataset.retryable === 'true', + active: row => row.dataset.active === 'true', + }; + const update = () => { + const query = normalizeSearch(search.value); + let visible = 0; + rows.forEach(row => { + row.hidden = !normalizeSearch(row.dataset.search).includes(query) || !matches[filter.value](row); + if (!row.hidden) visible += 1; + }); + status.textContent = `${number.format(visible)} از ${number.format(rows.length)} مخاطب`; + empty.hidden = visible > 0; + }; + search.addEventListener('input', update); + filter.addEventListener('change', update); + root.querySelectorAll('[data-recipient-reset]').forEach(button => button.addEventListener('click', () => { + search.value = ''; + filter.value = 'all'; + update(); + search.focus(); + })); + tools.hidden = false; + update(); + } + + document.querySelectorAll('[data-dispatch-workspace]').forEach(initComposer); + document.querySelectorAll('[data-dispatch-report]').forEach(initReport); +})(); diff --git a/templates/payments/_daily_summary.html b/templates/payments/_daily_summary.html new file mode 100644 index 0000000..8bf9f96 --- /dev/null +++ b/templates/payments/_daily_summary.html @@ -0,0 +1,44 @@ +{% load jalali humanize %} +
+
+

وضعیت امروز

+ در محدوده دسترسی شما · مستقل از فیلترها +
+
+
+
ارسالی امروز
+
{{ daily_summary.submitted_count|intcomma:False }} فیش
+
{{ daily_summary.submitted_amount|intcomma:False }} ریال
+
+
+
تأیید امروز
+
{{ daily_summary.approved_count|intcomma:False }} فیش
+
{{ daily_summary.approved_amount|intcomma:False }} ریال
+
+
+
رد امروز
+
{{ daily_summary.rejected_count|intcomma:False }} فیش
+
{{ daily_summary.rejected_amount|intcomma:False }} ریال
+
+
+
در انتظار بررسی
+
{{ daily_summary.pending_count|intcomma:False }} فیش
+
{{ daily_summary.pending_amount|intcomma:False }} ریال
+
همه روزها · شامل تأیید ناقص
+
+
+
اعلامیه امروز
+
{{ daily_summary.notice_count|intcomma:False }} اعلامیه
+
ثبت‌شده امروز
+
+
+
+ مبنای آمار +

ارسالی امروز: تعداد فیش‌هایی که اولین بار امروز ثبت شده‌اند و جمع مبلغ آن‌ها، با هر وضعیتی. تاریخ واریز درج‌شده روی فیش ملاک نیست؛ ارسال نسخه اصلاحیِ فیشی که روز دیگری ثبت شده، به این تعداد اضافه نمی‌شود.

+

تأیید امروز: تعداد و جمع مبلغ فیش‌هایی که نسخه جاری آن‌ها امروز به تأیید نهایی رسیده و اکنون تأییدشده است؛ حتی اگر روز دیگری ارسال شده باشند. رأی اولیه یا تأیید ناقص، تأیید نهایی محسوب نمی‌شود.

+

رد امروز: تعداد و جمع مبلغ فیش‌هایی که بررسی نسخه جاری آن‌ها امروز با رد پایان یافته و اکنون ردشده است. اگر فیش اصلاح و دوباره برای بررسی ارسال شود، دیگر در این گروه محاسبه نمی‌شود.

+

در انتظار بررسی: تعداد و جمع مبلغ همه فیش‌هایی که هنوز نتیجه نهایی ندارند؛ شامل «در انتظار بررسی» و «تأیید ناقص» از همه روزها.

+

اعلامیه امروز: تعداد اعلامیه‌های واریزی که امروز ایجاد شده‌اند. اعلامیه‌های حذف‌شده محاسبه نمی‌شوند؛ اعلامیه لغوشده، تا زمانی که حذف نشده باشد، در تعداد ثبت‌شده‌ها باقی می‌ماند.

+

قواعد مشترک: همه مبلغ‌ها به ریال و بر اساس آخرین نسخه هر فیش هستند؛ نسخه‌های قبلی و رأی‌های متعدد جداگانه شمرده نمی‌شوند. آمار فقط موارد قابل مشاهده برای شما را شامل می‌شود و با تغییر فیلترها، تب یا صفحه فهرست تغییر نمی‌کند. چون مبنای گروه‌ها متفاوت است، مجموع تأیید و رد امروز لزوماً برابر ارسالی امروز نیست.

+
+
diff --git a/templates/payments/_notice.html b/templates/payments/_notice.html index 447a872..0f5036d 100644 --- a/templates/payments/_notice.html +++ b/templates/payments/_notice.html @@ -11,9 +11,21 @@ {% if notice.deposit_id %}
شناسه واریز · ضروری
{{ notice.deposit_id }}
{% endif %} {% if notice.deposit_id %}

هنگام واریز، حتماً شناسه واریز ارائه‌شده را در فرم بانکی، موبایل‌بانک یا اینترنت‌بانک وارد کنید؛ واریز بدون این شناسه معتبر نخواهد بود.

{% endif %} - -

تأییدشده: {{ notice.approved_amount|intcomma:False }} ریال · باقی‌مانده: {{ notice.remaining_amount|intcomma:False }} ریال

اطلاعات تکمیلی و توضیحات

{% if notice.invoice_number %}

فاکتور: {{ notice.invoice_number }}

{% endif %}

{{ notice.description|default:'توضیح دیگری اعلام نشده است.'|linebreaksbr }}

-
{% if not notice.cancelled_at and not staff_notice and notice.remaining_amount > 0 %}ارسال فیش این واریز{% endif %}
+{% if notice_page and not staff_notice %} +
+{% if not notice.cancelled_at and notice.remaining_amount > 0 %} +
+

پس از واریز

تصویر فیش را برای بررسی این واریز ارسال کنید.

+{% include 'payments/_icon.html' with name='receipt' %}ارسال فیش این واریز +
+{% endif %} +{% endif %} + +

تأییدشده: {{ notice.approved_amount|intcomma:False }} ریال · باقی‌مانده: {{ notice.remaining_amount|intcomma:False }} ریال

+{% with invoice_number=notice.invoice_number.strip description=notice.description.strip %} +{% if invoice_number or description %}

اطلاعات تکمیلی و توضیحات

{% if invoice_number %}

فاکتور: {{ invoice_number }}

{% endif %}{% if description %}

{{ description|linebreaksbr }}

{% endif %}
{% endif %} +{% endwith %} +{% if not notice_page or staff_notice %}
{% if not notice.cancelled_at and not staff_notice and notice.remaining_amount > 0 %}ارسال فیش این واریز{% endif %}
{% endif %} {% if notice_page and not staff_notice %}
{% csrf_token %}

دارندهٔ لینک به اطلاعات همین واریز دسترسی دارد؛ لینک تا پایان اعتبار آن قابل استفاده است.

diff --git a/templates/payments/customer_notice_detail.html b/templates/payments/customer_notice_detail.html index cbc3ba3..65d26a6 100644 --- a/templates/payments/customer_notice_detail.html +++ b/templates/payments/customer_notice_detail.html @@ -4,6 +4,7 @@ {% include 'payments/_link_status.html' %} {% if not payment_link_access %}{% endif %}
{% include 'payments/_icon.html' with name='bank' %}

جزئیات واریز

اطلاعات حساب، فیش‌های ارسالی و سوابق بررسی این واریز

+{% if not notice.cancelled_at and notice.remaining_amount > 0 %}

پس از واریز، تصویر فیش را با دکمهٔ «ارسال فیش این واریز» در پایین صفحه ارسال کنید.

{% endif %} {% include 'payments/_notice.html' with notice_page=True %} {% endblock %} diff --git a/templates/payments/desk.html b/templates/payments/desk.html index d6dac42..d422e00 100644 --- a/templates/payments/desk.html +++ b/templates/payments/desk.html @@ -7,6 +7,7 @@
{% include 'payments/_icon.html' with name='receipt' %}

اعلامیه‌ها و فیش‌های واریزی

اعلام اطلاعات واریز به مشتریان و بررسی فیش‌های پرداخت

{% if can_create %}{% endif %} + {% include 'payments/_daily_summary.html' %}