Compare commits

...
2 Commits
Author SHA1 Message Date
javad de3135b588 add payment_link and banks 2026-10-06 10:03:22 +03:30
javad 2ae79b1ca4 design-prototypes/chaharsu 2026-10-05 12:36:58 +03:30
127 changed files with 8271 additions and 47 deletions

No files matched your search

+123 -9
View File
@@ -1,11 +1,11 @@
Project Root: customerTicket Project Root: customerTicket
Generated At: 2026-10-04T06:10:18+00:00 Generated At: 2026-10-05T09:09:24+00:00
Python Files: 214 Python Files: 223
Django Files: 42 Django Files: 42
JavaScript/TypeScript Files: 21 JavaScript/TypeScript Files: 28
HTML Files: 33 HTML Files: 45
Total Files: 841 Total Files: 951
Total Directories: 102 Total Directories: 106
customerTicket customerTicket
├── .agents ├── .agents
@@ -196,6 +196,7 @@ customerTicket
│ │ │ ├── 0014_merge_legacy_warehouse_units.py │ │ │ ├── 0014_merge_legacy_warehouse_units.py
│ │ │ ├── 0015_seed_customer_category_catalog.py │ │ │ ├── 0015_seed_customer_category_catalog.py
│ │ │ ├── 0016_scope_legacy_public_tickets_to_units.py │ │ │ ├── 0016_scope_legacy_public_tickets_to_units.py
│ │ │ ├── 0017_ticketaccessgrant_first_viewed_at_and_more.py
│ │ │ └── __init__.py │ │ │ └── __init__.py
│ │ ├── __init__.py │ │ ├── __init__.py
│ │ ├── admin.py │ │ ├── admin.py
@@ -207,6 +208,9 @@ customerTicket
│ │ ├── policies.py │ │ ├── policies.py
│ │ ├── selectors.py │ │ ├── selectors.py
│ │ ├── services.py │ │ ├── services.py
│ │ ├── sharing.py
│ │ ├── sharing_views.py
│ │ ├── staff_conversation.py
│ │ ├── staff_urls.py │ │ ├── staff_urls.py
│ │ └── views.py │ │ └── views.py
│ └── __init__.py │ └── __init__.py
@@ -222,6 +226,34 @@ customerTicket
│ ├── urls.py │ ├── urls.py
│ └── wsgi.py │ └── wsgi.py
├── design-prototypes ├── design-prototypes
│ ├── chaharsu
│ │ ├── accounts-desktop.png
│ │ ├── accounts-list-mobile.png
│ │ ├── accounts-mobile.png
│ │ ├── accounts-small.png
│ │ ├── bank-accounts.html
│ │ ├── desktop.png
│ │ ├── index.html
│ │ ├── mobile-small.png
│ │ ├── mobile.png
│ │ ├── payment-demo-data.js
│ │ ├── payment-receipt.html
│ │ ├── payment-services.js
│ │ ├── README.md
│ │ ├── receipt-amount.js
│ │ ├── receipt-banks.js
│ │ ├── receipt-date.js
│ │ ├── receipt-desktop.png
│ │ ├── receipt-independent-mobile.png
│ │ ├── receipt-mobile.png
│ │ ├── receipt-small.png
│ │ ├── receipt-validation-mobile.png
│ │ ├── receipt-validation.js
│ │ ├── requests-desktop.png
│ │ ├── requests-mobile.png
│ │ ├── requests-small.png
│ │ ├── service-pages.css
│ │ └── STYLE_GUIDE.md
│ └── ticket-new-wizard │ └── ticket-new-wizard
│ ├── index.html │ ├── index.html
│ ├── prototype.js │ ├── prototype.js
@@ -237,6 +269,7 @@ customerTicket
│ ├── PHONE_CONTACTS_API.md │ ├── PHONE_CONTACTS_API.md
│ ├── PRODUCTION_SEED.md │ ├── PRODUCTION_SEED.md
│ ├── QA_PERMISSION_MATRIX.md │ ├── QA_PERMISSION_MATRIX.md
│ ├── STAFF_SHARING.md
│ └── THEMING.md │ └── THEMING.md
├── graphify-out ├── graphify-out
│ ├── 2026-09-05 │ ├── 2026-09-05
@@ -292,6 +325,21 @@ customerTicket
│ │ ├── graph.json │ │ ├── graph.json
│ │ ├── GRAPH_REPORT.md │ │ ├── GRAPH_REPORT.md
│ │ └── manifest.json │ │ └── manifest.json
│ ├── 2026-10-04
│ │ ├── .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
│ │ ├── cost.json
│ │ ├── graph.html
│ │ ├── graph.json
│ │ ├── GRAPH_REPORT.md
│ │ └── manifest.json
│ ├── cache │ ├── cache
│ │ ├── ast │ │ ├── ast
│ │ │ └── v0.9.68-s4 │ │ │ └── v0.9.68-s4
@@ -302,6 +350,8 @@ customerTicket
│ │ │ ├── 02a3519f5b471b7083c161d9d9c73ab5b0ee78cf7e2cc4d5be7c2542435aa58e.json │ │ │ ├── 02a3519f5b471b7083c161d9d9c73ab5b0ee78cf7e2cc4d5be7c2542435aa58e.json
│ │ │ ├── 03040dc947a4bba22aafbc6262c9db2c8549826ae5aa1ac4cc1821806f93561f.json │ │ │ ├── 03040dc947a4bba22aafbc6262c9db2c8549826ae5aa1ac4cc1821806f93561f.json
│ │ │ ├── 039590a02c184d21a28caec4178b981a824ec4356a225d1ce546cc212d2f550f.json │ │ │ ├── 039590a02c184d21a28caec4178b981a824ec4356a225d1ce546cc212d2f550f.json
│ │ │ ├── 04b84d19d132764512441a31b31294e4f19dc963ed0a6e3abdd3eca7719e8bca.json
│ │ │ ├── 057ccb6901a83922bee4265993977ceb0d0ac425c900cb2d2f3181998df71796.json
│ │ │ ├── 058b4e36240cd25d158647c300472baad5e7101ebde0319883abb0edc8c9c74d.json │ │ │ ├── 058b4e36240cd25d158647c300472baad5e7101ebde0319883abb0edc8c9c74d.json
│ │ │ ├── 07e174cf218887aa0f8747199874faf6bc8af9347e43ff9b5a2073c089637f6f.json │ │ │ ├── 07e174cf218887aa0f8747199874faf6bc8af9347e43ff9b5a2073c089637f6f.json
│ │ │ ├── 08ff4daae812563107028890c2ae7257bb5f9a376a978244c814b7a4c1d10a98.json │ │ │ ├── 08ff4daae812563107028890c2ae7257bb5f9a376a978244c814b7a4c1d10a98.json
@@ -312,8 +362,10 @@ customerTicket
│ │ │ ├── 0d276b031b1e68ce07a7e0620645994c4d80c88dbdeb4b49deba5e053ef188cb.json │ │ │ ├── 0d276b031b1e68ce07a7e0620645994c4d80c88dbdeb4b49deba5e053ef188cb.json
│ │ │ ├── 0d5f84af41009fdecae2a46d4ad5fab38a79c1a5dde262e9c7f8d35707a35092.json │ │ │ ├── 0d5f84af41009fdecae2a46d4ad5fab38a79c1a5dde262e9c7f8d35707a35092.json
│ │ │ ├── 0ef31d053655ee64599a0246f26c8eb61f27654ae1ffbd41b87500438fc9b06a.json │ │ │ ├── 0ef31d053655ee64599a0246f26c8eb61f27654ae1ffbd41b87500438fc9b06a.json
│ │ │ ├── 0f28b01b2484ce84b5251ff848065a2c6f7f574665305b5ecf8cb31a755a7982.json
│ │ │ ├── 11d2b71feb1480622fb18ee3795cf070421b146e4273b5016fad81e7cc8e2c7e.json │ │ │ ├── 11d2b71feb1480622fb18ee3795cf070421b146e4273b5016fad81e7cc8e2c7e.json
│ │ │ ├── 11ff4da3afbce9cae19102862deefdf725fa363130fad753fa189fa4f6c8c4de.json │ │ │ ├── 11ff4da3afbce9cae19102862deefdf725fa363130fad753fa189fa4f6c8c4de.json
│ │ │ ├── 12a877e28c12d0651d6487751c6b746afbe8361f9ba7e1b672f09bd596fbcf82.json
│ │ │ ├── 12db2ab20d1e0be8080b5edd6cc40cacfdff350dd585126a0e0ce144073ad12f.json │ │ │ ├── 12db2ab20d1e0be8080b5edd6cc40cacfdff350dd585126a0e0ce144073ad12f.json
│ │ │ ├── 12f95828a2c1818402459627e36481199020e31faa280d214f81700fb9ad520d.json │ │ │ ├── 12f95828a2c1818402459627e36481199020e31faa280d214f81700fb9ad520d.json
│ │ │ ├── 1436cce90bf2f00f050c9eb67cddf5672ed6a3db185ef663c257ee7936b175ae.json │ │ │ ├── 1436cce90bf2f00f050c9eb67cddf5672ed6a3db185ef663c257ee7936b175ae.json
@@ -341,6 +393,7 @@ customerTicket
│ │ │ ├── 2b7cc5d0dec7234082c1797ab637b916ac7df39277f8fbf109ae90190fc1e900.json │ │ │ ├── 2b7cc5d0dec7234082c1797ab637b916ac7df39277f8fbf109ae90190fc1e900.json
│ │ │ ├── 2c8d91fbfca457646cf60edb03fd3a89a2494a6b6612087854edeac5a119739a.json │ │ │ ├── 2c8d91fbfca457646cf60edb03fd3a89a2494a6b6612087854edeac5a119739a.json
│ │ │ ├── 2df1edd1fd32c467f237babe4a94f2ed73b67270ede6fa8298b32eab7c3eb80b.json │ │ │ ├── 2df1edd1fd32c467f237babe4a94f2ed73b67270ede6fa8298b32eab7c3eb80b.json
│ │ │ ├── 2e7903ddca9e29c53f1706f2755303e56d60436e494d066d011fdba93e701903.json
│ │ │ ├── 2fd5953e7926f320bdeac2d559967758aef4632bf7dfa26a5ee50ad386fc9b67.json │ │ │ ├── 2fd5953e7926f320bdeac2d559967758aef4632bf7dfa26a5ee50ad386fc9b67.json
│ │ │ ├── 3142f9d4517f137483c716dd61985811bbf006b85506791e9a2ee37d58b2f53e.json │ │ │ ├── 3142f9d4517f137483c716dd61985811bbf006b85506791e9a2ee37d58b2f53e.json
│ │ │ ├── 32a4505d16f4fd1efc5bc0c2ea7c8900419856b21a41b06d988cc424258ea1c9.json │ │ │ ├── 32a4505d16f4fd1efc5bc0c2ea7c8900419856b21a41b06d988cc424258ea1c9.json
@@ -354,11 +407,13 @@ customerTicket
│ │ │ ├── 360e95dd7f9428a51961a1f4ae7968a6e3101b8df00027432c6c743f64ccf9f8.json │ │ │ ├── 360e95dd7f9428a51961a1f4ae7968a6e3101b8df00027432c6c743f64ccf9f8.json
│ │ │ ├── 367d8bf55d8d85048571009c40b04ef43211cc9c630af6222fc5db65fb382cc8.json │ │ │ ├── 367d8bf55d8d85048571009c40b04ef43211cc9c630af6222fc5db65fb382cc8.json
│ │ │ ├── 36c68425778aff8c4320f570cc4e4ec8b3c40094abdeffc556f5488085c587e5.json │ │ │ ├── 36c68425778aff8c4320f570cc4e4ec8b3c40094abdeffc556f5488085c587e5.json
│ │ │ ├── 36f5a5ada8b3f2cb753e47b20cfc0baf6e6fcb2fb67ac8d7df658ec8b82b34f4.json
│ │ │ ├── 393b2ca571bf671f26f243678a1be73266fe7435c43d7c837f1b53d4dc54cc66.json │ │ │ ├── 393b2ca571bf671f26f243678a1be73266fe7435c43d7c837f1b53d4dc54cc66.json
│ │ │ ├── 3948717314f47afa835e760073df4764ac427f4858f5ec0ee6a2f298ac393d49.json │ │ │ ├── 3948717314f47afa835e760073df4764ac427f4858f5ec0ee6a2f298ac393d49.json
│ │ │ ├── 3b9252049dbf8ef67e64a756bb41325a6cf50190004d4442119d438620bacc72.json │ │ │ ├── 3b9252049dbf8ef67e64a756bb41325a6cf50190004d4442119d438620bacc72.json
│ │ │ ├── 3c4a42b0b79b95acf81967479efdc4cd0a369e3b53b771fe87478d6327ddf0eb.json │ │ │ ├── 3c4a42b0b79b95acf81967479efdc4cd0a369e3b53b771fe87478d6327ddf0eb.json
│ │ │ ├── 3cec8055583c7d94e03ced535376a8bd9d3d335cd6c7e328f38dc2a9e6b1e397.json │ │ │ ├── 3cec8055583c7d94e03ced535376a8bd9d3d335cd6c7e328f38dc2a9e6b1e397.json
│ │ │ ├── 3d66d87daf2039a3fcd22d5d844e30c00443bed258dc048fbf939d97258e08d2.json
│ │ │ ├── 3f0cad4a4a6eb440a2b133bf72f2e72d6e7ef3668f4c8dbc2c759dbd1a41eb6f.json │ │ │ ├── 3f0cad4a4a6eb440a2b133bf72f2e72d6e7ef3668f4c8dbc2c759dbd1a41eb6f.json
│ │ │ ├── 401b3df2aa1a5c618d917ae2422e552c59dfbc5220b312fe7977d751168f6987.json │ │ │ ├── 401b3df2aa1a5c618d917ae2422e552c59dfbc5220b312fe7977d751168f6987.json
│ │ │ ├── 408f6eb568e45e950fdc170776dabf9087829df0db630e16643fba8995318d26.json │ │ │ ├── 408f6eb568e45e950fdc170776dabf9087829df0db630e16643fba8995318d26.json
@@ -373,6 +428,7 @@ customerTicket
│ │ │ ├── 485d542ea0237235e2d09999130ffa61c11da13873b62ca3e9228896f29da35f.json │ │ │ ├── 485d542ea0237235e2d09999130ffa61c11da13873b62ca3e9228896f29da35f.json
│ │ │ ├── 48f2dd332e3e37756440716095b3b0ba781ebd5abae50130c6b8bfee5a0468cd.json │ │ │ ├── 48f2dd332e3e37756440716095b3b0ba781ebd5abae50130c6b8bfee5a0468cd.json
│ │ │ ├── 4993b53d428cbbb1e334ea396aae6397586a1295a35f39da038cfe72f0b89fd4.json │ │ │ ├── 4993b53d428cbbb1e334ea396aae6397586a1295a35f39da038cfe72f0b89fd4.json
│ │ │ ├── 49dbf05487834af2e8fb3c1c4048f93565e6ca40251d04486a137b8f960adcac.json
│ │ │ ├── 4ae084687e4372e69f349e8bbc5cf603768a032d3d9ee4d2504e6709ed8ee440.json │ │ │ ├── 4ae084687e4372e69f349e8bbc5cf603768a032d3d9ee4d2504e6709ed8ee440.json
│ │ │ ├── 4ae419f04a3a3b1221faef43b8526910f1958336162fbce432bab0e596ef2aa1.json │ │ │ ├── 4ae419f04a3a3b1221faef43b8526910f1958336162fbce432bab0e596ef2aa1.json
│ │ │ ├── 4bf46c6c0f27e14312f7c1311bca1bf5e1bfee29ea5cb35e6f94142141985d23.json │ │ │ ├── 4bf46c6c0f27e14312f7c1311bca1bf5e1bfee29ea5cb35e6f94142141985d23.json
@@ -383,13 +439,16 @@ customerTicket
│ │ │ ├── 508a0054ec5c5b3353e94566a0f728d533ec47721e20e6037aa55e2d665a5143.json │ │ │ ├── 508a0054ec5c5b3353e94566a0f728d533ec47721e20e6037aa55e2d665a5143.json
│ │ │ ├── 52cd74ad5803b940489587043429b741434ae931fa4d244f5b0dc53582cee050.json │ │ │ ├── 52cd74ad5803b940489587043429b741434ae931fa4d244f5b0dc53582cee050.json
│ │ │ ├── 5323b09e3c325ba8c161f5e2cab233b25cfa1b96ff042c6ae79ae1b029e2b1bc.json │ │ │ ├── 5323b09e3c325ba8c161f5e2cab233b25cfa1b96ff042c6ae79ae1b029e2b1bc.json
│ │ │ ├── 54d567d298ddc3c274327011a29bd4f2f2cb31efaae5ecb3a7a94164a3eb3c1f.json
│ │ │ ├── 57bc96866536f0d67300ed29e56bd0f45fa9119c787fac805f228adcab18dc6f.json │ │ │ ├── 57bc96866536f0d67300ed29e56bd0f45fa9119c787fac805f228adcab18dc6f.json
│ │ │ ├── 582c91a8ddabbea5521fc65d724e4fb3672c070f9213fae7b58595fcf62f3c85.json │ │ │ ├── 582c91a8ddabbea5521fc65d724e4fb3672c070f9213fae7b58595fcf62f3c85.json
│ │ │ ├── 584447cf308657a696ef8465181b12d2c06f9dc5ff69752bea66bd0b9631feec.json │ │ │ ├── 584447cf308657a696ef8465181b12d2c06f9dc5ff69752bea66bd0b9631feec.json
│ │ │ ├── 596a13e9b322f8c3332a3ae214ca7bf368ba6158ff3adcc7db4582f102ea51dc.json │ │ │ ├── 596a13e9b322f8c3332a3ae214ca7bf368ba6158ff3adcc7db4582f102ea51dc.json
│ │ │ ├── 5a34ded8b2dd4c10c4251d287580918b88690c7058dd2299d2d670170104241d.json │ │ │ ├── 5a34ded8b2dd4c10c4251d287580918b88690c7058dd2299d2d670170104241d.json
│ │ │ ├── 5d43f2c9afc3451745d2a1b5f1ed17bc138c9b19caff61beadbc40a4e970b525.json │ │ │ ├── 5d43f2c9afc3451745d2a1b5f1ed17bc138c9b19caff61beadbc40a4e970b525.json
│ │ │ ├── 5f759ce7c1bcc67b3972da0ff15616dedf748eb2a85e029802d6f0e0feb053ee.json
│ │ │ ├── 5fcd0fdc47a301cbd07130dfa7545017b5975c29b9d7458ec65938ed503506a6.json │ │ │ ├── 5fcd0fdc47a301cbd07130dfa7545017b5975c29b9d7458ec65938ed503506a6.json
│ │ │ ├── 608fed7bef745c90c049732f5db2efe3982e483670dac36fff2b6e22ff2953fa.json
│ │ │ ├── 60ef2d6fded9e070e35cfed119e8325010aa76bce63aa74ca0f25005d8ed9517.json │ │ │ ├── 60ef2d6fded9e070e35cfed119e8325010aa76bce63aa74ca0f25005d8ed9517.json
│ │ │ ├── 62dab578f0debe93a8df61a1776953980abd254ae6f1eea77afb00dfefd28f15.json │ │ │ ├── 62dab578f0debe93a8df61a1776953980abd254ae6f1eea77afb00dfefd28f15.json
│ │ │ ├── 62e55882d2ce81dcfe5df1d58395e767ded55d6e39997d82cc3489c5e02ae910.json │ │ │ ├── 62e55882d2ce81dcfe5df1d58395e767ded55d6e39997d82cc3489c5e02ae910.json
@@ -405,10 +464,12 @@ customerTicket
│ │ │ ├── 6bd49215f8d2739a9e365dcc8855b95cfa34352c067a9e9ff48a623318c4ddaa.json │ │ │ ├── 6bd49215f8d2739a9e365dcc8855b95cfa34352c067a9e9ff48a623318c4ddaa.json
│ │ │ ├── 6c69494ba124d4bc564bcd3a3df1f33ae0ee0769001a2dfc46825ef8b44dc3e2.json │ │ │ ├── 6c69494ba124d4bc564bcd3a3df1f33ae0ee0769001a2dfc46825ef8b44dc3e2.json
│ │ │ ├── 6d3b094dc56f9b26ce7374ba28e460b85248711223cf516f8e732c5d8f4d9d75.json │ │ │ ├── 6d3b094dc56f9b26ce7374ba28e460b85248711223cf516f8e732c5d8f4d9d75.json
│ │ │ ├── 6d5685e1562faf37f6c30c614bcc7628edd19cd8181d5791aff416daf9fd5a6c.json
│ │ │ ├── 6ee9154979fb497a8ec229a7177660af53a487e1aa17c7b555cdc67797615162.json │ │ │ ├── 6ee9154979fb497a8ec229a7177660af53a487e1aa17c7b555cdc67797615162.json
│ │ │ ├── 7093cbd0a855750457814388b6e527fd3b612163f7a2b898ed6ce470dae74843.json │ │ │ ├── 7093cbd0a855750457814388b6e527fd3b612163f7a2b898ed6ce470dae74843.json
│ │ │ ├── 72ae8a21c9106c639725190cc02f4e07efe7c645896cc4410923ad36751fbb2e.json │ │ │ ├── 72ae8a21c9106c639725190cc02f4e07efe7c645896cc4410923ad36751fbb2e.json
│ │ │ ├── 72de23f55766289c980e6ccf604ca5d162e0989a222271b7496775ba4d749e3a.json │ │ │ ├── 72de23f55766289c980e6ccf604ca5d162e0989a222271b7496775ba4d749e3a.json
│ │ │ ├── 7379dad88b1741f6f4d999fcea3eb0ac4ffe56f72c07dc11881e94615457bd52.json
│ │ │ ├── 74c2a0c0f83087673b100b7555e506c71d2a6a941677e388a1de59371b6d9afe.json │ │ │ ├── 74c2a0c0f83087673b100b7555e506c71d2a6a941677e388a1de59371b6d9afe.json
│ │ │ ├── 75fad6124616f6d9e50ce9a475d4e989955c5fae7c5566b99992d986ef9a1f93.json │ │ │ ├── 75fad6124616f6d9e50ce9a475d4e989955c5fae7c5566b99992d986ef9a1f93.json
│ │ │ ├── 77916c3b035a577b24da1a1dbcf47db7307af4e0774fecc549f7c95b91994a20.json │ │ │ ├── 77916c3b035a577b24da1a1dbcf47db7307af4e0774fecc549f7c95b91994a20.json
@@ -422,6 +483,8 @@ customerTicket
│ │ │ ├── 7d276789b80c8d9d1677556b3fb33b2da05988e97f6bf28903a044bb9fd64894.json │ │ │ ├── 7d276789b80c8d9d1677556b3fb33b2da05988e97f6bf28903a044bb9fd64894.json
│ │ │ ├── 7e136779a95d8e8e2ac6cad2192890254fd0d54d46b4f5514debba0f22618d75.json │ │ │ ├── 7e136779a95d8e8e2ac6cad2192890254fd0d54d46b4f5514debba0f22618d75.json
│ │ │ ├── 7e1c0a38a3ed90205d1829bdef528887ef12c928d4da797e06a79b3e7a1a92ba.json │ │ │ ├── 7e1c0a38a3ed90205d1829bdef528887ef12c928d4da797e06a79b3e7a1a92ba.json
│ │ │ ├── 7eaffccd2a080e53d836bffdbe88facc2bfdde45ea602c9a800ee4e06c9f2f94.json
│ │ │ ├── 7f6e099e26893c4841b257491053beb516a92f256378a8e60e01d18e2635295f.json
│ │ │ ├── 7fa9734f0b6c5bbde268cb55ce9ba5016e9fd7162daaa665ed305a2d7e999bbe.json │ │ │ ├── 7fa9734f0b6c5bbde268cb55ce9ba5016e9fd7162daaa665ed305a2d7e999bbe.json
│ │ │ ├── 80468d8a995fb4092dfe5e57e25592c06713287d48367b675ae4d00955d4b25f.json │ │ │ ├── 80468d8a995fb4092dfe5e57e25592c06713287d48367b675ae4d00955d4b25f.json
│ │ │ ├── 806a3b07b690ca3871b1854aaff6f9042e48cf7aaa9bb9b6f15ab9634eae3b37.json │ │ │ ├── 806a3b07b690ca3871b1854aaff6f9042e48cf7aaa9bb9b6f15ab9634eae3b37.json
@@ -430,13 +493,16 @@ customerTicket
│ │ │ ├── 82fcc152dd67510406aa894d1e38939bac365093de9f34db4dfe2485806fda4f.json │ │ │ ├── 82fcc152dd67510406aa894d1e38939bac365093de9f34db4dfe2485806fda4f.json
│ │ │ ├── 833fb89a8d72afc195b8f5bd7338109d8ce7be79f240dac31c4dcd36c8c674f6.json │ │ │ ├── 833fb89a8d72afc195b8f5bd7338109d8ce7be79f240dac31c4dcd36c8c674f6.json
│ │ │ ├── 84579b3014e1a83e4201862df1e93378f5f953fbb547fea30965e335be5e7b1e.json │ │ │ ├── 84579b3014e1a83e4201862df1e93378f5f953fbb547fea30965e335be5e7b1e.json
│ │ │ ├── 8493b4822bc3ed0ccd847d04d26598953d76d438e8381c1102922ac8530cd4d4.json
│ │ │ ├── 84dedd4165eed360c4051690b2aa84f6ecc08f3f053f4129ca6f3e33c334ba35.json │ │ │ ├── 84dedd4165eed360c4051690b2aa84f6ecc08f3f053f4129ca6f3e33c334ba35.json
│ │ │ ├── 853fba8af82971f4fa56d957282d45d335d2e794f8e11001977161265af2ec79.json │ │ │ ├── 853fba8af82971f4fa56d957282d45d335d2e794f8e11001977161265af2ec79.json
│ │ │ ├── 8595a5d566d43428818ee229e64f6b56453cc9ca61fd68be0b1c3c783d326315.json │ │ │ ├── 8595a5d566d43428818ee229e64f6b56453cc9ca61fd68be0b1c3c783d326315.json
│ │ │ ├── 88ade181c6611ec33d9bcf5fe739dc809d81875b6571b9f60f87dafb4f5941b2.json │ │ │ ├── 88ade181c6611ec33d9bcf5fe739dc809d81875b6571b9f60f87dafb4f5941b2.json
│ │ │ ├── 899b16856382d32c4e5b39d02cb251bdedaf35c5a81bede5c19bfaff092208e9.json │ │ │ ├── 899b16856382d32c4e5b39d02cb251bdedaf35c5a81bede5c19bfaff092208e9.json
│ │ │ ├── 8e4cb8d07f4d43e171550ff2fc9423efd032b911d7ffee51259e437d4dc32d4f.json │ │ │ ├── 8e4cb8d07f4d43e171550ff2fc9423efd032b911d7ffee51259e437d4dc32d4f.json
│ │ │ ├── 91c2dc58207c4311391f22d524f461032f7ef3c9443d8f584edb2128966821bb.json
│ │ │ ├── 91d83c62f39f219ccb6125502bf8e0a71a587dfc0a66d977a9bb93c8f2dc5dc6.json │ │ │ ├── 91d83c62f39f219ccb6125502bf8e0a71a587dfc0a66d977a9bb93c8f2dc5dc6.json
│ │ │ ├── 91f4a9afed43fafc3d99d7fe5eaa945cd5c89e29d4d6f6448b630c3e58db05e8.json
│ │ │ ├── 9301321aa7fb1a7beff5518eb54fbf1bf7145e0aa76d3ed29ae4cb38f1057da9.json │ │ │ ├── 9301321aa7fb1a7beff5518eb54fbf1bf7145e0aa76d3ed29ae4cb38f1057da9.json
│ │ │ ├── 9326d87e36b2dc24e6173aa3346f46e7df9fc3353fdb409304af4363c286a41e.json │ │ │ ├── 9326d87e36b2dc24e6173aa3346f46e7df9fc3353fdb409304af4363c286a41e.json
│ │ │ ├── 9697c37cf3a88d2b4eb5419b2381951a9aca966172316db79e7b4c32557d5e87.json │ │ │ ├── 9697c37cf3a88d2b4eb5419b2381951a9aca966172316db79e7b4c32557d5e87.json
@@ -449,6 +515,7 @@ customerTicket
│ │ │ ├── 99f94b7b3b60f627c482cc195709b98cd2faa55f14c19c4bf5e0af8a88f34b7a.json │ │ │ ├── 99f94b7b3b60f627c482cc195709b98cd2faa55f14c19c4bf5e0af8a88f34b7a.json
│ │ │ ├── 9b267b0c45b93cecc1294ca1e040e5a18fdc9e0bee69a0ca8571ee11becdee97.json │ │ │ ├── 9b267b0c45b93cecc1294ca1e040e5a18fdc9e0bee69a0ca8571ee11becdee97.json
│ │ │ ├── 9b8bd2aff50e08b6f053b74a409c9439db41e692e29aabca5b67057884d945af.json │ │ │ ├── 9b8bd2aff50e08b6f053b74a409c9439db41e692e29aabca5b67057884d945af.json
│ │ │ ├── 9cac12079d625e44ef3f62c6e4c9c7848f1cdd4d086c9251b3c81101cf4c114e.json
│ │ │ ├── 9ce16f69c6b04f703adb24ec7498a2d2ecbf6f7524c459deb25111247e550698.json │ │ │ ├── 9ce16f69c6b04f703adb24ec7498a2d2ecbf6f7524c459deb25111247e550698.json
│ │ │ ├── 9d0e2a1fb14529aabd31c82c8d8f5543c0068366932934038ba93e234f291e22.json │ │ │ ├── 9d0e2a1fb14529aabd31c82c8d8f5543c0068366932934038ba93e234f291e22.json
│ │ │ ├── 9ed5e291859abba38b07725bc6cf0275a1548e937888a5ba177674afaa48b96f.json │ │ │ ├── 9ed5e291859abba38b07725bc6cf0275a1548e937888a5ba177674afaa48b96f.json
@@ -479,6 +546,7 @@ customerTicket
│ │ │ ├── b396b497b11e135cfc19ccbee84e3e67df0e401a412f51c0336c89cb3609b5f5.json │ │ │ ├── b396b497b11e135cfc19ccbee84e3e67df0e401a412f51c0336c89cb3609b5f5.json
│ │ │ ├── b4446ca11a1f4f6ea45256e755ed56d6ba7c330278e15ead8cae2de28d5e18d8.json │ │ │ ├── b4446ca11a1f4f6ea45256e755ed56d6ba7c330278e15ead8cae2de28d5e18d8.json
│ │ │ ├── b6e731246dc10854a0fa0455c3c5816e29887b1a8c436d518926e1c2a5e36239.json │ │ │ ├── b6e731246dc10854a0fa0455c3c5816e29887b1a8c436d518926e1c2a5e36239.json
│ │ │ ├── b7ddd6ec7284c45f0925a557b161afd7c9d01171b06c3f723ac4da599ecf47d2.json
│ │ │ ├── b922fd1a31f3cf602f197c872f3bac6e69c79255e502ddcbf1626f846ec3baee.json │ │ │ ├── b922fd1a31f3cf602f197c872f3bac6e69c79255e502ddcbf1626f846ec3baee.json
│ │ │ ├── bb3d09cc5d1bc380e841e2eb19cb497391117101c73c358e6db03db4d6ae51e7.json │ │ │ ├── bb3d09cc5d1bc380e841e2eb19cb497391117101c73c358e6db03db4d6ae51e7.json
│ │ │ ├── c1060dca2a3f437f386324975be659215fe9f7e689137dc85e823879b462d11f.json │ │ │ ├── c1060dca2a3f437f386324975be659215fe9f7e689137dc85e823879b462d11f.json
@@ -500,6 +568,7 @@ customerTicket
│ │ │ ├── d188f9185e71f8910607d275d82fea22199ba0e6009a5b1e34e5a20f041f0d71.json │ │ │ ├── d188f9185e71f8910607d275d82fea22199ba0e6009a5b1e34e5a20f041f0d71.json
│ │ │ ├── d2513d4f5ff7e6088864ff497613f47d4083c1ca378649a3f1db6d45a3f3fb2b.json │ │ │ ├── d2513d4f5ff7e6088864ff497613f47d4083c1ca378649a3f1db6d45a3f3fb2b.json
│ │ │ ├── d26a8fc663e9e5fcf33c87592f8c0456e7fe9ec3390bcb902631144d455c2922.json │ │ │ ├── d26a8fc663e9e5fcf33c87592f8c0456e7fe9ec3390bcb902631144d455c2922.json
│ │ │ ├── d54677c4320658676f86c09348933f298192a048b637aef94d607eea8a92b474.json
│ │ │ ├── d55e8987ca5ff35748b62290dc14150b1df53163c6f5a5cbe19d03058368f1c6.json │ │ │ ├── d55e8987ca5ff35748b62290dc14150b1df53163c6f5a5cbe19d03058368f1c6.json
│ │ │ ├── d561ececcdd4e7ff59595389fa499591f4e7f3c5324a58dbdd749ccf29e90cd1.json │ │ │ ├── d561ececcdd4e7ff59595389fa499591f4e7f3c5324a58dbdd749ccf29e90cd1.json
│ │ │ ├── d5dec4474de6c46020e987f20aa3fe54c9bb9c6726a067a75ed94091d552b87f.json │ │ │ ├── d5dec4474de6c46020e987f20aa3fe54c9bb9c6726a067a75ed94091d552b87f.json
@@ -509,6 +578,7 @@ customerTicket
│ │ │ ├── d9516c3232e0f78225764bf4f9586c88932df925c45de71f4eb1c883a9ed5b5d.json │ │ │ ├── d9516c3232e0f78225764bf4f9586c88932df925c45de71f4eb1c883a9ed5b5d.json
│ │ │ ├── d9c81db9cf9f2fee8a4985367d33b1c0ab1d7e6901e85473070c3bfd6bfe32c6.json │ │ │ ├── d9c81db9cf9f2fee8a4985367d33b1c0ab1d7e6901e85473070c3bfd6bfe32c6.json
│ │ │ ├── daa187dac6877c746c02269141a7e7db178ac32a0b3a4bfeaaa8ed046b6dcffb.json │ │ │ ├── daa187dac6877c746c02269141a7e7db178ac32a0b3a4bfeaaa8ed046b6dcffb.json
│ │ │ ├── dbaef2f4942a9f7ecde0afc10e4eebfe3a2809af1d8b76cb036bd417f19274b1.json
│ │ │ ├── dc90ac5e8d6766f8001bc2a40d21c203717d7a9f469956c2f6d9bedf3ae51aea.json │ │ │ ├── dc90ac5e8d6766f8001bc2a40d21c203717d7a9f469956c2f6d9bedf3ae51aea.json
│ │ │ ├── dd795078450fe69f61a445d3cf1275420be57381b005597cb5003859b2025800.json │ │ │ ├── dd795078450fe69f61a445d3cf1275420be57381b005597cb5003859b2025800.json
│ │ │ ├── de1655ae1d42e56f0cdb8788c0f6ba1c7c6e6725e6ef5e80969b92ec3e68ac78.json │ │ │ ├── de1655ae1d42e56f0cdb8788c0f6ba1c7c6e6725e6ef5e80969b92ec3e68ac78.json
@@ -526,6 +596,7 @@ customerTicket
│ │ │ ├── e8f3b285df9ff4d30c78eb1eded3eefdd86cfe325a7c8c29fb8457b6dde5e712.json │ │ │ ├── e8f3b285df9ff4d30c78eb1eded3eefdd86cfe325a7c8c29fb8457b6dde5e712.json
│ │ │ ├── e90aa709aa08fcf3294352b588ce8c8c902f3ef59a503d4b8d9a292a9c478096.json │ │ │ ├── e90aa709aa08fcf3294352b588ce8c8c902f3ef59a503d4b8d9a292a9c478096.json
│ │ │ ├── e974cd0006d8160a6ef1316083bac9e699adfd87acc7edb404ae7eba1c799b68.json │ │ │ ├── e974cd0006d8160a6ef1316083bac9e699adfd87acc7edb404ae7eba1c799b68.json
│ │ │ ├── e9b4ac230d46fa80048827a24c4211b7a71a741f2997ffaf0921b25a40f505c5.json
│ │ │ ├── ea56445d1314587e18b1992d9599da03e1419ea7bc4929854b9039d6947de33b.json │ │ │ ├── ea56445d1314587e18b1992d9599da03e1419ea7bc4929854b9039d6947de33b.json
│ │ │ ├── ea8564b376c7156927c3f412f1a7173248d6b2fd7dcfc3492c4c9d7c44dae889.json │ │ │ ├── ea8564b376c7156927c3f412f1a7173248d6b2fd7dcfc3492c4c9d7c44dae889.json
│ │ │ ├── eb1dd65efa5a99811ccca80ff03a73cb155ad86d13635010a44c09c13953604d.json │ │ │ ├── eb1dd65efa5a99811ccca80ff03a73cb155ad86d13635010a44c09c13953604d.json
@@ -548,6 +619,7 @@ customerTicket
│ │ │ ├── f8769e3eeb23aebc01d72869a2bc6e635d7dc82440bc2b0b0e11e6b6b17f5109.json │ │ │ ├── f8769e3eeb23aebc01d72869a2bc6e635d7dc82440bc2b0b0e11e6b6b17f5109.json
│ │ │ ├── f8fc55a028176d1ea9a1b580a8653d4c45ae7466445f37e6c23867f12821c44c.json │ │ │ ├── f8fc55a028176d1ea9a1b580a8653d4c45ae7466445f37e6c23867f12821c44c.json
│ │ │ ├── fb9736a4ef3aca74573f131c7cbd9f015d88dd755ffba3edd324aec95149175f.json │ │ │ ├── fb9736a4ef3aca74573f131c7cbd9f015d88dd755ffba3edd324aec95149175f.json
│ │ │ ├── fca6b7e4c9f92410b2590539af80d6b499509255af4ef88282912c6c79a09330.json
│ │ │ ├── fde577f1231f9b033560bfe075896efc6bf8d6079a030a159035584c7d39843b.json │ │ │ ├── fde577f1231f9b033560bfe075896efc6bf8d6079a030a159035584c7d39843b.json
│ │ │ ├── fe032050843e4e1c28f5712205df74fa1698390169658c4ddc4555b6bcce8872.json │ │ │ ├── fe032050843e4e1c28f5712205df74fa1698390169658c4ddc4555b6bcce8872.json
│ │ │ ├── fe0c3eed2ff5c4b5d2116aba4494d599ae3c27004136e99f5d455f351281fa78.json │ │ │ ├── fe0c3eed2ff5c4b5d2116aba4494d599ae3c27004136e99f5d455f351281fa78.json
@@ -568,6 +640,7 @@ customerTicket
│ │ │ ├── 1e1ba446fa646a9dbd2244d5138ad84e217e8f238f2508603cfe4c290423dde8.json │ │ │ ├── 1e1ba446fa646a9dbd2244d5138ad84e217e8f238f2508603cfe4c290423dde8.json
│ │ │ ├── 1f440edc6feb5bf8c2a462b1b4a0b44bd22b4a763286fb2b340c797c282b5bcd.json │ │ │ ├── 1f440edc6feb5bf8c2a462b1b4a0b44bd22b4a763286fb2b340c797c282b5bcd.json
│ │ │ ├── 1f916be2dc79752fb2aa8e0c386470f43cc983c9021de885b62ca962ffea960f.json │ │ │ ├── 1f916be2dc79752fb2aa8e0c386470f43cc983c9021de885b62ca962ffea960f.json
│ │ │ ├── 20d91cfaf9ec884fd9025e385620d6a4aea0b3d48cb26e5297808f3abc5fdfd2.json
│ │ │ ├── 2233df96365dd9e332d4744056d02289ae3a003a6fb8f3342a205860e03f8665.json │ │ │ ├── 2233df96365dd9e332d4744056d02289ae3a003a6fb8f3342a205860e03f8665.json
│ │ │ ├── 22569dac279084bd62c922dbc391f9a5a7cc2e1051ef043bde5cfc72df0ad864.json │ │ │ ├── 22569dac279084bd62c922dbc391f9a5a7cc2e1051ef043bde5cfc72df0ad864.json
│ │ │ ├── 23f032018f0612c58670d22539577856b5479f6107878d558e5e062bac9da30b.json │ │ │ ├── 23f032018f0612c58670d22539577856b5479f6107878d558e5e062bac9da30b.json
@@ -586,6 +659,7 @@ customerTicket
│ │ │ ├── 527fef297bee36f729186cbfe4bee9b0a41ad7bc98896815e2058c5348b76706.json │ │ │ ├── 527fef297bee36f729186cbfe4bee9b0a41ad7bc98896815e2058c5348b76706.json
│ │ │ ├── 5533327da089db48f0050a2d48af495de46f562ea2e29fcfdf3a527ece013770.json │ │ │ ├── 5533327da089db48f0050a2d48af495de46f562ea2e29fcfdf3a527ece013770.json
│ │ │ ├── 57240e5b1ee2390b9f26b44f11253b97b1c6b279affdedc34cbeeadc74181504.json │ │ │ ├── 57240e5b1ee2390b9f26b44f11253b97b1c6b279affdedc34cbeeadc74181504.json
│ │ │ ├── 5931c8253737ddb54edc00f7f0edb0dfcf52d0f4b34d1c94e05e918a991505ec.json
│ │ │ ├── 5aa8fb6425a50cdd0fdd3fe862960efbb179a8098df347292940f564737404ef.json │ │ │ ├── 5aa8fb6425a50cdd0fdd3fe862960efbb179a8098df347292940f564737404ef.json
│ │ │ ├── 5b5bd6e41d3308af6e19908a392f853875cd8a1f1a10f6e195d21aeab1a6a586.json │ │ │ ├── 5b5bd6e41d3308af6e19908a392f853875cd8a1f1a10f6e195d21aeab1a6a586.json
│ │ │ ├── 660dafe22e562cecf6aaf4afdf658996dd024a1436c57e9815c82caa0c3e1782.json │ │ │ ├── 660dafe22e562cecf6aaf4afdf658996dd024a1436c57e9815c82caa0c3e1782.json
@@ -624,6 +698,7 @@ customerTicket
│ │ │ ├── c931d64d02e6800ca7dcb354e7043c889c2ab0f9c6bd72723e09da0b754501ef.json │ │ │ ├── c931d64d02e6800ca7dcb354e7043c889c2ab0f9c6bd72723e09da0b754501ef.json
│ │ │ ├── ca98ecfba0b2ad600c63c4f743e41528e2add0a2b48774b065aa0f60dcd905e3.json │ │ │ ├── ca98ecfba0b2ad600c63c4f743e41528e2add0a2b48774b065aa0f60dcd905e3.json
│ │ │ ├── cf5a494e78799d9b7557e6021056f1f896a5e8d8378b85d5a4879eeaceb8bec7.json │ │ │ ├── cf5a494e78799d9b7557e6021056f1f896a5e8d8378b85d5a4879eeaceb8bec7.json
│ │ │ ├── d2a670f53c70d2f82ff69615cfec3ab85a8b106dbd812f47cd3f0351c63ad69a.json
│ │ │ ├── ddf9f939efdbd2f15fe815fd608b3048eb2c439343c9700f446739f72582a91b.json │ │ │ ├── ddf9f939efdbd2f15fe815fd608b3048eb2c439343c9700f446739f72582a91b.json
│ │ │ ├── e00a1732e6e7df91a7aabb9bd609e2a5985d5cf7215635cde4a7495a0112efec.json │ │ │ ├── e00a1732e6e7df91a7aabb9bd609e2a5985d5cf7215635cde4a7495a0112efec.json
│ │ │ ├── e10d4f4e825fa6fd025ab71d55be4304798b707016c9b0b0f6cc0da6589a8435.json │ │ │ ├── e10d4f4e825fa6fd025ab71d55be4304798b707016c9b0b0f6cc0da6589a8435.json
@@ -708,12 +783,33 @@ customerTicket
│ ├── .graphify_python │ ├── .graphify_python
│ ├── .graphify_root │ ├── .graphify_root
│ ├── .graphify_semantic.json │ ├── .graphify_semantic.json
│ ├── .html_semantic_extract.json
│ ├── .vocab.txt │ ├── .vocab.txt
│ ├── cost.json │ ├── cost.json
│ ├── db-before-announcement-fix-20261004-115635.sqlite3
│ ├── db-before-staff-sharing.sqlite3
│ ├── graph.html │ ├── graph.html
│ ├── graph.json │ ├── graph.json
│ ├── GRAPH_REPORT.md │ ├── GRAPH_REPORT.md
│ └── manifest.json │ ├── manifest.json
│ ├── shared-ticket-metadata-1280.png
│ ├── shared-ticket-metadata-390.png
│ ├── staff-inline-desktop-closed.png
│ ├── staff-inline-desktop-history.png
│ ├── staff-inline-desktop-opened.png
│ ├── staff-inline-mobile-closed.png
│ ├── staff-inline-mobile-history.png
│ ├── staff-inline-mobile-opened.png
│ ├── staff-sharing-desktop-audit.png
│ ├── staff-sharing-desktop-message.png
│ ├── staff-sharing-desktop-ticket.png
│ ├── staff-sharing-mobile-audit.png
│ ├── staff-sharing-mobile-message.png
│ ├── staff-sharing-mobile-ticket.png
│ ├── unified-shares-message-1280.png
│ ├── unified-shares-message-390.png
│ ├── unified-shares-queue-1280.png
│ └── unified-shares-queue-390.png
├── production-seed ├── production-seed
│ ├── data.json.gz │ ├── data.json.gz
│ ├── manifest.json │ ├── manifest.json
@@ -725,6 +821,7 @@ customerTicket
│ │ ├── dispatches.css │ │ ├── dispatches.css
│ │ ├── enhancements.css │ │ ├── enhancements.css
│ │ ├── operations.css │ │ ├── operations.css
│ │ ├── staff-sharing.css
│ │ ├── tailwind.css │ │ ├── tailwind.css
│ │ ├── ticket-composer-v2.css │ │ ├── ticket-composer-v2.css
│ │ └── ticket-composer.css │ │ └── ticket-composer.css
@@ -748,6 +845,7 @@ customerTicket
│ │ ├── location-picker.js │ │ ├── location-picker.js
│ │ ├── pwa.js │ │ ├── pwa.js
│ │ ├── staff-customer-picker.js │ │ ├── staff-customer-picker.js
│ │ ├── staff-sharing.js
│ │ ├── ticket-intent-search.js │ │ ├── ticket-intent-search.js
│ │ ├── ticket-unit-options.js │ │ ├── ticket-unit-options.js
│ │ ├── ui.js │ │ ├── ui.js
@@ -782,7 +880,15 @@ customerTicket
│ │ ├── _messages.html │ │ ├── _messages.html
│ │ ├── _older_messages.html │ │ ├── _older_messages.html
│ │ ├── _staff_customer_results.html │ │ ├── _staff_customer_results.html
│ │ ├── _staff_message_share.html
│ │ ├── _staff_message_share_form.html
│ │ ├── _staff_messages.html
│ │ ├── _staff_older_messages.html
│ │ ├── _staff_share_audit_row.html
│ │ ├── _staff_share_icon.html
│ │ ├── _staff_sharing_audit.html
│ │ ├── _staff_ticket_results.html │ │ ├── _staff_ticket_results.html
│ │ ├── _staff_ticket_share.html
│ │ ├── _voip_call_button.html │ │ ├── _voip_call_button.html
│ │ ├── customer_detail.html │ │ ├── customer_detail.html
│ │ ├── customer_list.html │ │ ├── customer_list.html
@@ -888,7 +994,10 @@ customerTicket
│ │ ├── tests_e2e_test_staff_operations_ui.py__test_dispatch_report_presents_delivery_and_engagement_professionally │ │ ├── tests_e2e_test_staff_operations_ui.py__test_dispatch_report_presents_delivery_and_engagement_professionally
│ │ │ ├── failure.png │ │ │ ├── failure.png
│ │ │ └── trace.zip │ │ │ └── trace.zip
│ │ └── tests_e2e_test_staff_operations_ui.py__test_staff_operations_pages_are_authorized_and_visually_styled │ │ ├── tests_e2e_test_staff_operations_ui.py__test_staff_operations_pages_are_authorized_and_visually_styled
│ │ │ ├── failure.png
│ │ │ └── trace.zip
│ │ └── tests_e2e_test_staff_sharing_e2e.py__test_staff_can_share_one_message_track_opening_and_revoke
│ │ ├── failure.png │ │ ├── failure.png
│ │ └── trace.zip │ │ └── trace.zip
│ ├── customer-chat-guidance.png │ ├── customer-chat-guidance.png
@@ -900,6 +1009,7 @@ customerTicket
│ │ │ ├── auth.py │ │ │ ├── auth.py
│ │ │ ├── base.py │ │ │ ├── base.py
│ │ │ ├── customer_new_ticket.py │ │ │ ├── customer_new_ticket.py
│ │ │ ├── sharing.py
│ │ │ └── tickets.py │ │ │ └── tickets.py
│ │ ├── config.py │ │ ├── config.py
│ │ ├── conftest.py │ │ ├── conftest.py
@@ -911,7 +1021,8 @@ customerTicket
│ │ ├── test_organization_permissions_e2e.py │ │ ├── test_organization_permissions_e2e.py
│ │ ├── test_public_ticket_workflows_e2e.py │ │ ├── test_public_ticket_workflows_e2e.py
│ │ ├── test_pwa_versioning.py │ │ ├── test_pwa_versioning.py
│ │ └── test_staff_operations_ui.py │ │ ├── test_staff_operations_ui.py
│ │ └── test_staff_sharing_e2e.py
│ ├── js │ ├── js
│ │ └── chat-coach.test.mjs │ │ └── chat-coach.test.mjs
│ ├── test_announcements.py │ ├── test_announcements.py
@@ -927,6 +1038,9 @@ customerTicket
│ ├── test_persian_text.py │ ├── test_persian_text.py
│ ├── test_production_seed.py │ ├── test_production_seed.py
│ ├── test_role_workflow_regression.py │ ├── test_role_workflow_regression.py
│ ├── test_staff_inline_sharing.py
│ ├── test_staff_shared_queue.py
│ ├── test_staff_sharing.py
│ ├── test_views.py │ ├── test_views.py
│ └── test_voip_calls.py │ └── test_voip_calls.py
├── .dockerignore ├── .dockerignore
+36
View File
@@ -0,0 +1,36 @@
def generate_id_algorithm(id_code: str) -> str:
"""
Calculates the verification code based on the reverse-engineered logic:
1. Iterate digits from right to left (index i = 0, 1, 2...).
2. Weights:
- If i < 8: Weight = 8 - i (Sequence: 8, 7, 6, 5, 4, 3, 2, 1)
- If i >= 8: Weight = i + 1 (Sequence: 9, 10, ...)
3. Sum = Sum(Digit * Weight)
4. Check Digit = Sum % 99
"""
# تمیزکاری ورودی (حذف فاصله‌ها و اطمینان از عددی بودن)
if not id_code.isdigit():
return "Error: ورودی باید فقط عدد باشد."
total_sum = 0
# پیمایش از راست به چپ
# reversed(id_code) ارقام را برعکس می‌کند
for i, char in enumerate(reversed(id_code)):
digit = int(char)
# تعیین وزن بر اساس ایندکس (مکان رقم از راست)
if i < 8:
weight = 8 - i
else:
weight = i + 1
total_sum += digit * weight
# محاسبه باقی‌مانده بر 99
check_digit = total_sum % 99
# فرمت‌دهی دو رقمی (مثلاً 6 می‌شود 06)
check_digit_str = f"{check_digit:02}"
return f"{id_code}{check_digit_str}"
+2 -4
View File
@@ -1,4 +1,5 @@
from django.utils import timezone from django.utils import timezone
from apps.core.session_access import grant_scoped_session_access
from .models import TicketMagicLink from .models import TicketMagicLink
@@ -8,10 +9,7 @@ MAGIC_LINK_SESSION_KEY = "ticket_magic_link_id"
def grant_ticket_magic_access(request, link): def grant_ticket_magic_access(request, link):
"""Grant an anonymous session access to exactly one ticket until link expiry.""" """Grant an anonymous session access to exactly one ticket until link expiry."""
request.session.cycle_key() grant_scoped_session_access(request, MAGIC_LINK_SESSION_KEY, link.pk, link.expires_at)
request.session[MAGIC_LINK_SESSION_KEY] = link.pk
remaining = max(1, int((link.expires_at - timezone.now()).total_seconds()))
request.session.set_expiry(remaining)
def get_ticket_magic_access(request, ticket=None): def get_ticket_magic_access(request, ticket=None):
+41 -10
View File
@@ -2,8 +2,11 @@ from django.contrib import messages
from django.conf import settings from django.conf import settings
from django.contrib.auth import login, logout from django.contrib.auth import login, logout
from django.shortcuts import get_object_or_404, redirect, render from django.shortcuts import get_object_or_404, redirect, render
from django.utils.http import url_has_allowed_host_and_scheme
from django.urls import reverse
from django.utils import timezone from django.utils import timezone
from django.views.decorators.http import require_GET, require_http_methods, require_POST from django.views.decorators.http import require_GET, require_http_methods, require_POST
from django.views.decorators.cache import never_cache
from .forms import CustomerPasswordLoginForm, CustomerProfileForm, MobileForm, OTPForm, StaffLoginForm from .forms import CustomerPasswordLoginForm, CustomerProfileForm, MobileForm, OTPForm, StaffLoginForm
from apps.customers.models import CustomerProfile from apps.customers.models import CustomerProfile
from django.contrib.auth.decorators import login_required from django.contrib.auth.decorators import login_required
@@ -16,9 +19,22 @@ import logging
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
def _remember_destination(request):
target = request.GET.get("next", "")
if target and target.startswith(("/chaharsu/", "/tickets/")) and url_has_allowed_host_and_scheme(target, allowed_hosts={request.get_host()}, require_https=request.is_secure()):
request.session["next"] = target
def _login_destination(request):
target = request.session.pop("next", "")
if target.startswith(("/chaharsu/", "/tickets/")) and url_has_allowed_host_and_scheme(target, allowed_hosts={request.get_host()}, require_https=request.is_secure()):
return target
return "staff_tickets:list" if request.user.account_type == User.AccountType.STAFF else "payments:home"
def _authenticated_home(request): def _authenticated_home(request):
if not request.user.is_authenticated: return None if not request.user.is_authenticated: return None
return redirect("staff_tickets:list" if request.user.account_type==User.AccountType.STAFF else "tickets:list") return redirect("staff_tickets:list" if request.user.account_type==User.AccountType.STAFF else "payments:home")
@require_GET @require_GET
def home(request): def home(request):
@@ -27,6 +43,7 @@ def home(request):
@require_http_methods(["GET", "POST"]) @require_http_methods(["GET", "POST"])
def customer_login(request): def customer_login(request):
_remember_destination(request)
if response:=_authenticated_home(request): return response if response:=_authenticated_home(request): return response
form = MobileForm(request.POST or None) form = MobileForm(request.POST or None)
if request.method == "POST" and form.is_valid(): if request.method == "POST" and form.is_valid():
@@ -60,18 +77,19 @@ def verify_otp(request):
if not user.is_active: form.add_error(None, "حساب شما غیرفعال است.") if not user.is_active: form.add_error(None, "حساب شما غیرفعال است.")
else: else:
login(request, user, backend="django.contrib.auth.backends.ModelBackend") login(request, user, backend="django.contrib.auth.backends.ModelBackend")
return redirect(request.session.pop("next", "tickets:list")) return redirect(_login_destination(request))
else: form.add_error("code", "کد نامعتبر یا منقضی است.") else: form.add_error("code", "کد نامعتبر یا منقضی است.")
return render(request, "accounts/otp.html", {"form": form, "mobile": challenge.mobile}) return render(request, "accounts/otp.html", {"form": form, "mobile": challenge.mobile})
@require_http_methods(["GET","POST"]) @require_http_methods(["GET","POST"])
def customer_password_login(request): def customer_password_login(request):
_remember_destination(request)
if response:=_authenticated_home(request): return response if response:=_authenticated_home(request): return response
form=CustomerPasswordLoginForm(request.POST or None) form=CustomerPasswordLoginForm(request.POST or None)
if request.method=="POST" and form.is_valid(): if request.method=="POST" and form.is_valid():
user=form.authenticate(request) user=form.authenticate(request)
if user and user.is_active: if user and user.is_active:
login(request,user); return redirect(request.session.pop("next","tickets:list")) login(request,user); return redirect(_login_destination(request))
form.add_error(None,"شماره موبایل یا رمز عبور صحیح نیست.") form.add_error(None,"شماره موبایل یا رمز عبور صحیح نیست.")
return render(request,"accounts/customer_password_login.html",{"form":form}) return render(request,"accounts/customer_password_login.html",{"form":form})
@@ -106,17 +124,30 @@ def logout_view(request):
response["Cache-Control"]="no-store" response["Cache-Control"]="no-store"
return response return response
@never_cache
@require_http_methods(["GET", "POST"])
def consume_magic_link(request, token): def consume_magic_link(request, token):
link = TicketMagicLink.objects.select_related("customer", "ticket").filter(token_hash=TicketMagicLink.digest(token)).first() link = TicketMagicLink.objects.select_related("customer", "ticket").filter(token_hash=TicketMagicLink.digest(token)).first()
if not link or link.revoked_at or link.expires_at <= timezone.now(): if not link or link.revoked_at or link.expires_at <= timezone.now() or not link.customer.is_active:
request.session["next"] = f"/tickets/{link.ticket.public_id}/" if link else "/tickets/" request.session["next"] = f"/tickets/{link.ticket.public_id}/" if link else "/tickets/"
messages.error(request, "این لینک منقضی شده یا اشتباه است. لطفاً با شماره موبایل خود وارد شوید تا بتوانید پیام‌ها را ببینید.") messages.error(request, "این لینک منقضی شده یا اشتباه است. لطفاً با شماره موبایل خود وارد شوید تا بتوانید پیام‌ها را ببینید.")
return redirect("accounts:login") response = redirect("accounts:login")
if request.user.is_authenticated: response["Referrer-Policy"] = "no-referrer"
if request.user.pk != link.customer_id: return response
from django.core.exceptions import PermissionDenied if request.user.is_authenticated and request.user.pk != link.customer_id:
raise PermissionDenied confirmed = (
else: request.method == "POST"
and request.POST.get("action") == "logout_and_open"
and request.POST.get("account_id") == str(request.user.pk)
)
if not confirmed:
response = render(request, "accounts/magic_link_confirmation.html")
# Same-origin form submissions need an Origin for Django CSRF checks.
response["Referrer-Policy"] = "same-origin"
return response
# Logout flushes the old session; grant access only in the new one.
logout(request)
if not request.user.is_authenticated:
grant_ticket_magic_access(request, link) grant_ticket_magic_access(request, link)
ip_address = request.META.get("REMOTE_ADDR") ip_address = request.META.get("REMOTE_ADDR")
user_agent = request.META.get("HTTP_USER_AGENT", "")[:500] user_agent = request.META.get("HTTP_USER_AGENT", "")[:500]
+12
View File
@@ -0,0 +1,12 @@
from django.utils import timezone
def grant_scoped_session_access(request, grant_key, grant_id, expires_at, *, preserve_expiry=False):
"""Rotate anonymous session keys; resource grants still validate their own expiry."""
session = request.session
session.cycle_key()
session[grant_key] = grant_id
seconds = max(1, int((expires_at - timezone.now()).total_seconds()))
if preserve_expiry:
seconds = max(session.get_expiry_age(), seconds)
session.set_expiry(seconds)
+6 -1
View File
@@ -16,7 +16,12 @@ class AnnouncementJobAdmin(admin.ModelAdmin):
class SMSTemplateAdmin(admin.ModelAdmin): class SMSTemplateAdmin(admin.ModelAdmin):
list_display=("event_type","unit","title","is_active","updated_at") list_display=("event_type","unit","title","is_active","updated_at")
list_filter=("unit","is_active","event_type") list_filter=("unit","is_active","event_type")
readonly_fields=("allowed_tags",) readonly_fields=("allowed_tags_display",)
exclude=("allowed_tags",)
@admin.display(description='Allowed tags / تگ‌های مجاز')
def allowed_tags_display(self, obj):
return obj.supported_tags
@admin.register(NotificationPolicy) @admin.register(NotificationPolicy)
class NotificationPolicyAdmin(admin.ModelAdmin): class NotificationPolicyAdmin(admin.ModelAdmin):
list_display=("key","quiet_period_seconds","is_active","updated_at") list_display=("key","quiet_period_seconds","is_active","updated_at")
@@ -73,6 +73,9 @@ def _bale_ticket_text(ticket, message=None, *, is_reply=False, message_count=1,
return "\n".join(lines) return "\n".join(lines)
def _send_sms(job): def _send_sms(job):
if job.event_type in {"PAYMENT_NOTICE_CREATED", "PAYMENT_RECEIPT_APPROVED"}:
from apps.payments.notifications import send_payment_sms
return send_payment_sms(job, provider_factory=get_sms_provider)
issued = None issued = None
if job.event_type == "UNIT_SUPERVISOR_NEW_TICKET": if job.event_type == "UNIT_SUPERVISOR_NEW_TICKET":
recipient = job.payload.get("recipient_mobile", "") recipient = job.payload.get("recipient_mobile", "")
@@ -0,0 +1,18 @@
# Generated by Django 5.1.1 on 2026-10-05 10:44
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('notifications', '0016_announcementjob_requested_by_announcementjob_source_and_more'),
]
operations = [
migrations.AlterField(
model_name='smstemplate',
name='event_type',
field=models.CharField(choices=[('CUSTOMER_TICKET_CREATED', 'ثبت تیکت توسط مشتری'), ('STAFF_TICKET_CREATED', 'ثبت تیکت توسط کارشناس'), ('STAFF_REPLY', 'پاسخ کارشناس'), ('PAYMENT_NOTICE_CREATED', 'صدور اعلامیه واریز'), ('PAYMENT_RECEIPT_APPROVED', 'تأیید نهایی فیش واریز'), ('UNIT_SUPERVISOR_NEW_TICKET', 'اطلاع تیکت جدید به سرپرستان واحد')], max_length=50),
),
]
@@ -0,0 +1,31 @@
# Generated by Django 5.1.1 on 2026-10-06 05:38
from django.db import migrations, models
def align_template_tags(apps, schema_editor):
template = apps.get_model('notifications', 'SMSTemplate')
ticket_tags = ['magic_link', 'first_name', 'last_name', 'recipient_name', 'unit_name', 'company_name',
'ticket_number', 'subject', 'province', 'city', 'expert_name', 'customer_mobile', 'category_name']
payment_tags = ['recipient_name', 'company_name', 'notice_number', 'receipt_number', 'amount_rial', 'invoice_number', 'payment_link']
for item in template.objects.all():
tags = payment_tags if item.event_type in {'PAYMENT_NOTICE_CREATED', 'PAYMENT_RECEIPT_APPROVED'} else ticket_tags
if item.event_type == 'UNIT_SUPERVISOR_NEW_TICKET':
tags = [tag for tag in tags if tag != 'magic_link']
template.objects.filter(pk=item.pk).update(allowed_tags=tags)
class Migration(migrations.Migration):
dependencies = [
('notifications', '0017_alter_smstemplate_event_type'),
]
operations = [
migrations.AlterField(
model_name='smstemplate',
name='body',
field=models.TextField(help_text='تگ\u200cها بر اساس رویداد مشخص\u200cاند: {magic_link} برای تیکت و {payment_link} برای پرداخت.'),
),
migrations.RunPython(align_template_tags, migrations.RunPython.noop),
]
+26 -2
View File
@@ -13,20 +13,44 @@ class SMSTemplate(PersianTextNormalizationMixin, models.Model):
CUSTOMER_TICKET_CREATED="CUSTOMER_TICKET_CREATED","ثبت تیکت توسط مشتری" CUSTOMER_TICKET_CREATED="CUSTOMER_TICKET_CREATED","ثبت تیکت توسط مشتری"
STAFF_TICKET_CREATED="STAFF_TICKET_CREATED","ثبت تیکت توسط کارشناس" STAFF_TICKET_CREATED="STAFF_TICKET_CREATED","ثبت تیکت توسط کارشناس"
STAFF_REPLY="STAFF_REPLY","پاسخ کارشناس" STAFF_REPLY="STAFF_REPLY","پاسخ کارشناس"
PAYMENT_NOTICE_CREATED="PAYMENT_NOTICE_CREATED","صدور اعلامیه واریز"
PAYMENT_RECEIPT_APPROVED="PAYMENT_RECEIPT_APPROVED","تأیید نهایی فیش واریز"
UNIT_SUPERVISOR_NEW_TICKET="UNIT_SUPERVISOR_NEW_TICKET","اطلاع تیکت جدید به سرپرستان واحد" UNIT_SUPERVISOR_NEW_TICKET="UNIT_SUPERVISOR_NEW_TICKET","اطلاع تیکت جدید به سرپرستان واحد"
unit=models.ForeignKey("staff.OrganizationalUnit",on_delete=models.CASCADE,null=True,blank=True,related_name="sms_templates",help_text="خالی یعنی الگوی عمومی و fallback.") unit=models.ForeignKey("staff.OrganizationalUnit",on_delete=models.CASCADE,null=True,blank=True,related_name="sms_templates",help_text="خالی یعنی الگوی عمومی و fallback.")
event_type=models.CharField(max_length=50,choices=Event.choices) event_type=models.CharField(max_length=50,choices=Event.choices)
title=models.CharField(max_length=120) title=models.CharField(max_length=120)
body=models.TextField(help_text="تگ‌ها را مانند {magic_link} در متن قرار دهید.") body=models.TextField(help_text="تگ‌ها بر اساس رویداد مشخص‌اند: {magic_link} برای تیکت و {payment_link} برای پرداخت.")
allowed_tags=models.JSONField(default=sms_template_tags,blank=True) allowed_tags=models.JSONField(default=sms_template_tags,blank=True)
is_active=models.BooleanField(default=True) is_active=models.BooleanField(default=True)
updated_at=models.DateTimeField(auto_now=True) updated_at=models.DateTimeField(auto_now=True)
class Meta: class Meta:
constraints=[models.UniqueConstraint(fields=["unit","event_type"],name="unique_sms_template_per_unit"),models.UniqueConstraint(fields=["event_type"],condition=models.Q(unit__isnull=True),name="unique_global_sms_template_event")] constraints=[models.UniqueConstraint(fields=["unit","event_type"],name="unique_sms_template_per_unit"),models.UniqueConstraint(fields=["event_type"],condition=models.Q(unit__isnull=True),name="unique_global_sms_template_event")]
def __str__(self): return self.get_event_type_display() def __str__(self): return self.get_event_type_display()
@classmethod
def tags_for_event(cls, event_type):
if event_type in {'PAYMENT_NOTICE_CREATED', 'PAYMENT_RECEIPT_APPROVED'}:
from apps.payments.notifications import TAGS
return list(TAGS)
tags = sms_template_tags()
return [tag for tag in tags if tag != 'magic_link'] if event_type == 'UNIT_SUPERVISOR_NEW_TICKET' else tags
@property
def supported_tags(self):
return ', '.join('{' + tag + '}' for tag in self.tags_for_event(self.event_type))
def save(self, *args, **kwargs):
self.allowed_tags = self.tags_for_event(self.event_type)
if kwargs.get('update_fields') is not None:
kwargs['update_fields'] = set(kwargs['update_fields']) | {'allowed_tags'}
return super().save(*args, **kwargs)
def clean(self): def clean(self):
self.allowed_tags = self.tags_for_event(self.event_type)
from string import Formatter from string import Formatter
tags={field for _,field,_,_ in Formatter().parse(self.body) if field} try:
tags={field for _,field,_,_ in Formatter().parse(self.body) if field}
except ValueError:
raise ValidationError({'body': 'ساختار تگ معتبر نیست؛ هر تگ باید بین { و } قرار بگیرد.'})
invalid=tags-set(self.allowed_tags) invalid=tags-set(self.allowed_tags)
if invalid: raise ValidationError({"body":f"تگ‌های غیرمجاز: {', '.join(sorted(invalid))}"}) if invalid: raise ValidationError({"body":f"تگ‌های غیرمجاز: {', '.join(sorted(invalid))}"})
View File
Whitespace-only changes.
+130
View File
@@ -0,0 +1,130 @@
"""A payment link never authenticates a user or grants access to their whole account."""
import hashlib
import secrets
import uuid
from datetime import timedelta
from django.conf import settings
from django.db.models import Q
from django.contrib.auth.views import redirect_to_login
from django.core.exceptions import PermissionDenied
from django.http import Http404
from django.shortcuts import get_object_or_404, render
from django.urls import reverse
from django.utils import timezone
from apps.core.session_access import grant_scoped_session_access
from . import policies
from .models import PaymentAccessLink, PaymentNotice
SESSION_KEY = 'payment_access_link_id'
def token_digest(token):
return hashlib.sha256(token.encode()).hexdigest()
def issue_payment_link(*, notice=None, receipt=None):
target = receipt or notice
if target is None:
raise ValueError('Payment link requires a notice or receipt')
if receipt is not None:
notice = receipt.notice
token = secrets.token_urlsafe(32)
link = PaymentAccessLink.objects.create(customer=target.customer, notice=notice, receipt=receipt,
token_hash=token_digest(token),
expires_at=timezone.now() + timedelta(days=settings.PAYMENT_LINK_TTL_DAYS))
return link, settings.PUBLIC_BASE_URL + reverse('payments:open_link', args=[token])
def active_links():
return PaymentAccessLink.objects.select_related('customer', 'notice', 'receipt').filter(
revoked_at__isnull=True, expires_at__gt=timezone.now(), customer__is_active=True,
customer__account_type='CUSTOMER')
def grant_payment_access(request, link):
# The session may already contain a ticket grant; each grant checks its own expiry.
grant_scoped_session_access(request, SESSION_KEY, link.pk, link.expires_at, preserve_expiry=True)
def get_payment_access(request):
link_id = request.session.get(SESSION_KEY)
if not isinstance(link_id, int):
return None
link = active_links().filter(pk=link_id).first()
if link is None:
request.session.pop(SESSION_KEY, None)
return link
def visible_payments(request, queryset):
link = getattr(request, 'payment_access_link', None)
if link is None:
return policies.visible(queryset, request.user)
owned = queryset.filter(customer_id=link.customer_id)
if queryset.model == PaymentNotice:
return owned.filter(pk=link.notice_id)
scope = Q(pk=link.receipt_id) | Q(access_links=link)
if link.notice_id:
scope |= Q(notice_id=link.notice_id)
return owned.filter(scope).distinct()
def customer_context(request, **kwargs):
return {'payment_link_access': getattr(request, 'payment_access_link', None), **kwargs}
def prepare_payment_request(request, *, staff=False, allow_payment_link=False):
if request.user.is_authenticated:
customer = request.user.is_active and request.user.account_type == 'CUSTOMER'
permitted = policies.is_staff(request.user) if staff else customer
if staff is None:
permitted = customer or policies.is_staff(request.user)
if not permitted:
raise PermissionDenied
request.payment_customer = request.user
return None
had_link = SESSION_KEY in request.session
link = get_payment_access(request) if allow_payment_link else None
if link:
request.payment_access_link = link
request.payment_customer = link.customer
return None
if had_link and allow_payment_link:
return unavailable_link(request)
return redirect_to_login(request.get_full_path(), '/staff/login/' if staff else '/login/')
def receipt_initial(request, customer):
link = getattr(request, 'payment_access_link', None)
identifier = request.GET.get('notice')
initial = {}
if link and not identifier:
if request.GET.get('mode') == 'independent' or not link.notice_id or link.notice.cancelled_at:
return {'receipt_mode': 'independent'}
identifier = str(link.notice.public_id)
if not identifier:
return initial
try:
identifier = uuid.UUID(identifier)
except ValueError:
raise Http404
notices = visible_payments(request, PaymentNotice.objects.all()) if link else PaymentNotice.objects.filter(customer=customer)
notice = get_object_or_404(notices, public_id=identifier, cancelled_at__isnull=True)
return {'notice_id': notice.pk, 'destination_account': notice.account_id, 'deposit_id': notice.deposit_id}
def validate_link_submission(link, customer, data):
if not active_links().select_related(None).select_for_update(of=('self',)).filter(pk=link.pk, customer=customer).first():
raise PermissionDenied
notice = data.get('notice_id')
if notice is not None and notice.pk != link.notice_id:
raise PermissionDenied
def unavailable_link(request):
response = render(request, 'payments/link_unavailable.html', {'financial_page': True}, status=410)
response['Referrer-Policy'] = 'no-referrer'
response['X-Robots-Tag'] = 'noindex, nofollow'
return response
+128
View File
@@ -0,0 +1,128 @@
from django.contrib import admin
from django.core.exceptions import PermissionDenied
from django.db import connection
from . import models, policies, services
@admin.register(models.PaymentAccessLink)
class PaymentAccessLinkAdmin(admin.ModelAdmin):
list_display = ('customer', 'notice', 'receipt', 'created_at', 'expires_at', 'revoked_at', 'view_count')
list_filter = ('revoked_at', 'expires_at')
exclude = ('token_hash',)
readonly_fields = ('customer', 'notice', 'receipt', 'created_at', 'expires_at', 'revoked_at', 'view_count', 'last_used_at', 'submitted_receipts')
actions = ['revoke_links']
def has_add_permission(self, request):
return False
def has_delete_permission(self, request, obj=None):
return False
@admin.action(description='ابطال لینک‌های انتخاب‌شده')
def revoke_links(self, request, queryset):
from django.utils import timezone
for link in queryset.filter(revoked_at__isnull=True):
link.revoked_at = timezone.now()
link.save(update_fields=['revoked_at'])
services.audit(request.user, 'PAYMENT_LINK_REVOKED', link)
from .forms import BankAccountForm
class AuditedConfigurationAdmin(admin.ModelAdmin):
permission = 'manage_payment_approval_policy'
singleton_configuration = False
def has_view_permission(self, request, obj=None):
return policies.has_permission(request.user,self.permission)
def has_add_permission(self, request):
return self.has_view_permission(request) and not (self.singleton_configuration and self.model.objects.exists())
def has_change_permission(self, request, obj=None):
return self.has_view_permission(request,obj)
def has_delete_permission(self, request, obj=None):
return False
def save_model(self, request, obj, form, change):
if not self.has_change_permission(request,obj):
raise PermissionDenied
current = self.model.objects.select_for_update().get(pk=obj.pk) if change else None
before = services.configuration_snapshot(current) if current else {}
super().save_model(request,obj,form,change)
services.audit(request.user,'CONFIG_SAVED',obj,{'fields': form.changed_data,
'before':before,'after':services.configuration_snapshot(obj)})
def save_related(self, request, form, formsets, change):
super().save_related(request,form,formsets,change)
if isinstance(form.instance,models.PaymentApprovalPolicy):
services.audit(request.user,'POLICY_ASSIGNMENTS_CHANGED',form.instance,{'reviewers':list(form.instance.reviewers.values_list('pk',flat=True)), 'seniors':list(form.instance.seniors.values_list('pk',flat=True))})
@admin.register(models.PaymentApprovalPolicy)
class ApprovalPolicyAdmin(AuditedConfigurationAdmin):
list_display = ['name','unit','is_active']
filter_horizontal = ['reviewers','seniors']
def formfield_for_manytomany(self, db_field, request, **kwargs):
from apps.accounts.models import User
if db_field.name in ['reviewers','seniors']:
kwargs['queryset'] = User.objects.filter(account_type='STAFF',is_active=True).exclude(staff_profile__is_active=False)
return super().formfield_for_manytomany(db_field,request,**kwargs)
@admin.register(models.PaymentAccessConfiguration)
class AccessAdmin(AuditedConfigurationAdmin):
permission = 'manage_payment_access'
singleton_configuration = True
@admin.register(models.CompanyBankAccount)
class AccountAdmin(AuditedConfigurationAdmin):
form = BankAccountForm
list_display = ['name','bank','requires_deposit_id','is_active','available_to_staff','available_to_customers']
permission = 'manage_company_bankaccounts'
def has_view_permission(self, request, obj=None):
return policies.can_manage_banks(request.user)
def get_queryset(self, request):
queryset = super().get_queryset(request)
# Django wraps POST change forms in atomic(): lock before token validation.
if request.method == 'POST' and connection.in_atomic_block:
return queryset.select_for_update(of=('self',))
return queryset
@admin.register(models.BankInstitution)
class BankAdmin(AccountAdmin):
form = admin.ModelAdmin.form
list_display = ['name','code','is_active','historical']
class EvidenceAdmin(admin.ModelAdmin):
def has_view_permission(self, request, obj=None):
if self.model in [models.PaymentNotice, models.PaymentReceipt]:
return super().has_view_permission(request, obj)
return policies.has_permission(request.user, 'view_payment_audit')
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
def get_queryset(self, request):
queryset = super().get_queryset(request)
if queryset.model in [models.PaymentNotice,models.PaymentReceipt]:
return policies.visible(queryset,request.user)
return queryset if policies.has_permission(request.user,'view_payment_audit') else queryset.none()
for model in [models.PaymentNotice,models.PaymentReceipt,models.PaymentReceiptRevision,models.ReceiptApprovalCycle,
models.ReceiptApprovalAssignment,models.ReceiptDecision,models.PaymentAuditEvent,
models.PaymentNotification,models.PaymentNotificationAttempt]:
admin.site.register(model,EvidenceAdmin)
+7
View File
@@ -0,0 +1,7 @@
from django.apps import AppConfig
class PaymentsConfig(AppConfig):
default_auto_field = 'django.db.models.BigAutoField'
name = 'apps.payments'
verbose_name = 'واریزها و تأیید مالی'
+15
View File
@@ -0,0 +1,15 @@
from django.db import OperationalError, ProgrammingError
from . import policies
from .models import PaymentReceipt
def payment_navigation(request):
if not policies.is_staff(request.user):
return {}
try:
return {'payment_can_create': policies.can_create(request.user),
'payment_pending_count': policies.visible(PaymentReceipt.objects.all(),request.user).filter(status__in=['PENDING','PARTIAL']).count(),
'payment_can_manage_banks': policies.can_manage_banks(request.user),
'payment_can_view': policies.visible(PaymentReceipt.objects.all(),request.user).exists() or policies.config().open_view or request.user.has_perm('payments.view_paymentreceipt') or request.user.is_superuser}
except (OperationalError, ProgrammingError):
return {}
+77
View File
@@ -0,0 +1,77 @@
"""Reuse the contact directory's scoped history; never render all customer choices."""
from uuid import UUID
from django.db.models import Prefetch, Q
from django.http import Http404
from django.urls import reverse
from apps.accounts.models import normalize_mobile
from apps.core.persian_text import normalize_persian_text
from apps.tickets.selectors import staff_ticket_rows
from .validators import digits
def customer_rows(user, search=''):
# The existing directory projection counts only tickets visible to this staff user.
from apps.tickets.views import _customer_rows
rows = _customer_rows(user=user).filter(is_active=True)
if search:
terms_query = Q()
for term in search.split():
mobile = term
try:
mobile = normalize_mobile(term)
except ValueError:
if term.isascii() and term.isdecimal() and term.startswith('0'):
mobile = term[1:]
terms_query &= (Q(mobile__icontains=mobile) | Q(username__icontains=term) |
Q(first_name__icontains=term) | Q(last_name__icontains=term) |
Q(customer_profile__first_name__icontains=term) |
Q(customer_profile__last_name__icontains=term) |
Q(customer_profile__organization_name__icontains=term))
history = staff_ticket_rows(user).filter(
Q(ticket_number__icontains=search) | Q(subject__icontains=search) |
Q(subject__icontains=normalize_persian_text(search)))
rows = rows.filter(terms_query | Q(customer_tickets__pk__in=history.values('pk'))).distinct()
return rows.prefetch_related(Prefetch('customer_tickets',
queryset=staff_ticket_rows(user).order_by('-created_at', '-pk')[:3],
to_attr='payment_recent_tickets'))
def customer_data(customer):
profile = getattr(customer, 'customer_profile', None)
name = ' '.join(filter(None, [getattr(profile, 'first_name', ''), getattr(profile, 'last_name', '')]))
return {'value': str(customer.pk), 'id': str(customer.public_id),
'name': name or customer.get_full_name() or 'نام ثبت نشده',
'mobile': customer.local_mobile, 'organization': getattr(profile, 'organization_name', ''),
'location': '، '.join(filter(None, [getattr(profile, 'city', ''), getattr(profile, 'province', '')])),
'ticket_count': customer.ticket_count, 'active_count': customer.active_count,
'history': [{'number': ticket.ticket_number, 'subject': ticket.subject,
'status': ticket.get_status_display(), 'url': reverse('staff_tickets:detail', args=[ticket.public_id])}
for ticket in customer.payment_recent_tickets]}
def initial_customer(request):
public_id = request.GET.get('customer', '')
if not public_id or request.method != 'GET':
return {}
try:
public_id = UUID(public_id)
except (ValueError, TypeError, AttributeError):
raise Http404('مشتری معتبر نیست.')
customer = customer_rows(request.user).filter(public_id=public_id).first()
if customer is None:
raise Http404('مشتری فعال پیدا نشد.')
return {'customer': customer.pk}
def picker_context(request, form):
value = str(form['customer'].value() or '')
selected = customer_rows(request.user).filter(pk=value).first() if value.isascii() and value.isdecimal() else None
return {'customer_selection': customer_data(selected) if selected else None}
def search_results(user, query):
query = digits(query).translate(str.maketrans('يك', 'یک'))[:100]
if len(query) < 2:
return {'results': [], 'has_more': False}
rows = list(customer_rows(user, query)[:21])
return {'results': [customer_data(customer) for customer in rows[:20]], 'has_more': len(rows) > 20}
+42
View File
@@ -0,0 +1,42 @@
"""Deposit identifier algorithm copied from repository alghortim_shenase.py.
The input is the customer local mobile number, retaining its leading zero.
This is a deterministic identifier, not a random or unique-per-notice value.
"""
def generate_id_algorithm(id_code: str) -> str:
"""
Calculates the verification code based on the reverse-engineered logic:
1. Iterate digits from right to left (index i = 0, 1, 2...).
2. Weights:
- If i < 8: Weight = 8 - i (Sequence: 8, 7, 6, 5, 4, 3, 2, 1)
- If i >= 8: Weight = i + 1 (Sequence: 9, 10, ...)
3. Sum = Sum(Digit * Weight)
4. Check Digit = Sum % 99
"""
# تمیزکاری ورودی (حذف فاصله‌ها و اطمینان از عددی بودن)
if not id_code.isdigit():
return "Error: ورودی باید فقط عدد باشد."
total_sum = 0
# پیمایش از راست به چپ
# reversed(id_code) ارقام را برعکس می‌کند
for i, char in enumerate(reversed(id_code)):
digit = int(char)
# تعیین وزن بر اساس ایندکس (مکان رقم از راست)
if i < 8:
weight = 8 - i
else:
weight = i + 1
total_sum += digit * weight
# محاسبه باقی‌مانده بر 99
check_digit = total_sum % 99
# فرمت‌دهی دو رقمی (مثلاً 6 می‌شود 06)
check_digit_str = f"{check_digit:02}"
return f"{id_code}{check_digit_str}"
+127
View File
@@ -0,0 +1,127 @@
import uuid
import jdatetime
from django import forms
from django.db.models import Q
from django.utils import timezone
from apps.accounts.models import User
from . import validators
from .models import BankInstitution, CompanyBankAccount, PaymentNotice
class BankAccountForm(forms.ModelForm):
revision_token = forms.CharField(required=False, widget=forms.HiddenInput)
class Meta:
model = CompanyBankAccount
fields = ['revision_token', 'name', 'bank', 'holder', 'account_number', 'iban', 'card_number', 'requires_deposit_id', 'is_active', 'available_to_staff', 'available_to_customers', 'sort_order']
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
if self.instance.pk:
self.initial['revision_token'] = self.instance.updated_at.isoformat()
def clean_account_number(self):
return validators.bank_number(self.cleaned_data['account_number'])
def clean_card_number(self):
value = validators.bank_number(self.cleaned_data['card_number'])
if value and (len(value) != 16 or sum((int(n) * (2 if i % 2 == 0 else 1)) // 10 + (int(n) * (2 if i % 2 == 0 else 1)) % 10 for i, n in enumerate(value)) % 10):
raise forms.ValidationError('شماره کارت ۱۶ رقمی معتبر وارد کنید.')
return value
def clean_iban(self):
return validators.iban(self.cleaned_data['iban'])
def clean(self):
data = super().clean()
if self.instance.pk and data.get('revision_token') != self.instance.updated_at.isoformat():
raise forms.ValidationError('حساب از زمان باز کردن فرم تغییر کرده است؛ صفحه را تازه کنید و تغییرات را دوباره بررسی کنید.')
if not any(data.get(key) for key in ('account_number', 'iban', 'card_number')):
raise forms.ValidationError('حداقل شماره حساب، شبا یا کارت را وارد کنید.')
return data
class NoticeForm(forms.Form):
submission_key = forms.UUIDField(widget=forms.HiddenInput)
customer = forms.ModelChoiceField(label='مشتری', queryset=User.objects.none(), widget=forms.HiddenInput,
error_messages={'required': 'مشتری را از نتایج جست‌وجو انتخاب کنید.',
'invalid_choice': 'مشتری انتخاب‌شده معتبر یا فعال نیست؛ دوباره جست‌وجو کنید.'})
account = forms.ModelChoiceField(label='حساب مقصد', queryset=CompanyBankAccount.objects.none())
subject = forms.CharField(label='موضوع', max_length=200)
amount = forms.CharField(label='مبلغ (ریال)')
deposit_id = forms.CharField(label='شناسه واریز', max_length=100, required=False)
invoice_number = forms.CharField(label='شماره فاکتور', max_length=100, required=False)
description = forms.CharField(label='توضیحات', required=False, widget=forms.Textarea)
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.initial.setdefault('submission_key', uuid.uuid4())
self.fields['customer'].queryset = User.objects.filter(account_type='CUSTOMER', is_active=True).select_related('customer_profile')
self.fields['customer'].label_from_instance = lambda u: f'{getattr(u, "customer_profile", u)} — {u.local_mobile}'
self.fields['account'].queryset = CompanyBankAccount.objects.filter(is_active=True, available_to_staff=True).select_related('bank')
def clean_amount(self):
return validators.amount(self.cleaned_data['amount'])
def clean(self):
data = super().clean()
if data.get('account') and data['account'].requires_deposit_id and not data.get('deposit_id', '').strip():
self.add_error('deposit_id', 'این حساب شناسه‌دار است؛ شناسه واریز مشتری را حتماً وارد کنید.')
return data
class ReceiptForm(forms.Form):
receipt_mode = forms.ChoiceField(label='نوع واریز', choices=[('linked', 'واریز مرتبط (پیشنهادی)'), ('independent', 'واریز مستقل')], widget=forms.RadioSelect, initial='linked')
notice_id = forms.ModelChoiceField(label='اعلامیه واریز', queryset=PaymentNotice.objects.none(), required=False)
paid_amount = forms.CharField(label='مبلغ پرداخت‌شده (ریال)')
destination_account = forms.ModelChoiceField(label='به کدام حساب ما واریز کردید؟', queryset=CompanyBankAccount.objects.none())
deposit_id = forms.CharField(label='شناسه واریز که با آن پرداخت انجام داده‌اید (اختیاری)', max_length=100, required=False)
origin_bank = forms.ModelChoiceField(label='بانک مبدأ (اختیاری)', queryset=BankInstitution.objects.filter(is_active=True), required=False)
origin_other = forms.CharField(label='نام بانک دیگر (اختیاری)', max_length=100, required=False)
paid_day = forms.ChoiceField(label='روز', choices=[('', 'روز')] + [(str(i), str(i)) for i in range(1, 32)])
paid_month = forms.ChoiceField(label='ماه', choices=[('', 'ماه')] + list(zip(map(str, range(1, 13)), ['فروردین','اردیبهشت','خرداد','تیر','مرداد','شهریور','مهر','آبان','آذر','دی','بهمن','اسفند'])))
paid_year = forms.ChoiceField(label='سال')
receipt_image = forms.FileField(label='تصویر فیش')
tracking_number = forms.CharField(label='شماره پیگیری (اختیاری)', max_length=100, required=False)
description = forms.CharField(label='توضیحات (اختیاری)', required=False, widget=forms.Textarea)
submission_key = forms.UUIDField(widget=forms.HiddenInput)
def __init__(self, *args, customer, notice_ids=None, **kwargs):
super().__init__(*args, **kwargs)
notices = PaymentNotice.objects.filter(customer=customer, cancelled_at__isnull=True)
if notice_ids is not None:
notices = notices.filter(pk__in=notice_ids)
self.fields['notice_id'].queryset = notices
self.fields['notice_id'].label_from_instance = lambda n: f'{n.subject} — {n.amount:,} ریال — {n.invoice_number}'
accounts = Q(is_active=True, available_to_customers=True) | Q(is_active=True, paymentnotice__in=notices)
self.fields['destination_account'].queryset = CompanyBankAccount.objects.filter(accounts).distinct()
current = jdatetime.date.fromgregorian(date=timezone.localdate())
self.fields['paid_year'].choices = [('', 'سال')] + [(str(y), str(y)) for y in range(current.year, current.year - 11, -1)]
self.initial.setdefault('submission_key', uuid.uuid4())
self.initial.setdefault('paid_month', str(current.month))
self.initial.setdefault('paid_year', str(current.year))
def clean_paid_amount(self):
return validators.amount(self.cleaned_data['paid_amount'])
def clean_receipt_image(self):
upload = self.cleaned_data['receipt_image']
self.image_metadata = validators.image(upload)
return upload
def clean(self):
data = super().clean()
notice = data.get('notice_id') if data.get('receipt_mode') == 'linked' else None
data['notice_id'] = notice
if data.get('receipt_mode') == 'linked' and not notice:
self.add_error('notice_id', 'اعلامیه را انتخاب کنید یا واریز مستقل را بزنید.')
try:
data['paid_date'] = validators.paid_date(data.get('paid_day'), data.get('paid_month'), data.get('paid_year'))
if self.data.get('paid_date') and self.data['paid_date'] != data['paid_date'].isoformat():
raise forms.ValidationError('تاریخ ارسالی با تاریخ انتخاب‌شده سازگار نیست؛ دوباره انتخاب کنید.')
except forms.ValidationError as error:
self.add_error('paid_day', error)
account = data.get('destination_account')
if account and not account.available_to_customers and (not notice or notice.account_id != account.pk):
self.add_error('destination_account', 'این حساب فقط برای اعلامیه مربوط به آن قابل انتخاب است.')
return data
+40
View File
@@ -0,0 +1,40 @@
import re
from django.contrib.auth import logout
from django.db import transaction
from django.db.models import F
from django.shortcuts import redirect, render
from django.utils import timezone
from django.views.decorators.cache import never_cache
from django.views.decorators.http import require_http_methods
from . import access, services
from .models import PaymentAccessLink
@never_cache
@require_http_methods(['GET', 'POST'])
def open_link(request, token):
link = access.active_links().filter(token_hash=access.token_digest(token)).first() if re.fullmatch(r'[A-Za-z0-9_-]{43}', token) else None
if link is None:
return access.unavailable_link(request)
if request.user.is_authenticated and request.user.pk != link.customer_id:
confirmed = request.method == 'POST' and request.POST.get('action') == 'logout_and_open' and request.POST.get('account_id') == str(request.user.pk)
if not confirmed:
response = render(request, 'payments/link_confirmation.html')
response['Referrer-Policy'] = 'same-origin'
response['X-Robots-Tag'] = 'noindex, nofollow'
return response
logout(request)
if not request.user.is_authenticated:
access.grant_payment_access(request, link)
with transaction.atomic():
PaymentAccessLink.objects.filter(pk=link.pk).update(view_count=F('view_count') + 1, last_used_at=timezone.now())
services.audit(None, 'PAYMENT_LINK_OPENED', link, {
'ip_address': request.META.get('REMOTE_ADDR', ''),
'user_agent': request.META.get('HTTP_USER_AGENT', '')[:500]})
target = link.receipt or link.notice
response = redirect('payments:receipt_detail' if link.receipt_id else 'payments:notice_detail', public_id=target.public_id)
response['Referrer-Policy'] = 'no-referrer'
response['X-Robots-Tag'] = 'noindex, nofollow'
return response
+53
View File
@@ -0,0 +1,53 @@
"""Audit administrative financial grant changes with the actual request actor.
Only selected non-secret fields and payments permissions are captured."""
from django.apps import apps
from django.db import OperationalError, ProgrammingError
from .models import PaymentAuditEvent
TARGETS = {'accounts/user': 'accounts.User', 'auth/group': 'auth.Group',
'staff/organizationalunit': 'staff.OrganizationalUnit', 'staff/unitmembership': 'staff.UnitMembership',
'staff/staffprofile': 'staff.StaffProfile'}
def access_snapshot(obj):
fields = ['is_active','account_type','user_id','unit_id','ticket_scope','role','primary_unit_id']
result = {field:getattr(obj,field) for field in fields if hasattr(obj,field)}
for name in ['permissions','user_permissions']:
relation = getattr(obj,name,None)
if relation is not None:
result[name] = sorted(relation.filter(content_type__app_label='payments').values_list('codename',flat=True))
if hasattr(obj,'groups'):
result['groups'] = sorted(obj.groups.filter(permissions__content_type__app_label='payments').values_list('pk',flat=True).distinct())
return result
class FinancialGrantAuditMiddleware:
def __init__(self,get_response):
self.get_response=get_response
def __call__(self,request):
target = self.target(request)
before = access_snapshot(target) if target else None
response = self.get_response(request)
if target and response.status_code < 400:
try:
target.refresh_from_db()
after=access_snapshot(target)
if before != after:
from .services import audit
audit(request.user,'FINANCIAL_ACCESS_CHANGED',target,{'before':before,'after':after})
except target.DoesNotExist:
from .services import audit
audit(request.user,'FINANCIAL_ACCESS_REMOVED',target,{'before':before})
return response
def target(self,request):
if request.method != 'POST' or not request.user.is_authenticated or not request.path.startswith('/x5admin/'):
return None
parts=request.path.strip('/').split('/')
if len(parts)<5 or not parts[3].isdecimal():
return None
label=TARGETS.get('/'.join(parts[1:3]))
if not label:
return None
return apps.get_model(label).objects.filter(pk=parts[3]).first()
+269
View File
@@ -0,0 +1,269 @@
# Generated by Django 5.1.1 on 2026-10-05 10:44
import apps.payments.models
import django.db.models.deletion
import uuid
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
('notifications', '0017_alter_smstemplate_event_type'),
('staff', '0012_organizationalunit_supervisor_mobile_5'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name='BankInstitution',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('code', models.SlugField(unique=True)),
('name', models.CharField(max_length=100)),
('is_active', models.BooleanField(default=True)),
('historical', models.BooleanField(default=False)),
],
options={
'ordering': ['historical', 'name'],
},
),
migrations.CreateModel(
name='PaymentAccessConfiguration',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('singleton', models.PositiveSmallIntegerField(default=1, editable=False, unique=True)),
('open_view', models.BooleanField(default=True, verbose_name='مشاهده برای همه کارشناسان فعال')),
('open_create', models.BooleanField(default=True, verbose_name='ایجاد برای همه کارشناسان فعال')),
],
options={
'permissions': [('manage_payment_access', 'Manage payment access')],
},
),
migrations.CreateModel(
name='CompanyBankAccount',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('public_id', models.UUIDField(default=uuid.uuid4, editable=False, unique=True)),
('name', models.CharField(max_length=120, verbose_name='نام نمایشی حساب')),
('holder', models.CharField(max_length=150, verbose_name='صاحب حساب')),
('account_number', models.CharField(blank=True, max_length=40, verbose_name='شماره حساب')),
('iban', models.CharField(blank=True, max_length=26, verbose_name='شبا')),
('card_number', models.CharField(blank=True, max_length=16, verbose_name='شماره کارت')),
('requires_deposit_id', models.BooleanField(default=False, verbose_name='شناسه واریز الزامی')),
('is_active', models.BooleanField(default=True, verbose_name='فعال کلی')),
('available_to_staff', models.BooleanField(default=True, verbose_name='قابل انتخاب کارشناسان')),
('available_to_customers', models.BooleanField(default=True, verbose_name='قابل انتخاب مشتریان')),
('sort_order', models.PositiveIntegerField(default=0)),
('bank', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to='payments.bankinstitution', verbose_name='بانک')),
],
options={
'ordering': ['sort_order', 'name', 'pk'],
'permissions': [('manage_company_bankaccounts', 'Manage company bank accounts')],
},
),
migrations.CreateModel(
name='PaymentAuditEvent',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('actor_name', models.CharField(max_length=150)),
('action', models.CharField(max_length=50)),
('object_type', models.CharField(max_length=50)),
('object_id', models.CharField(max_length=64)),
('details', models.JSONField(default=dict)),
('created_at', models.DateTimeField(auto_now_add=True, db_index=True)),
('actor', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, to=settings.AUTH_USER_MODEL)),
],
options={
'ordering': ['-created_at', '-pk'],
'permissions': [('view_payment_audit', 'View financial audit')],
},
),
migrations.CreateModel(
name='PaymentNotice',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('public_id', models.UUIDField(default=uuid.uuid4, editable=False, unique=True)),
('account_snapshot', models.JSONField()),
('subject', models.CharField(max_length=200)),
('amount', models.BigIntegerField()),
('deposit_id', models.CharField(blank=True, max_length=100)),
('invoice_number', models.CharField(blank=True, max_length=100)),
('description', models.TextField(blank=True)),
('cancelled_at', models.DateTimeField(blank=True, null=True)),
('account', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to='payments.companybankaccount')),
('created_by', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='issued_payment_notices', to=settings.AUTH_USER_MODEL)),
('customer', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='payment_notices', to=settings.AUTH_USER_MODEL)),
('owning_unit', models.ForeignKey(null=True, on_delete=django.db.models.deletion.PROTECT, to='staff.organizationalunit')),
],
options={
'ordering': ['-created_at', '-pk'],
},
),
migrations.CreateModel(
name='PaymentNotification',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('context', models.JSONField()),
('rendered_body', models.TextField(blank=True)),
('template_snapshot', models.JSONField(default=dict)),
('job', models.OneToOneField(on_delete=django.db.models.deletion.PROTECT, related_name='payment_notification', to='notifications.notificationjob')),
('notice', models.ForeignKey(null=True, on_delete=django.db.models.deletion.PROTECT, to='payments.paymentnotice')),
('unit', models.ForeignKey(null=True, on_delete=django.db.models.deletion.PROTECT, to='staff.organizationalunit')),
],
),
migrations.CreateModel(
name='PaymentNotificationAttempt',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('started_at', models.DateTimeField(auto_now_add=True)),
('finished_at', models.DateTimeField(null=True)),
('success', models.BooleanField(null=True)),
('provider_code', models.CharField(blank=True, max_length=50)),
('provider_id', models.CharField(blank=True, max_length=500)),
('error', models.TextField(blank=True)),
('notification', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='attempt_log', to='payments.paymentnotification')),
],
),
migrations.CreateModel(
name='PaymentReceipt',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('public_id', models.UUIDField(default=uuid.uuid4, editable=False, unique=True)),
('status', models.CharField(choices=[('PENDING', 'در انتظار بررسی'), ('PARTIAL', 'تأیید ناقص'), ('APPROVED', 'تأیید شده'), ('REJECTED', 'رد شده')], db_index=True, default='PENDING', max_length=12)),
('submission_key', models.UUIDField(unique=True)),
('current_version', models.PositiveIntegerField(default=1)),
('customer', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='payment_receipts', to=settings.AUTH_USER_MODEL)),
('notice', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='receipts', to='payments.paymentnotice')),
('owning_unit', models.ForeignKey(null=True, on_delete=django.db.models.deletion.PROTECT, to='staff.organizationalunit')),
('submitted_by', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='submitted_payment_receipts', to=settings.AUTH_USER_MODEL)),
],
options={
'ordering': ['-created_at', '-pk'],
'permissions': [('approve_paymentreceipt', 'Approve payment receipt'), ('senior_approve_paymentreceipt', 'Senior final approval'), ('submit_customer_receipt', 'Submit receipt for customer'), ('view_all_payments', 'View payments across units')],
},
),
migrations.CreateModel(
name='PaymentReceiptRevision',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('version', models.PositiveIntegerField()),
('amount', models.BigIntegerField()),
('paid_date', models.DateField()),
('account_snapshot', models.JSONField()),
('origin_other', models.CharField(blank=True, max_length=100)),
('deposit_id', models.CharField(blank=True, max_length=100)),
('tracking_number', models.CharField(blank=True, max_length=100)),
('description', models.TextField(blank=True)),
('destination_mismatch', models.BooleanField(default=False)),
('created_at', models.DateTimeField(auto_now_add=True)),
('image', models.FileField(max_length=500, upload_to=apps.payments.models.receipt_path)),
('mime_type', models.CharField(max_length=100)),
('sha256', models.CharField(max_length=64)),
('original_name', models.CharField(max_length=255)),
('file_size', models.PositiveBigIntegerField()),
('destination', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to='payments.companybankaccount')),
('origin_bank', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, to='payments.bankinstitution')),
('receipt', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='revisions', to='payments.paymentreceipt')),
],
),
migrations.CreateModel(
name='ReceiptApprovalCycle',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('policy_snapshot', models.JSONField(default=dict)),
('status', models.CharField(choices=[('PENDING', 'در انتظار بررسی'), ('PARTIAL', 'تأیید ناقص'), ('APPROVED', 'تأیید شده'), ('REJECTED', 'رد شده')], default='PENDING', max_length=12)),
('finalized_at', models.DateTimeField(blank=True, null=True)),
('finalized_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, to=settings.AUTH_USER_MODEL)),
('revision', models.OneToOneField(on_delete=django.db.models.deletion.PROTECT, related_name='cycle', to='payments.paymentreceiptrevision')),
],
),
migrations.CreateModel(
name='ReceiptApprovalAssignment',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('is_senior', models.BooleanField(default=False)),
('user', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to=settings.AUTH_USER_MODEL)),
('cycle', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='assignments', to='payments.receiptapprovalcycle')),
],
),
migrations.AddField(
model_name='paymentnotification',
name='cycle',
field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.PROTECT, to='payments.receiptapprovalcycle'),
),
migrations.CreateModel(
name='ReceiptDecision',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('action', models.CharField(choices=[('APPROVE', 'تأیید'), ('REJECT', 'رد'), ('SENIOR_APPROVE', 'تأیید ارشد')], max_length=20)),
('reason', models.TextField(blank=True)),
('actor_name', models.CharField(max_length=150)),
('created_at', models.DateTimeField(auto_now_add=True)),
('cycle', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='decisions', to='payments.receiptapprovalcycle')),
('user', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to=settings.AUTH_USER_MODEL)),
],
),
migrations.CreateModel(
name='PaymentApprovalPolicy',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('name', models.CharField(max_length=100, verbose_name='عنوان سیاست')),
('is_active', models.BooleanField(default=True)),
('reviewers', models.ManyToManyField(related_name='payment_review_policies', to=settings.AUTH_USER_MODEL, verbose_name='افراد الزامی تأیید')),
('seniors', models.ManyToManyField(blank=True, related_name='payment_senior_policies', to=settings.AUTH_USER_MODEL, verbose_name='تأییدکنندگان ارشد')),
('unit', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, to='staff.organizationalunit', verbose_name='واحد (خالی: عمومی)')),
],
options={
'permissions': [('manage_payment_approval_policy', 'Manage payment approval policy')],
'constraints': [models.UniqueConstraint(condition=models.Q(('is_active', True), ('unit__isnull', False)), fields=('unit',), name='pay_active_unit_policy'), models.UniqueConstraint(condition=models.Q(('is_active', True), ('unit__isnull', True)), fields=('is_active',), name='pay_active_global_policy')],
},
),
migrations.AddIndex(
model_name='paymentnotice',
index=models.Index(fields=['customer', '-created_at'], name='payments_pa_custome_bc6e3f_idx'),
),
migrations.AddIndex(
model_name='paymentnotice',
index=models.Index(fields=['owning_unit', '-created_at'], name='payments_pa_owning__f2b6af_idx'),
),
migrations.AddConstraint(
model_name='paymentnotice',
constraint=models.CheckConstraint(condition=models.Q(('amount__gt', 0), ('amount__lte', 9007199254740991)), name='pay_notice_amount'),
),
migrations.AddIndex(
model_name='paymentreceipt',
index=models.Index(fields=['customer', '-created_at'], name='payments_pa_custome_e266cc_idx'),
),
migrations.AddIndex(
model_name='paymentreceipt',
index=models.Index(fields=['status', '-created_at'], name='payments_pa_status_5cd50b_idx'),
),
migrations.AddConstraint(
model_name='paymentreceiptrevision',
constraint=models.UniqueConstraint(fields=('receipt', 'version'), name='pay_receipt_revision'),
),
migrations.AddConstraint(
model_name='paymentreceiptrevision',
constraint=models.CheckConstraint(condition=models.Q(('amount__gt', 0), ('amount__lte', 9007199254740991)), name='pay_receipt_amount'),
),
migrations.AddConstraint(
model_name='receiptapprovalassignment',
constraint=models.UniqueConstraint(fields=('cycle', 'user', 'is_senior'), name='pay_cycle_assignment'),
),
migrations.AddConstraint(
model_name='receiptdecision',
constraint=models.UniqueConstraint(fields=('cycle', 'user'), name='pay_one_vote_per_user'),
),
]
@@ -0,0 +1,23 @@
from django.db import migrations
BANKS = [["melli","ملی ایران"],["mellat","ملت"],["saderat","صادرات ایران"],["sepah","سپه"],["tejarat","تجارت"],["refah","رفاه کارگران"],["keshavarzi","کشاورزی"],["maskan","مسکن"],["sanat-madan","صنعت و معدن"],["tosee-saderat","توسعه صادرات ایران"],["tosee-taavon","توسعه تعاون"],["post","پست بانک ایران"],["pasargad","پاسارگاد"],["parsian","پارسیان"],["saman","سامان"],["eghtesad-novin","اقتصاد نوین"],["karafarin","کارآفرین"],["sina","سینا"],["shahr","شهر"],["sarmayeh","سرمایه"],["dey","دی"],["gardeshgari","گردشگری"],["iran-zamin","ایران زمین"],["khavarmianeh","خاورمیانه"],["resalat","قرض‌الحسنه رسالت"],["mehr-iran","قرض‌الحسنه مهر ایران"],["iran-venezuela","مشترک ایران و ونزوئلا"],["melal","مؤسسه اعتباری ملل"],["ayandeh","آینده"],["ansar","انصار"],["ghavamin","قوامین"],["hekmat","حکمت ایرانیان"],["mehr-eghtesad","مهر اقتصاد"],["kosar","مؤسسه کوثر"],["noor","مؤسسه نور"],["tosee","مؤسسه توسعه"]]
TAGS = ['recipient_name','company_name','notice_number','receipt_number','amount_rial','invoice_number','payment_link']
def seed(apps, schema_editor):
Bank = apps.get_model('payments', 'BankInstitution')
Template = apps.get_model('notifications', 'SMSTemplate')
for code, name in BANKS:
Bank.objects.get_or_create(code=code, defaults={'name':name, 'historical':code in {'ayandeh','ansar','ghavamin','hekmat','mehr-eghtesad','kosar','noor','tosee'}})
Bank.objects.get_or_create(code='other', defaults={'name': 'سایر'})
for event, title, body in [
('PAYMENT_NOTICE_CREATED','اعلامیه واریز','{recipient_name}، اعلامیه واریز {notice_number} به مبلغ {amount_rial} ریال برای شما صادر شد.\nمشاهده اطلاعات: {payment_link}\n{company_name}'),
('PAYMENT_RECEIPT_APPROVED','تأیید فیش','{recipient_name}، فیش واریز شما به شماره {receipt_number} و مبلغ {amount_rial} ریال تأیید شد.\nپیگیری: {payment_link}\n{company_name}'),
]:
Template.objects.get_or_create(unit=None,event_type=event, defaults={'title':title,'body':body,'allowed_tags':TAGS,'is_active':True})
apps.get_model('payments','PaymentAccessConfiguration').objects.get_or_create(singleton=1)
class Migration(migrations.Migration):
dependencies = [('payments','0001_initial'), ('notifications','0017_alter_smstemplate_event_type')]
operations = [migrations.RunPython(seed, migrations.RunPython.noop)]
@@ -0,0 +1,19 @@
# Generated by Django 5.1.1 on 2026-10-05 11:05
import uuid
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('payments', '0002_initial_configuration'),
]
operations = [
migrations.AddField(
model_name='paymentnotice',
name='issuance_key',
field=models.UUIDField(default=uuid.uuid4, editable=False, unique=True),
),
]
@@ -0,0 +1,46 @@
# Generated by Django 5.1.1 on 2026-10-06 05:38
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('payments', '0003_paymentnotice_issuance_key'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name='PaymentAccessLink',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('token_hash', models.CharField(editable=False, max_length=64, unique=True)),
('created_at', models.DateTimeField(auto_now_add=True)),
('expires_at', models.DateTimeField()),
('revoked_at', models.DateTimeField(blank=True, null=True)),
('view_count', models.PositiveIntegerField(default=0)),
('last_used_at', models.DateTimeField(blank=True, null=True)),
('customer', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to=settings.AUTH_USER_MODEL)),
('notice', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, to='payments.paymentnotice')),
('receipt', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, to='payments.paymentreceipt')),
('submitted_receipts', models.ManyToManyField(blank=True, related_name='access_links', to='payments.paymentreceipt')),
],
options={
'verbose_name': 'لینک موقت پرداخت',
'verbose_name_plural': 'لینک\u200cهای موقت پرداخت',
'ordering': ['-created_at'],
},
),
migrations.AddField(
model_name='paymentnotification',
name='access_link',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, to='payments.paymentaccesslink'),
),
migrations.AddConstraint(
model_name='paymentaccesslink',
constraint=models.CheckConstraint(condition=models.Q(('notice__isnull', False), ('receipt__isnull', False), _connector='OR'), name='payment_link_has_target'),
),
]
Whitespace-only changes.
+234
View File
@@ -0,0 +1,234 @@
"""Financial records are independent of tickets; issued instructions and evidence are snapshots."""
import uuid
from pathlib import Path
from django.conf import settings
from django.db import models
from apps.core.models import PublicIdModel, TimeStampedModel
MAX_AMOUNT = 9_007_199_254_740_991
class BankInstitution(models.Model):
code = models.SlugField(unique=True)
name = models.CharField(max_length=100)
is_active = models.BooleanField(default=True)
historical = models.BooleanField(default=False)
class Meta:
ordering = ['historical', 'name']
def __str__(self):
return self.name
class CompanyBankAccount(PublicIdModel, TimeStampedModel):
name = models.CharField('نام نمایشی حساب', max_length=120)
bank = models.ForeignKey(BankInstitution, on_delete=models.PROTECT, verbose_name='بانک')
holder = models.CharField('صاحب حساب', max_length=150)
account_number = models.CharField('شماره حساب', max_length=40, blank=True)
iban = models.CharField('شبا', max_length=26, blank=True)
card_number = models.CharField('شماره کارت', max_length=16, blank=True)
requires_deposit_id = models.BooleanField('شناسه واریز الزامی', default=False)
is_active = models.BooleanField('فعال کلی', default=True)
available_to_staff = models.BooleanField('قابل انتخاب کارشناسان', default=True)
available_to_customers = models.BooleanField('قابل انتخاب مشتریان', default=True)
sort_order = models.PositiveIntegerField(default=0)
class Meta:
ordering = ['sort_order', 'name', 'pk']
permissions = [('manage_company_bankaccounts', 'Manage company bank accounts')]
def __str__(self):
return self.name
def snapshot(self):
return {key: getattr(self, key) for key in ('name', 'holder', 'account_number', 'iban', 'card_number', 'requires_deposit_id')} | {'bank': self.bank.name, 'id': str(self.public_id)}
class PaymentAccessConfiguration(models.Model):
singleton = models.PositiveSmallIntegerField(default=1, unique=True, editable=False)
open_view = models.BooleanField('مشاهده برای همه کارشناسان فعال', default=True)
open_create = models.BooleanField('ایجاد برای همه کارشناسان فعال', default=True)
class Meta:
permissions = [('manage_payment_access', 'Manage payment access')]
class PaymentApprovalPolicy(TimeStampedModel):
name = models.CharField('عنوان سیاست', max_length=100)
unit = models.ForeignKey('staff.OrganizationalUnit', null=True, blank=True, on_delete=models.PROTECT, verbose_name='واحد (خالی: عمومی)')
reviewers = models.ManyToManyField(settings.AUTH_USER_MODEL, related_name='payment_review_policies', verbose_name='افراد الزامی تأیید')
seniors = models.ManyToManyField(settings.AUTH_USER_MODEL, related_name='payment_senior_policies', blank=True, verbose_name='تأییدکنندگان ارشد')
is_active = models.BooleanField(default=True)
class Meta:
constraints = [
models.UniqueConstraint(fields=['unit'], condition=models.Q(is_active=True, unit__isnull=False), name='pay_active_unit_policy'),
models.UniqueConstraint(fields=['is_active'], condition=models.Q(is_active=True, unit__isnull=True), name='pay_active_global_policy'),
]
permissions = [('manage_payment_approval_policy', 'Manage payment approval policy')]
def __str__(self):
return self.name
class PaymentNotice(PublicIdModel, TimeStampedModel):
issuance_key = models.UUIDField(default=uuid.uuid4, unique=True, editable=False)
customer = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.PROTECT, related_name='payment_notices')
created_by = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.PROTECT, related_name='issued_payment_notices')
owning_unit = models.ForeignKey('staff.OrganizationalUnit', null=True, on_delete=models.PROTECT)
account = models.ForeignKey(CompanyBankAccount, on_delete=models.PROTECT)
account_snapshot = models.JSONField()
subject = models.CharField(max_length=200)
amount = models.BigIntegerField()
deposit_id = models.CharField(max_length=100, blank=True)
invoice_number = models.CharField(max_length=100, blank=True)
description = models.TextField(blank=True)
cancelled_at = models.DateTimeField(null=True, blank=True)
class Meta:
ordering = ['-created_at', '-pk']
constraints = [models.CheckConstraint(condition=models.Q(amount__gt=0, amount__lte=MAX_AMOUNT), name='pay_notice_amount')]
indexes = [models.Index(fields=['customer', '-created_at']), models.Index(fields=['owning_unit', '-created_at'])]
@property
def number(self):
return f'PN-{self.public_id.hex[:12].upper()}'
class PaymentReceipt(PublicIdModel, TimeStampedModel):
class Status(models.TextChoices):
PENDING = 'PENDING', 'در انتظار بررسی'
PARTIAL = 'PARTIAL', 'تأیید ناقص'
APPROVED = 'APPROVED', 'تأیید شده'
REJECTED = 'REJECTED', 'رد شده'
customer = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.PROTECT, related_name='payment_receipts')
submitted_by = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.PROTECT, related_name='submitted_payment_receipts')
notice = models.ForeignKey(PaymentNotice, null=True, blank=True, on_delete=models.PROTECT, related_name='receipts')
owning_unit = models.ForeignKey('staff.OrganizationalUnit', null=True, on_delete=models.PROTECT)
status = models.CharField(max_length=12, choices=Status.choices, default=Status.PENDING, db_index=True)
submission_key = models.UUIDField(unique=True)
current_version = models.PositiveIntegerField(default=1)
class Meta:
ordering = ['-created_at', '-pk']
indexes = [models.Index(fields=['customer', '-created_at']), models.Index(fields=['status', '-created_at'])]
permissions = [('approve_paymentreceipt', 'Approve payment receipt'), ('senior_approve_paymentreceipt', 'Senior final approval'), ('submit_customer_receipt', 'Submit receipt for customer'), ('view_all_payments', 'View payments across units')]
@property
def number(self):
return f'PR-{self.public_id.hex[:12].upper()}'
def receipt_path(instance, filename):
return f'payments/{instance.created_at:%Y/%m}/{uuid.uuid4().hex}{Path(filename).suffix.lower()}'
class PaymentReceiptRevision(models.Model):
receipt = models.ForeignKey(PaymentReceipt, on_delete=models.PROTECT, related_name='revisions')
version = models.PositiveIntegerField()
amount = models.BigIntegerField()
paid_date = models.DateField()
destination = models.ForeignKey(CompanyBankAccount, on_delete=models.PROTECT)
account_snapshot = models.JSONField()
origin_bank = models.ForeignKey(BankInstitution, null=True, blank=True, on_delete=models.PROTECT)
origin_other = models.CharField(max_length=100, blank=True)
deposit_id = models.CharField(max_length=100, blank=True)
tracking_number = models.CharField(max_length=100, blank=True)
description = models.TextField(blank=True)
destination_mismatch = models.BooleanField(default=False)
created_at = models.DateTimeField(auto_now_add=True)
image = models.FileField(upload_to=receipt_path, max_length=500)
mime_type = models.CharField(max_length=100)
sha256 = models.CharField(max_length=64)
original_name = models.CharField(max_length=255)
file_size = models.PositiveBigIntegerField()
class Meta:
constraints = [models.UniqueConstraint(fields=['receipt', 'version'], name='pay_receipt_revision'), models.CheckConstraint(condition=models.Q(amount__gt=0, amount__lte=MAX_AMOUNT), name='pay_receipt_amount')]
class ReceiptApprovalCycle(models.Model):
revision = models.OneToOneField(PaymentReceiptRevision, on_delete=models.PROTECT, related_name='cycle')
policy_snapshot = models.JSONField(default=dict)
status = models.CharField(max_length=12, choices=PaymentReceipt.Status.choices, default=PaymentReceipt.Status.PENDING)
finalized_by = models.ForeignKey(settings.AUTH_USER_MODEL, null=True, blank=True, on_delete=models.PROTECT)
finalized_at = models.DateTimeField(null=True, blank=True)
class ReceiptApprovalAssignment(models.Model):
cycle = models.ForeignKey(ReceiptApprovalCycle, on_delete=models.PROTECT, related_name='assignments')
user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.PROTECT)
is_senior = models.BooleanField(default=False)
class Meta:
constraints = [models.UniqueConstraint(fields=['cycle', 'user', 'is_senior'], name='pay_cycle_assignment')]
class ReceiptDecision(models.Model):
cycle = models.ForeignKey(ReceiptApprovalCycle, on_delete=models.PROTECT, related_name='decisions')
user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.PROTECT)
action = models.CharField(max_length=20, choices=[('APPROVE', 'تأیید'), ('REJECT', 'رد'), ('SENIOR_APPROVE', 'تأیید ارشد')])
reason = models.TextField(blank=True)
actor_name = models.CharField(max_length=150)
created_at = models.DateTimeField(auto_now_add=True)
class Meta:
constraints = [models.UniqueConstraint(fields=['cycle', 'user'], name='pay_one_vote_per_user')]
class PaymentAuditEvent(models.Model):
actor = models.ForeignKey(settings.AUTH_USER_MODEL, null=True, on_delete=models.SET_NULL)
actor_name = models.CharField(max_length=150)
action = models.CharField(max_length=50)
object_type = models.CharField(max_length=50)
object_id = models.CharField(max_length=64)
details = models.JSONField(default=dict)
created_at = models.DateTimeField(auto_now_add=True, db_index=True)
class Meta:
ordering = ['-created_at', '-pk']
permissions = [('view_payment_audit', 'View financial audit')]
class PaymentAccessLink(models.Model):
"""Revocable capability, separate from customer login and ticket magic links."""
customer = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.PROTECT)
notice = models.ForeignKey(PaymentNotice, null=True, blank=True, on_delete=models.PROTECT)
receipt = models.ForeignKey(PaymentReceipt, null=True, blank=True, on_delete=models.PROTECT)
token_hash = models.CharField(max_length=64, unique=True, editable=False)
created_at = models.DateTimeField(auto_now_add=True)
expires_at = models.DateTimeField()
revoked_at = models.DateTimeField(null=True, blank=True)
submitted_receipts = models.ManyToManyField(PaymentReceipt, blank=True, related_name='access_links')
view_count = models.PositiveIntegerField(default=0)
last_used_at = models.DateTimeField(null=True, blank=True)
class Meta:
ordering = ['-created_at']
constraints = [models.CheckConstraint(
condition=models.Q(notice__isnull=False) | models.Q(receipt__isnull=False),
name='payment_link_has_target')]
verbose_name = 'لینک موقت پرداخت'
verbose_name_plural = 'لینک‌های موقت پرداخت'
class PaymentNotification(models.Model):
job = models.OneToOneField('notifications.NotificationJob', on_delete=models.PROTECT, related_name='payment_notification')
notice = models.ForeignKey(PaymentNotice, null=True, on_delete=models.PROTECT)
cycle = models.ForeignKey(ReceiptApprovalCycle, null=True, on_delete=models.PROTECT)
unit = models.ForeignKey('staff.OrganizationalUnit', null=True, on_delete=models.PROTECT)
context = models.JSONField()
rendered_body = models.TextField(blank=True)
template_snapshot = models.JSONField(default=dict)
access_link = models.ForeignKey(PaymentAccessLink, null=True, blank=True, on_delete=models.PROTECT)
class PaymentNotificationAttempt(models.Model):
notification = models.ForeignKey(PaymentNotification, on_delete=models.PROTECT, related_name='attempt_log')
started_at = models.DateTimeField(auto_now_add=True)
finished_at = models.DateTimeField(null=True)
success = models.BooleanField(null=True)
provider_code = models.CharField(max_length=50, blank=True)
provider_id = models.CharField(max_length=500, blank=True)
error = models.TextField(blank=True)
+71
View File
@@ -0,0 +1,71 @@
from django.conf import settings
from django.db import transaction
from django.urls import reverse
from django.utils import timezone
from apps.notifications.services import enqueue
from .models import PaymentNotification, PaymentNotificationAttempt
EVENTS = {'PAYMENT_NOTICE_CREATED', 'PAYMENT_RECEIPT_APPROVED'}
TAGS = ['recipient_name', 'company_name', 'notice_number', 'receipt_number', 'amount_rial', 'invoice_number', 'payment_link']
def context(customer, notice=None, receipt=None, amount=0):
profile = getattr(customer, 'customer_profile', None)
return {'recipient_name': str(profile) if profile else customer.username, 'company_name': settings.COMPANY_NAME,
'notice_number': notice.number if notice else '', 'receipt_number': receipt.number if receipt else '',
'amount_rial': f'{amount:,}', 'invoice_number': notice.invoice_number if notice else '',
'payment_link': settings.PUBLIC_BASE_URL + (reverse('payments:receipt_detail', args=[receipt.public_id]) if receipt else reverse('payments:notices') + f'?notice={notice.public_id}')}
def queue_notice(notice):
job = enqueue('SMS', 'PAYMENT_NOTICE_CREATED', None, f'payment-notice:{notice.public_id}', customer=notice.customer, staff_user=notice.created_by)
PaymentNotification.objects.get_or_create(job=job, defaults={'notice': notice, 'unit': notice.owning_unit, 'context': context(notice.customer, notice=notice, amount=notice.amount)})
def queue_approval(receipt, cycle):
job = enqueue('SMS', 'PAYMENT_RECEIPT_APPROVED', None, f'payment-approved:{cycle.pk}', customer=receipt.customer, staff_user=cycle.finalized_by)
PaymentNotification.objects.get_or_create(job=job, defaults={'cycle': cycle, 'unit': receipt.owning_unit, 'context': context(receipt.customer, notice=receipt.notice, receipt=receipt, amount=cycle.revision.amount)})
def send_payment_sms(job, provider=None, provider_factory=None):
from apps.notifications.models import SMSTemplate
from apps.notifications.management.commands.process_notifications import _render_sms
notification = PaymentNotification.objects.get(job=job)
# Persist the attempt before rendering/provider construction: failed preparation is history too.
attempt = PaymentNotificationAttempt.objects.create(notification=notification)
phase = 'آماده‌سازی قالب پیامک'
try:
with transaction.atomic():
notification = PaymentNotification.objects.select_for_update().get(pk=notification.pk)
if not notification.access_link_id:
from .access import issue_payment_link
receipt = notification.cycle.revision.receipt if notification.cycle_id else None
link, url = issue_payment_link(notice=notification.notice, receipt=receipt)
notification.access_link = link
old_url = notification.context.get('payment_link', '')
if old_url and notification.rendered_body:
notification.rendered_body = notification.rendered_body.replace(old_url, url)
notification.context = {**notification.context, 'payment_link': url}
notification.save(update_fields=['context', 'access_link', 'rendered_body'])
if not notification.rendered_body:
body = _render_sms(job.event_type, notification.context, notification.unit)
template = SMSTemplate.objects.filter(event_type=job.event_type, is_active=True, unit=notification.unit).first() if notification.unit_id else None
template = template or SMSTemplate.objects.get(event_type=job.event_type, is_active=True, unit__isnull=True)
notification.rendered_body = body
notification.template_snapshot = {'id': template.pk, 'updated_at': template.updated_at.isoformat()}
notification.save(update_fields=['rendered_body', 'template_snapshot', 'context', 'access_link'])
phase = 'ارتباط با ارائه‌دهنده پیامک'
provider = provider if provider is not None else provider_factory()
result = provider.send_message(job.customer.mobile, notification.rendered_body)
attempt.success = result.success
attempt.provider_id = str(result.provider_id or '')[:500]
attempt.provider_code = str(result.provider_code or '')[:50]
attempt.error = str(result.error or '')[:1000]
return result, None
except Exception:
attempt.success = False
attempt.error = 'خطا در ' + phase
raise
finally:
attempt.finished_at = timezone.now()
attempt.save()
+64
View File
@@ -0,0 +1,64 @@
from django.db.models import Q
from .models import PaymentAccessConfiguration
def is_staff(user):
profile = getattr(user, 'staff_profile', None)
return bool(user.is_authenticated and user.is_active and user.account_type == 'STAFF' and (profile is None or profile.is_active))
def has_permission(user, codename, unit=None):
if not is_staff(user):
return False
if user.has_perm(f'payments.{codename}'):
return True
if unit is None or not unit.is_active:
return False
return user.unit_memberships.filter(is_active=True, unit=unit).filter(
Q(permissions__content_type__app_label='payments', permissions__codename=codename) |
Q(unit__permissions__content_type__app_label='payments', unit__permissions__codename=codename)
).exists()
def config():
# No get_or_create on a read request; defaults keep initial view/create open only.
return PaymentAccessConfiguration.objects.first() or PaymentAccessConfiguration()
def can_create(user, unit=None):
if unit is None and is_staff(user):
from apps.tickets.policies import TicketPolicy
unit = TicketPolicy.primary_unit(user)
return is_staff(user) and (config().open_create or has_permission(user, 'add_paymentnotice', unit))
def can_manage_banks(user):
profile = getattr(user, 'staff_profile', None)
return is_staff(user) and (user.is_superuser or getattr(profile, 'role', '') == 'CEO' or has_permission(user, 'manage_company_bankaccounts'))
def visible(queryset, user):
if not user.is_authenticated or not user.is_active:
return queryset.none()
if user.account_type == 'CUSTOMER':
return queryset.filter(customer=user)
if not is_staff(user):
return queryset.none()
if config().open_view or user.has_perm('payments.view_all_payments') or user.is_superuser:
return queryset
permission = f'view_{queryset.model._meta.model_name}'
if user.has_perm(f'payments.{permission}'):
return queryset
units = user.unit_memberships.filter(is_active=True, unit__is_active=True).filter(
Q(permissions__content_type__app_label='payments', permissions__codename=permission) |
Q(unit__permissions__content_type__app_label='payments', unit__permissions__codename=permission)
).values_list('unit_id', flat=True)
return queryset.filter(owning_unit_id__in=units).distinct()
def can_decide(user, receipt, cycle, senior=False):
code = 'senior_approve_paymentreceipt' if senior else 'approve_paymentreceipt'
return (is_staff(user) and receipt.submitted_by_id != user.pk and
has_permission(user, code, receipt.owning_unit) and
visible(type(receipt).objects.filter(pk=receipt.pk), user).exists() and
cycle.assignments.filter(user=user, is_senior=senior).exists())
+78
View File
@@ -0,0 +1,78 @@
"""Bounded-query projections use only the current evidence revision for financial totals."""
from django.urls import reverse
from django.db.models import BigIntegerField, F, OuterRef, Subquery, Sum, Value
from django.db.models.functions import Coalesce
from .models import PaymentReceiptRevision
def notices_with_totals(queryset):
approved = PaymentReceiptRevision.objects.filter(
receipt__notice_id=OuterRef('pk'), receipt__status='APPROVED',
version=F('receipt__current_version'), destination_mismatch=False
).values('receipt__notice_id').annotate(total=Sum('amount')).values('total')
return queryset.annotate(approved_total=Coalesce(Subquery(approved), Value(0), output_field=BigIntegerField()))
def receipts_with_current_values(queryset):
current = PaymentReceiptRevision.objects.filter(receipt_id=OuterRef('pk'), version=OuterRef('current_version'))
return queryset.annotate(current_amount=Subquery(current.values('amount')[:1]),
current_paid_date=Subquery(current.values('paid_date')[:1]))
def notice_form_data(notice, staff=False):
"""Customer-owned notice summary; account numbers stay on the notice page."""
return {'id': str(notice.pk), 'subject': notice.subject, 'amount': str(notice.amount),
'account': notice.account_id, 'deposit': notice.deposit_id,
'invoice': notice.invoice_number,
'url': reverse('staff_payments:notice_detail' if staff else 'payments:notice_detail', args=[notice.public_id])}
def receipt_review_summaries(receipts):
"""Attach current-version decisions in one query, preserving snapshot actor names."""
from .models import ReceiptDecision
receipts = list(receipts)
indexed = {receipt.pk: receipt for receipt in receipts}
for receipt in receipts:
receipt.review_decisions = []
if indexed:
decisions = ReceiptDecision.objects.filter(
cycle__revision__receipt_id__in=indexed,
cycle__revision__version=F('cycle__revision__receipt__current_version')
).select_related('cycle__revision').order_by('created_at', 'pk')
for decision in decisions:
indexed[decision.cycle.revision.receipt_id].review_decisions.append(decision)
return receipts
def attach_notice_receipts(notices, visible_receipts):
"""Only fetch linked receipts visible to the current viewer, for this page."""
notices = list(notices)
indexed = {notice.pk: notice for notice in notices}
for notice in notices:
notice.related_receipts = []
if indexed:
receipts = receipt_review_summaries(receipts_with_current_values(
visible_receipts.filter(notice_id__in=indexed)))
for receipt in receipts:
indexed[receipt.notice_id].related_receipts.append(receipt)
return notices
def recent_pending_notice_choices(form, retain_original=None):
"""Limit the visible menu, keeping server-side ownership validation unsliced.
An explicitly selected pending notice replaces the last option when needed.
A revision retains its original notice even if it has since been settled.
This does not change which notices the financial service may validate.
"""
field = form.fields['notice_id']
pending = notices_with_totals(field.queryset).filter(amount__gt=F('approved_total'))
available = list(pending.order_by('-created_at', '-pk')[:10])
selected = form['notice_id'].value()
if selected and not any(str(n.pk) == str(selected) for n in available):
source = field.queryset if str(selected) == str(retain_original) else pending
selected_notice = source.filter(pk=selected).first() if str(selected).isdecimal() and len(str(selected)) <= 19 and int(selected) <= 9223372036854775807 else None
if selected_notice:
available = [selected_notice] + available[:9]
field.widget.choices = [('', field.empty_label)] + [(n.pk, field.label_from_instance(n)) for n in available]
return available
+286
View File
@@ -0,0 +1,286 @@
"""All business writes go through these atomic, permission-checked operations."""
from django.core.exceptions import PermissionDenied, ValidationError
from django.core import serializers
import json
from django.db import transaction
from django.utils import timezone
from apps.accounts.models import User
from apps.tickets.policies import TicketPolicy
from . import policies, validators
from .models import (CompanyBankAccount, PaymentNotice, PaymentReceipt, PaymentReceiptRevision,
PaymentApprovalPolicy, ReceiptApprovalCycle, ReceiptApprovalAssignment,
ReceiptDecision, PaymentAuditEvent)
def actor_name(actor):
profile = getattr(actor, 'staff_profile', None)
return getattr(profile, 'display_name', '') or actor.get_full_name() or actor.username
def audit(actor, action, obj, details=None):
return PaymentAuditEvent.objects.create(actor=actor, actor_name=actor_name(actor) if actor else 'SYSTEM',
action=action, object_type=obj._meta.label_lower, object_id=str(getattr(obj, 'public_id', obj.pk)), details=details or {})
def configuration_snapshot(obj):
"""Serialize only the financial configuration passed here, including flags and M2M IDs."""
return json.loads(serializers.serialize('json', [obj]))[0]['fields']
@transaction.atomic
def issue_notice(actor, data):
unit = TicketPolicy.primary_unit(actor)
if not policies.can_create(actor, unit):
raise PermissionDenied
# Keep lock order consistent with receipt submission: customer, then account.
customer = User.objects.select_for_update().get(pk=data['customer'].pk, account_type='CUSTOMER', is_active=True)
account = CompanyBankAccount.objects.select_for_update(of=('self',)).select_related('bank').get(pk=data['account'].pk)
if not account.is_active or not account.available_to_staff:
raise ValidationError('این حساب دیگر برای صدور اعلامیه فعال نیست.')
deposit_id = validators.digits(data.get('deposit_id', ''))
if account.requires_deposit_id and not deposit_id:
raise ValidationError('برای این حساب شناسه واریز مشتری الزامی است.')
key = data.get('submission_key')
if key:
existing = PaymentNotice.objects.filter(issuance_key=key).first()
if existing:
if existing.created_by_id != actor.pk or existing.customer_id != customer.pk:
raise PermissionDenied
expected = {'account_id': account.pk, 'amount': validators.amount(data['amount']),
'deposit_id': deposit_id, 'subject': data['subject'],
'invoice_number': data.get('invoice_number', ''), 'description': data.get('description', '')}
if any(getattr(existing, field) != value for field, value in expected.items()):
raise ValidationError('این اعلامیه قبلاً ثبت شده است؛ برای اطلاعات متفاوت فرم تازه باز کنید.')
return existing
notice = PaymentNotice.objects.create(customer=customer, created_by=actor, owning_unit=unit, account=account,
account_snapshot=account.snapshot(), amount=validators.amount(data['amount']), deposit_id=deposit_id,
subject=data['subject'], invoice_number=data.get('invoice_number', ''), description=data.get('description', ''),
**({'issuance_key': key} if key else {}))
audit(actor, 'NOTICE_ISSUED', notice, {'account': account.snapshot(), 'amount': str(notice.amount), 'deposit_id': deposit_id})
from .notifications import queue_notice
queue_notice(notice)
return notice
def policy_for(unit):
policy = PaymentApprovalPolicy.objects.filter(is_active=True, unit=unit).first() if unit else None
return policy or PaymentApprovalPolicy.objects.filter(is_active=True, unit__isnull=True).first()
def assign_cycle(revision, actor):
policy = policy_for(revision.receipt.owning_unit)
reviewer_ids = list(policy.reviewers.values_list('pk', flat=True)) if policy else []
senior_ids = list(policy.seniors.values_list('pk', flat=True)) if policy else []
selected = User.objects.filter(pk__in=set(reviewer_ids + senior_ids))
if policy and (not reviewer_ids or any(not policies.is_staff(user) for user in selected)):
raise ValidationError('سیاست تأیید معتبر نیست؛ مدیر باید افراد فعال تأییدکننده را تعیین کند.')
cycle = ReceiptApprovalCycle.objects.create(revision=revision, policy_snapshot={
'policy_id': policy.pk if policy else None, 'name': policy.name if policy else 'هنوز تعیین نشده',
'method': 'ALL_SELECTED', 'reviewers': reviewer_ids, 'seniors': senior_ids})
ReceiptApprovalAssignment.objects.bulk_create([
ReceiptApprovalAssignment(cycle=cycle, user_id=pk, is_senior=senior)
for senior, ids in [(False, reviewer_ids), (True, senior_ids)] for pk in ids])
audit(actor, 'CYCLE_STARTED', revision.receipt, {'version': revision.version, 'policy': cycle.policy_snapshot})
return cycle
def receipt_metadata(data):
return {'amount': data['paid_amount'], 'paid_date': data['paid_date'], 'destination': data['destination_account'],
'origin_bank': data.get('origin_bank'), 'origin_other': data.get('origin_other', ''),
'deposit_id': validators.digits(data.get('deposit_id', '')), 'tracking_number': data.get('tracking_number', ''),
'description': data.get('description', '')}
def validate_destination(account, notice):
if not account.is_active:
raise ValidationError('این حساب غیرفعال شده است؛ برای ثبت پرداخت قبلی با کارشناس هماهنگ کنید.')
if not account.available_to_customers and (not notice or notice.account_id != account.pk):
raise ValidationError('حساب مقصد برای این واریز قابل انتخاب نیست.')
def _submit_receipt(actor, customer, data, file_info, created_files):
# Same-customer lock serializes retries of a submission nonce before inserting it.
User.objects.select_for_update().get(pk=customer.pk)
existing = PaymentReceipt.objects.filter(submission_key=data['submission_key']).first()
if existing:
revision = existing.revisions.get(version=1)
if existing.customer_id != customer.pk or existing.submitted_by_id != actor.pk:
raise PermissionDenied
if (revision.amount != data['paid_amount'] or revision.paid_date != data['paid_date'] or
revision.destination_id != data['destination_account'].pk or revision.sha256 != file_info['sha256'] or
existing.notice_id != getattr(data.get('notice_id'), 'pk', None)):
raise ValidationError('این ارسال قبلاً ثبت شده است؛ برای اطلاعات متفاوت فرم تازه باز کنید.')
return existing
notice = data.get('notice_id')
if notice:
notice = PaymentNotice.objects.select_for_update().get(pk=notice.pk, customer=customer)
if notice.cancelled_at:
raise ValidationError('اعلامیه لغو شده است؛ واریز مستقل را انتخاب کنید.')
account = CompanyBankAccount.objects.select_for_update().select_related('bank').get(pk=data['destination_account'].pk)
validate_destination(account, notice)
receipt = PaymentReceipt.objects.create(customer=customer, submitted_by=actor, notice=notice,
owning_unit=notice.owning_unit if notice else (TicketPolicy.primary_unit(actor) if policies.is_staff(actor) else None), submission_key=data['submission_key'])
fields = receipt_metadata(data)
fields['destination'] = account
fields['deposit_id'] = fields['deposit_id'] or (notice.deposit_id if notice and notice.account_id == account.pk else '')
revision = PaymentReceiptRevision(receipt=receipt, version=1,
account_snapshot=notice.account_snapshot if notice and notice.account_id == account.pk else account.snapshot(),
destination_mismatch=bool(notice and notice.account_id != account.pk), image=data['receipt_image'], **file_info, **fields)
try:
revision.save()
finally:
if revision.image and revision.image._committed:
created_files.append((revision.image.storage, revision.image.name))
assign_cycle(revision, actor)
audit(actor, 'RECEIPT_SUBMITTED', receipt, {'version': 1, 'amount': str(revision.amount), 'sha256': revision.sha256})
return receipt
def submit_receipt(actor, customer, data, *, access_link=None):
if not actor.is_active or customer.account_type != 'CUSTOMER' or not customer.is_active:
raise PermissionDenied
if actor.pk != customer.pk and not policies.can_create(actor):
raise PermissionDenied
if actor.account_type == 'CUSTOMER' and actor.pk != customer.pk:
raise PermissionDenied
data = dict(data)
data['paid_amount'] = validators.amount(data['paid_amount'])
if data['paid_date'] > timezone.localdate():
raise ValidationError('تاریخ واریز نمی‌تواند بعد از امروز باشد.')
file_info = validators.image(data['receipt_image'])
# File storage is not transactional. Remove only the file created by this failed write.
created_files = []
try:
with transaction.atomic():
if access_link:
from .access import validate_link_submission
validate_link_submission(access_link, customer, data)
result = _submit_receipt(actor, customer, data, file_info, created_files)
if access_link:
access_link.submitted_receipts.add(result)
return result
except Exception:
for storage, name in created_files:
storage.delete(name)
raise
@transaction.atomic
def decide_receipt(actor, receipt_id, version, action, reason=''):
# Lock only the receipt: PostgreSQL cannot lock the nullable side of a LEFT JOIN.
receipt = PaymentReceipt.objects.select_for_update(of=('self',)).select_related('owning_unit').get(public_id=receipt_id)
# Never authorize a financial vote using a stale User/profile/permission cache.
actor = User.objects.select_for_update().get(pk=actor.pk)
if receipt.current_version != version:
raise ValidationError('اطلاعات تغییر کرده است؛ نسخه تازه را دوباره بررسی کنید.')
revision = receipt.revisions.get(version=version)
cycle = ReceiptApprovalCycle.objects.select_for_update().get(revision=revision)
if action not in {'APPROVE', 'REJECT', 'SENIOR_APPROVE'}:
raise ValidationError('نوع تصمیم معتبر نیست.')
allowed = policies.can_decide(actor, receipt, cycle, senior=action == 'SENIOR_APPROVE')
# Assigned seniors may also reject with a reason; all permission, visibility,
# own-submission and assignment checks remain in can_decide.
if not allowed and action == 'REJECT':
allowed = policies.can_decide(actor, receipt, cycle, senior=True)
if not allowed:
raise PermissionDenied
reason = reason.strip()
if action in {'REJECT', 'SENIOR_APPROVE'} and not reason:
raise ValidationError('دلیل این تصمیم را بنویسید.')
old = cycle.decisions.filter(user=actor).first()
if old:
if old.action == action and old.reason == reason:
return receipt
raise ValidationError('تصمیم شما ثبت شده است؛ تغییر آن نیازمند چرخه جدید است.')
if receipt.status not in {'PENDING', 'PARTIAL'}:
raise ValidationError('بررسی این فیش پایان یافته است.')
if not cycle.assignments.filter(is_senior=False).exists():
raise ValidationError('مدیر باید ابتدا افراد تأییدکننده را تعیین کند.')
if revision.destination_mismatch and action != 'REJECT' and not reason:
raise ValidationError('حساب مقصد با اعلامیه متفاوت است؛ نتیجه بررسی مغایرت را بنویسید.')
ReceiptDecision.objects.create(cycle=cycle, user=actor, action=action, reason=reason, actor_name=actor_name(actor))
if action == 'REJECT':
status = 'REJECTED'
elif action == 'SENIOR_APPROVE':
status = 'APPROVED'
else:
required = set(cycle.assignments.filter(is_senior=False).values_list('user_id', flat=True))
voted = set(cycle.decisions.filter(action='APPROVE').values_list('user_id', flat=True))
status = 'APPROVED' if required <= voted else 'PARTIAL'
cycle.status = receipt.status = status
if status in {'APPROVED', 'REJECTED'}:
cycle.finalized_by = actor
cycle.finalized_at = timezone.now()
cycle.save(update_fields=['status', 'finalized_by', 'finalized_at'])
receipt.save(update_fields=['status', 'updated_at'])
audit(actor, action, receipt, {'version': version, 'reason': reason, 'status': status, 'cycle': cycle.pk})
if status == 'APPROVED':
from .notifications import queue_approval
queue_approval(receipt, cycle)
return receipt
@transaction.atomic
def bind_unassigned_cycle(actor, receipt):
"""An empty cycle cannot accept votes. An authorized manager can bind a newly configured policy."""
if not policies.has_permission(actor, 'manage_payment_approval_policy'):
raise PermissionDenied
receipt = PaymentReceipt.objects.select_for_update().get(pk=receipt.pk)
revision = receipt.revisions.get(version=receipt.current_version)
cycle = ReceiptApprovalCycle.objects.select_for_update().get(revision=revision)
if cycle.assignments.exists() or cycle.decisions.exists() or receipt.status != 'PENDING':
raise ValidationError('سیاست این چرخه قبلاً ثبت شده است و قابل بازنویسی نیست.')
policy = policy_for(receipt.owning_unit)
if not policy or not policy.reviewers.exists():
raise ValidationError('ابتدا یک سیاست فعال با افراد تأییدکننده ایجاد کنید.')
users = list(policy.reviewers.all()) + list(policy.seniors.all())
if any(not policies.is_staff(user) for user in users):
raise ValidationError('همه تأییدکنندگان باید فعال باشند.')
cycle.policy_snapshot = {'policy_id': policy.pk, 'name': policy.name, 'method': 'ALL_SELECTED', 'reviewers': list(policy.reviewers.values_list('pk', flat=True)), 'seniors': list(policy.seniors.values_list('pk', flat=True))}
cycle.save(update_fields=['policy_snapshot'])
ReceiptApprovalAssignment.objects.bulk_create([ReceiptApprovalAssignment(cycle=cycle, user=user, is_senior=senior) for senior, selected in [(False, policy.reviewers.all()), (True, policy.seniors.all())] for user in selected])
audit(actor, 'EMPTY_CYCLE_ASSIGNED', receipt, cycle.policy_snapshot)
def revise_receipt(actor, receipt_id, version, data):
data = dict(data)
data['paid_amount'] = validators.amount(data['paid_amount'])
if data['paid_date'] > timezone.localdate():
raise ValidationError('تاریخ واریز نمی‌تواند بعد از امروز باشد.')
file_info = validators.image(data['receipt_image'])
created_files = []
try:
with transaction.atomic():
receipt = PaymentReceipt.objects.select_for_update().get(public_id=receipt_id)
if actor.pk != receipt.customer_id and not policies.has_permission(actor,'submit_customer_receipt',receipt.owning_unit):
raise PermissionDenied
if not actor.is_active or receipt.status != 'REJECTED' or receipt.current_version != version:
raise ValidationError('فقط نسخه جاری فیش ردشده قابل اصلاح است.')
# Link identity is fixed for a revision; changing notice requires a distinct receipt.
if getattr(data.get('notice_id'),'pk',None) != receipt.notice_id:
raise ValidationError('ارتباط اعلامیه این فیش قابل تغییر نیست.')
account = CompanyBankAccount.objects.select_for_update().select_related('bank').get(pk=data['destination_account'].pk)
validate_destination(account,receipt.notice)
fields=receipt_metadata(data)
fields['destination']=account
notice = receipt.notice
fields['deposit_id'] = fields['deposit_id'] or (notice.deposit_id if notice and notice.account_id == account.pk else '')
new_version=version+1
revision=PaymentReceiptRevision(receipt=receipt,version=new_version,
account_snapshot=notice.account_snapshot if notice and notice.account_id == account.pk else account.snapshot(),
image=data['receipt_image'],destination_mismatch=bool(receipt.notice and receipt.notice.account_id!=account.pk),**file_info,**fields)
try:
revision.save()
finally:
if revision.image and revision.image._committed:
created_files.append((revision.image.storage,revision.image.name))
assign_cycle(revision,actor)
receipt.current_version=new_version
receipt.status='PENDING'
receipt.save(update_fields=['current_version','status','updated_at'])
audit(actor,'RECEIPT_REVISED',receipt,{'version':new_version,'previous_version':version,'sha256':revision.sha256})
return receipt
except Exception:
for storage,name in created_files:
storage.delete(name)
raise
+22
View File
@@ -0,0 +1,22 @@
from django.urls import path
from . import views
app_name = 'staff_payments'
urlpatterns = [
path('', views.desk, name='desk'),
path('count/', views.counts, name='count'),
path('customers/search/', views.customer_search, name='customer_search'),
path('notices/generate-identifier/', views.generate_deposit_identifier, name='generate_identifier'),
path('notices/new/', views.notice_new, name='notice_new'),
path('notices/<uuid:public_id>/', views.notice_detail, name='notice_detail'),
path('notices/<uuid:public_id>/payment-link/', views.notice_payment_link, name='notice_payment_link'),
path('receipts/new/<uuid:customer_id>/', views.staff_receipt_new, name='receipt_new'),
path('receipts/new/', views.staff_receipt_customer, name='receipt_customer'),
path('receipts/<uuid:public_id>/', views.staff_receipt_detail, name='receipt_detail'),
path('receipts/<uuid:public_id>/decide/', views.decision, name='decision'),
path('receipts/<uuid:public_id>/assign/', views.assign_policy, name='assign_policy'),
path('banks/', views.banks, name='banks'),
path('banks/new/', views.bank_edit, name='bank_new'),
path('banks/bulk/', views.bank_bulk, name='bank_bulk'),
path('banks/<uuid:public_id>/', views.bank_edit, name='bank_edit'),
path('banks/<uuid:public_id>/delete/', views.bank_delete, name='bank_delete'),
]
+14
View File
@@ -0,0 +1,14 @@
from django.urls import path
from . import views, link_views
app_name = 'payments'
urlpatterns = [
path('link/<str:token>/', link_views.open_link, name='open_link'),
path('', views.home, name='home'),
path('bank-accounts/', views.notices, name='notices'),
path('bank-accounts/<uuid:public_id>/', views.customer_notice_detail, name='notice_detail'),
path('payment-receipt/', views.receipt_new, name='receipt_new'),
path('receipts/', views.receipt_list, name='receipt_list'),
path('receipts/<uuid:public_id>/', views.receipt_detail, name='receipt_detail'),
path('receipts/<uuid:public_id>/revise/', views.receipt_revise, name='receipt_revise'),
path('receipts/<uuid:public_id>/image/', views.receipt_image, name='receipt_image'),
]
+93
View File
@@ -0,0 +1,93 @@
import hashlib
import re
import warnings
from pathlib import Path
import jdatetime
import magic
from PIL import Image, UnidentifiedImageError
from django.core.exceptions import ValidationError
from django.utils import timezone
from apps.attachments.models import AttachmentPolicy
from .models import MAX_AMOUNT
DIGIT_MAP = str.maketrans('۰۱۲۳۴۵۶۷۸۹٠١٢٣٤٥٦٧٨٩', '01234567890123456789')
def digits(value):
return str(value or '').translate(DIGIT_MAP).strip()
def amount(value):
value = digits(value)
if not re.fullmatch(r'\d+|\d{1,3}(?:[,٬ ]\d{3})+', value):
raise ValidationError('مبلغ را به ریال، فقط با عدد صحیح وارد کنید.')
value = int(re.sub(r'[,٬ ]', '', value))
if not 0 < value <= MAX_AMOUNT:
raise ValidationError('مبلغ باید بیشتر از صفر و در محدوده مجاز باشد.')
return value
def bank_number(value):
value = re.sub(r'[\s-]', '', digits(value))
if value and not value.isascii():
raise ValidationError('شماره را با ارقام معتبر وارد کنید.')
if value and not value.isdecimal():
raise ValidationError('شماره حساب یا کارت باید فقط عدد باشد.')
return value
def iban(value):
value = re.sub(r'\s', '', digits(value)).upper()
if not value:
return ''
if not re.fullmatch(r'IR\d{24}', value) or int(value[4:] + '1827' + value[2:4]) % 97 != 1:
raise ValidationError('شماره شبای معتبر ایرانی وارد کنید؛ IR و ۲۴ رقم.')
return value
def paid_date(day, month, year):
if not all((day, month, year)):
raise ValidationError('تاریخ را کامل کنید؛ روز، ماه و سال لازم‌اند.')
try:
value = jdatetime.date(int(digits(year)), int(digits(month)), int(digits(day))).togregorian()
except (ValueError, TypeError, OverflowError):
raise ValidationError('تاریخ شمسی معتبر انتخاب کنید.')
today = timezone.localdate()
if value > today:
raise ValidationError('تاریخ واریز نمی‌تواند بعد از امروز باشد.')
if jdatetime.date.fromgregorian(date=value).year < jdatetime.date.fromgregorian(date=today).year - 10:
raise ValidationError('تاریخ خارج از بازه ده سال گذشته است.')
return value
def image(upload):
policy = AttachmentPolicy.objects.filter(attachment_type='IMAGE', is_enabled=True).first()
if not policy:
raise ValidationError('ارسال تصویر فعلاً فعال نیست؛ با کارشناس هماهنگ کنید.')
if not upload.size or upload.size > policy.max_file_size:
raise ValidationError('تصویر خالی یا بزرگ‌تر از حجم مجاز است.')
ext = Path(upload.name).suffix.lower()
if ext not in (policy.allowed_extensions or ['.jpg', '.jpeg', '.png', '.gif', '.webp']):
raise ValidationError('فقط تصویر مجاز فیش را انتخاب کنید.')
head = upload.read(8192)
upload.seek(0)
mime = magic.from_buffer(head, mime=True)
expected = {'.jpg':'image/jpeg', '.jpeg':'image/jpeg', '.png':'image/png', '.gif':'image/gif', '.webp':'image/webp'}
if expected.get(ext) != mime or (policy.allowed_mime_types and mime not in policy.allowed_mime_types):
raise ValidationError('محتوای واقعی فایل با نوع تصویر سازگار نیست.')
try:
with warnings.catch_warnings():
warnings.simplefilter('error', Image.DecompressionBombWarning)
with Image.open(upload) as picture:
if picture.width * picture.height > 25_000_000:
raise ValidationError('ابعاد تصویر بیش از حد مجاز است.')
picture.verify()
except (UnidentifiedImageError, OSError, SyntaxError, Image.DecompressionBombError, Image.DecompressionBombWarning):
raise ValidationError('تصویر خراب است یا باز نمی‌شود؛ تصویر دیگری انتخاب کنید.')
finally:
upload.seek(0)
digest = hashlib.sha256()
for chunk in upload.chunks():
digest.update(chunk)
upload.seek(0)
return {'mime_type': mime, 'sha256': digest.hexdigest(), 'original_name': Path(upload.name).name[:255], 'file_size': upload.size}
+466
View File
@@ -0,0 +1,466 @@
from functools import wraps
from django.contrib import messages
from django.core.exceptions import PermissionDenied, ValidationError
from django.core.paginator import Paginator
from django.db import transaction
from django.db.models import Q, Count, Sum, F, Exists, OuterRef
from django.db.models.deletion import ProtectedError
from django.http import FileResponse, JsonResponse
from django.shortcuts import get_object_or_404, redirect, render
from django.urls import reverse
from django.utils import timezone
from django.views.decorators.http import require_POST
from . import policies, services, validators, selectors, customer_picker, access
from .forms import BankAccountForm, NoticeForm, ReceiptForm
from .models import (CompanyBankAccount, PaymentNotice, PaymentReceipt, PaymentReceiptRevision,
PaymentAuditEvent, ReceiptDecision)
def financial_required(staff=False, allow_payment_link=False):
def decorate(view):
@wraps(view)
def wrapped(request, *args, **kwargs):
blocked = access.prepare_payment_request(request, staff=staff, allow_payment_link=allow_payment_link)
if blocked is not None:
return blocked
response = view(request, *args, **kwargs)
if getattr(request, 'payment_access_link', None):
response['Referrer-Policy'] = 'same-origin'
response['X-Robots-Tag'] = 'noindex, nofollow'
return response
return wrapped
return decorate
def staff_context(**kwargs):
return {'active_staff_section': 'payments', 'active_tab_title': 'اعلامیه‌ها و فیش‌های واریزی', **kwargs}
@financial_required()
def home(request):
return render(request, 'payments/home.html', {'financial_page': True})
def complete_notice(notice):
snapshot = notice.account_snapshot
approved = getattr(notice, 'approved_total', None)
if approved is None:
approved = notice.receipts.filter(status='APPROVED', revisions__version=F('current_version'), revisions__destination_mismatch=False).aggregate(total=Sum('revisions__amount'))['total'] or 0
notice.approved_amount = approved
notice.remaining_amount = max(0, notice.amount-approved)
notice.settlement_label = 'تسویه شده' if approved >= notice.amount else ('پرداخت جزئی' if approved else 'در انتظار فیش تأییدشده')
# Complete copy uses immutable data, including fields absent from the compact UI.
from apps.core.templatetags.jalali import jalali_datetime
notice.copy_text = '\n'.join(f'{label}: {value}' for label, value in [
('اعلامیه', notice.number), ('تاریخ صدور', jalali_datetime(notice.created_at)), ('موضوع', notice.subject), ('مبلغ (ریال)', f'{notice.amount:,}'),
('نام حساب', snapshot.get('name')), ('بانک', snapshot.get('bank')), ('صاحب حساب', snapshot.get('holder')),
('شماره حساب', snapshot.get('account_number')), ('شبا', snapshot.get('iban')),
('کارت', snapshot.get('card_number')), ('شناسه واریز', notice.deposit_id),
('فاکتور', notice.invoice_number), ('توضیحات', notice.description)] if value)
return notice
@financial_required()
def notices(request):
items = selectors.notices_with_totals(policies.visible(PaymentNotice.objects.all(), request.user))
query = request.GET.get('q', '').strip()
if query:
items = items.filter(Q(subject__icontains=query) | Q(invoice_number__icontains=validators.digits(query)))
page = Paginator(items, 20).get_page(request.GET.get('page'))
page.object_list = selectors.attach_notice_receipts(page.object_list, policies.visible(PaymentReceipt.objects.all(), request.user))
for notice in page:
complete_notice(notice)
params = request.GET.copy()
params.pop('page', None)
return render(request, 'payments/notices.html', {'page_obj': page, 'query': query, 'query_string':params.urlencode(), 'open_notice': request.GET.get('notice', ''), 'financial_page': True})
@financial_required(allow_payment_link=True)
def customer_notice_detail(request, public_id):
notice = get_object_or_404(selectors.notices_with_totals(
access.visible_payments(request, PaymentNotice.objects.all())), public_id=public_id)
selectors.attach_notice_receipts([notice], access.visible_payments(request, PaymentReceipt.objects.all()))
return render(request, 'payments/customer_notice_detail.html', access.customer_context(request,
notice=complete_notice(notice), open_notice=str(notice.public_id), financial_page=True))
def receipt_form_page(request, customer, staff=False):
link = getattr(request, 'payment_access_link', None)
initial = access.receipt_initial(request, customer)
form = ReceiptForm(request.POST or None, request.FILES or None, customer=customer, initial=initial,
notice_ids=[link.notice_id] if link else None)
if request.method == 'POST' and form.is_valid():
try:
receipt = services.submit_receipt(customer if link else request.user, customer, form.cleaned_data, access_link=link)
messages.success(request, 'فیش ثبت شد و در انتظار بررسی است.')
return redirect('staff_payments:receipt_detail' if staff else 'payments:receipt_detail', public_id=receipt.public_id)
except ValidationError as error:
form.add_error(None, error)
selected = form['notice_id'].value()
available = list(form.fields['notice_id'].queryset) if staff else selectors.recent_pending_notice_choices(form)
notice_data = [selectors.notice_form_data(n, staff=staff) for n in available]
from apps.attachments.models import AttachmentPolicy
image_policy = AttachmentPolicy.objects.filter(attachment_type='IMAGE', is_enabled=True).first()
ctx = {'form': form, 'selected_notice': selected, 'notice_data': notice_data, 'financial_page': True,
'max_bytes': image_policy.max_file_size if image_policy else 0, 'staff_form': staff, 'customer': customer,
'account_requirements': {str(a.pk): a.requires_deposit_id for a in form.fields['destination_account'].queryset}}
return render(request, 'payments/receipt_form.html', staff_context(**ctx) if staff else access.customer_context(request, **ctx))
@financial_required(allow_payment_link=True)
def receipt_new(request):
return receipt_form_page(request, request.payment_customer)
@financial_required(allow_payment_link=True)
def receipt_list(request):
items = selectors.receipts_with_current_values(access.visible_payments(request, PaymentReceipt.objects.all()).select_related('notice'))
return render(request, 'payments/receipt_list.html', access.customer_context(request, financial_page=True,
page_obj=Paginator(items, 20).get_page(request.GET.get('page'))))
@financial_required(staff=True)
def staff_receipt_customer(request):
if not policies.can_create(request.user):
raise PermissionDenied
from django import forms
form = forms.Form(request.POST or None, initial=customer_picker.initial_customer(request))
# Reuse the same active-customer selector and display labels as notice issuance.
form.fields['customer'] = NoticeForm().fields['customer']
if request.method == 'POST' and form.is_valid():
return redirect('staff_payments:receipt_new', customer_id=form.cleaned_data['customer'].public_id)
return render(request, 'payments/staff_form.html',staff_context(form=form,heading='ثبت فیش برای مشتری',submit_label='ادامه', **customer_picker.picker_context(request, form)))
@financial_required(staff=True)
def customer_search(request):
if not policies.can_create(request.user):
raise PermissionDenied
response = JsonResponse(customer_picker.search_results(request.user, request.GET.get('q', '')))
response['Cache-Control'] = 'private, no-store'
return response
@financial_required(staff=True)
def staff_receipt_new(request, customer_id):
if not policies.can_create(request.user):
raise PermissionDenied
from apps.accounts.models import User
customer = get_object_or_404(User.objects.select_related('customer_profile'), public_id=customer_id, account_type='CUSTOMER', is_active=True)
return receipt_form_page(request, customer, staff=True)
def receipt_context(request, public_id, staff):
receipt = get_object_or_404(access.visible_payments(request, PaymentReceipt.objects.select_related('customer__customer_profile', 'notice', 'owning_unit')), public_id=public_id)
revision = receipt.revisions.select_related('destination', 'origin_bank', 'cycle').get(version=receipt.current_version)
cycle = revision.cycle
services.audit(request.payment_customer, 'RECEIPT_VIEWED', receipt)
decisions = ReceiptDecision.objects.filter(cycle__revision__receipt=receipt).select_related('user', 'cycle__revision').order_by('created_at', 'pk')
return {'receipt': receipt, 'revision': revision, 'cycle': cycle, 'decisions': decisions,
'payment_link_access': getattr(request, 'payment_access_link', None),
'revision_events': PaymentAuditEvent.objects.filter(object_type='payments.paymentreceipt', object_id=str(receipt.public_id), action__in=['RECEIPT_SUBMITTED', 'RECEIPT_REVISED']).order_by('created_at', 'pk'),
'can_approve': staff and policies.can_decide(request.user, receipt, cycle),
'can_senior': staff and policies.can_decide(request.user, receipt, cycle, senior=True),
'can_assign': staff and not cycle.assignments.exists() and policies.has_permission(request.user, 'manage_payment_approval_policy'),
'staff_detail': staff, 'financial_page': True, 'versions': receipt.revisions.select_related('cycle').order_by('version'),
'duplicate_image': PaymentReceiptRevision.objects.filter(sha256=revision.sha256,
receipt__in=policies.visible(PaymentReceipt.objects.all(),request.user)).exclude(receipt=receipt).exists() if staff else False}
@financial_required(allow_payment_link=True)
def receipt_detail(request, public_id):
return render(request, 'payments/receipt_detail.html', receipt_context(request, public_id, False))
@financial_required(staff=True)
def staff_receipt_detail(request, public_id):
return render(request, 'payments/receipt_detail.html', staff_context(**receipt_context(request, public_id, True)))
@financial_required(staff=None, allow_payment_link=True)
def receipt_image(request, public_id):
try:
version = int(request.GET.get('version', '1'))
except ValueError:
from django.http import Http404
raise Http404
revision = get_object_or_404(PaymentReceiptRevision.objects.select_related('receipt'), receipt__public_id=public_id, version=version)
if not access.visible_payments(request, PaymentReceipt.objects.filter(pk=revision.receipt_id)).exists():
raise PermissionDenied
services.audit(request.payment_customer, 'RECEIPT_FILE_REQUESTED', revision.receipt, {'version': revision.version})
# Authorize EVERY access, including debug: financial media never uses MEDIA_URL.
response = FileResponse(revision.image.open('rb'), content_type=revision.mime_type,
as_attachment=request.GET.get('download') == '1', filename=revision.original_name)
response['Cache-Control'] = 'private, no-store'
response['X-Content-Type-Options'] = 'nosniff'
return response
@financial_required(staff=True)
@require_POST
def decision(request, public_id):
get_object_or_404(policies.visible(PaymentReceipt.objects.all(),request.user),public_id=public_id)
try:
version = int(request.POST.get('version', '0'))
receipt = services.decide_receipt(request.user, public_id, version, request.POST.get('action'), request.POST.get('reason', ''))
messages.success(request, 'تصمیم ثبت شد. ' + receipt.get_status_display())
except (ValidationError, ValueError) as error:
messages.error(request, '؛ '.join(error.messages) if isinstance(error, ValidationError) else 'نسخه فیش معتبر نیست.')
return redirect('staff_payments:receipt_detail', public_id=public_id)
@financial_required(staff=True)
@require_POST
def assign_policy(request, public_id):
receipt = get_object_or_404(policies.visible(PaymentReceipt.objects.all(), request.user), public_id=public_id)
try:
services.bind_unassigned_cycle(request.user, receipt)
messages.success(request, 'افراد تأییدکننده تعیین شدند.')
except ValidationError as error:
messages.error(request, '؛ '.join(error.messages))
return redirect('staff_payments:receipt_detail', public_id=public_id)
def filtered(queryset, request):
query = validators.digits(request.GET.get('q', ''))
if query:
condition = Q(customer__username__icontains=query) | Q(customer__mobile__icontains=query) | Q(customer__customer_profile__first_name__icontains=query) | Q(customer__customer_profile__last_name__icontains=query) | Q(customer__customer_profile__province__icontains=query) | Q(customer__customer_profile__city__icontains=query)
# PN/PR numbers are UUID prefixes, not database columns.
prefix = query.upper().removeprefix('PN-').removeprefix('PR-').replace('-', '')
if prefix and len(prefix) <= 32 and all(c in '0123456789ABCDEF' for c in prefix):
uuid_prefix = '-'.join(prefix[start:end] for start, end in [(0, 8), (8, 12), (12, 16), (16, 20), (20, 32)] if len(prefix) > start)
condition |= Q(public_id__istartswith=uuid_prefix)
if queryset.model == PaymentNotice:
condition |= Q(subject__icontains=query) | Q(invoice_number__icontains=query)
else:
condition |= Q(notice__invoice_number__icontains=query) | Q(revisions__tracking_number__icontains=query)
queryset = queryset.filter(condition)
for key, field in [('account', 'account_id' if queryset.model == PaymentNotice else 'revisions__destination_id'), ('unit', 'owning_unit_id'), ('customer', 'customer_id')]:
value = request.GET.get(key, '')
if value.isdecimal():
queryset = queryset.filter(**{field: value})
for key, op in [('from', 'gte'), ('to', 'lte')]:
value = request.GET.get(key, '')
if value:
try:
import datetime
parsed = datetime.date.fromisoformat(value)
queryset = queryset.filter(**{f'created_at__date__{op}': parsed})
except ValueError:
messages.error(request, 'بازه تاریخ معتبر نیست.')
return queryset.distinct()
@financial_required(staff=True)
def desk(request):
tab = request.GET.get('tab', 'receipts')
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))
if tab == 'notices':
items = filtered(notice_items, request)
state = request.GET.get('notice_state', '')
if state == 'settled':
items = items.filter(cancelled_at__isnull=True, approved_total__gte=F('amount'))
elif state == 'pending':
items = items.filter(cancelled_at__isnull=True, approved_total__lt=F('amount'))
elif state == 'cancelled':
items = items.filter(cancelled_at__isnull=False)
else:
items = filtered(receipts, request)
status = request.GET.get('status', '')
if status in PaymentReceipt.Status.values:
items = items.filter(status=status)
if request.GET.get('kind') == 'independent':
items = items.filter(notice__isnull=True)
elif request.GET.get('kind') == 'linked':
items = items.filter(notice__isnull=False)
if request.GET.get('mine'):
my_decision = ReceiptDecision.objects.filter(cycle__revision__receipt_id=OuterRef('pk'),
cycle__revision__version=OuterRef('current_version'), user=request.user)
items = items.filter(status__in=['PENDING','PARTIAL'], revisions__version=F('current_version'),
revisions__cycle__assignments__user=request.user).annotate(
my_decision_exists=Exists(my_decision)).filter(my_decision_exists=False).distinct()
page = Paginator(items.order_by('-created_at','-pk'), 25).get_page(request.GET.get('page'))
if tab == 'notices':
page.object_list = selectors.attach_notice_receipts(page.object_list, policies.visible(PaymentReceipt.objects.all(), request.user))
for notice in page.object_list:
complete_notice(notice)
else:
page.object_list = selectors.receipt_review_summaries(page.object_list)
query = request.GET.copy()
query.pop('page', None)
return render(request, 'payments/desk.html', staff_context(page_obj=page, tab=tab, statuses=PaymentReceipt.Status.choices,
can_create=policies.can_create(request.user), banks=CompanyBankAccount.objects.all(), query_string=query.urlencode()))
@financial_required(staff=True)
@require_POST
def generate_deposit_identifier(request):
if not policies.can_create(request.user):
raise PermissionDenied
# Resolve the same active customer as NoticeForm; never trust a posted mobile.
try:
customer = NoticeForm().fields['customer'].clean(request.POST.get('customer'))
mobile = validators.digits(customer.local_mobile)
import re
if not re.fullmatch(r'09[0-9]{9}', mobile):
raise ValidationError('شماره موبایل مشتری معتبر نیست؛ ابتدا اطلاعات مشتری را اصلاح کنید.')
except ValidationError as error:
return JsonResponse({'error': '؛ '.join(error.messages)}, status=400)
from .deposit_identifier import generate_id_algorithm
response = JsonResponse({'identifier': generate_id_algorithm(mobile), 'customer': str(customer.pk)})
response['Cache-Control'] = 'no-store'
return response
@financial_required(staff=True)
def notice_new(request):
if not policies.can_create(request.user):
raise PermissionDenied
form = NoticeForm(request.POST or None, initial=customer_picker.initial_customer(request))
if request.method == 'POST' and form.is_valid():
try:
notice = services.issue_notice(request.user, form.cleaned_data)
messages.success(request, 'اعلامیه صادر شد و پیامک در صف اطلاع‌رسانی قرار گرفت.')
return redirect('staff_payments:notice_detail', public_id=notice.public_id)
except ValidationError as error:
form.add_error(None, error)
return render(request, 'payments/staff_form.html', staff_context(form=form, heading='ایجاد اعلامیه واریز', submit_label='صدور و اطلاع‌رسانی', identifier_accounts=list(CompanyBankAccount.objects.filter(requires_deposit_id=True).values_list('pk',flat=True)), **customer_picker.picker_context(request, form)))
@financial_required(staff=True)
def notice_detail(request, public_id):
notice = complete_notice(get_object_or_404(policies.visible(PaymentNotice.objects.select_related('customer'), request.user), public_id=public_id))
services.audit(request.user, 'NOTICE_VIEWED', notice)
share = request.session.get('payment_link_share', {})
if share.get('notice') == str(public_id):
request.session.pop('payment_link_share', None)
else:
share = {}
return render(request, 'payments/notice_detail.html', staff_context(notice=notice,
can_share_payment_link=policies.can_create(request.user) and notice.customer.is_active,
payment_share_url=share.get('url', ''),
receipts=policies.visible(notice.receipts.all(), request.user), notifications=notice.paymentnotification_set.select_related('job')))
@financial_required(staff=True)
@require_POST
def notice_payment_link(request, public_id):
if not policies.can_create(request.user):
raise PermissionDenied
notice = get_object_or_404(policies.visible(PaymentNotice.objects.all(), request.user),
public_id=public_id, customer__is_active=True)
link, url = access.issue_payment_link(notice=notice)
services.audit(request.user, 'PAYMENT_LINK_CREATED', link)
request.session['payment_link_share'] = {'notice': str(public_id), 'url': url}
return redirect('staff_payments:notice_detail', public_id=public_id)
@financial_required(staff=True)
def counts(request):
count = policies.visible(PaymentReceipt.objects.all(), request.user).filter(status__in=['PENDING', 'PARTIAL']).count()
response = JsonResponse({'pending_receipts': count})
response['Cache-Control'] = 'private, no-store'
return response
@financial_required(staff=True)
def banks(request):
if not policies.can_manage_banks(request.user):
raise PermissionDenied
items = CompanyBankAccount.objects.select_related('bank')
query = request.GET.get('q','').strip()
if query:
items = items.filter(Q(name__icontains=query) | Q(bank__name__icontains=query) | Q(holder__icontains=query))
for field in ['is_active', 'available_to_staff', 'available_to_customers']:
if request.GET.get(field) in ['0','1']:
items = items.filter(**{field: request.GET[field] == '1'})
params = request.GET.copy()
params.pop('page', None)
return render(request, 'payments/banks.html', staff_context(page_obj=Paginator(items,25).get_page(request.GET.get('page')),
query_string=params.urlencode(), bank_flags=[{'key':key,'label':label,'value':request.GET.get(key,'')} for key,label in [('is_active','فعال کلی'),('available_to_staff','پنل کارشناسان'),('available_to_customers','پنل مشتریان')]], active_staff_section='banks', active_tab_title='بانک‌ها'))
@financial_required(staff=True)
def bank_edit(request, public_id=None):
if not policies.can_manage_banks(request.user):
raise PermissionDenied
instance = get_object_or_404(CompanyBankAccount, public_id=public_id) if public_id else None
before = services.configuration_snapshot(instance) if instance else {}
form = BankAccountForm(request.POST or None, instance=instance)
if request.method == 'POST' and form.is_valid():
with transaction.atomic():
if instance:
current = CompanyBankAccount.objects.select_for_update().get(pk=instance.pk)
if form.cleaned_data['revision_token'] != current.updated_at.isoformat():
form.add_error(None, 'حساب هم‌زمان تغییر کرده است؛ صفحه را تازه کنید.')
return render(request,'payments/staff_form.html',staff_context(form=form,bank_account=instance,
heading='ویرایش حساب',submit_label='ذخیره حساب',active_staff_section='banks'))
before = services.configuration_snapshot(current)
account = form.save()
services.audit(request.user, 'BANK_SAVED', account, {'before': before, 'after': services.configuration_snapshot(account)})
return redirect('staff_payments:banks')
return render(request,'payments/staff_form.html',staff_context(form=form, bank_account=instance, heading='ویرایش حساب' if instance else 'ایجاد حساب', submit_label='ذخیره حساب', active_staff_section='banks'))
@financial_required(staff=True)
@require_POST
def bank_bulk(request):
if not policies.can_manage_banks(request.user):
raise PermissionDenied
field = request.POST.get('field')
if field not in ['is_active','available_to_staff','available_to_customers'] or request.POST.get('value') not in ['0','1']:
raise ValidationError('عملیات معتبر نیست.')
ids = [value for value in request.POST.getlist('accounts') if value.isdecimal()]
with transaction.atomic():
for account in CompanyBankAccount.objects.select_for_update().filter(pk__in=ids):
before = getattr(account, field)
setattr(account,field,request.POST['value'] == '1')
account.save(update_fields=[field,'updated_at'])
services.audit(request.user,'BANK_FLAG_CHANGED',account,{'field':field,'before':before,'after':getattr(account,field)})
return redirect('staff_payments:banks')
@financial_required(staff=True)
@require_POST
def bank_delete(request, public_id):
if not policies.can_manage_banks(request.user):
raise PermissionDenied
account = get_object_or_404(CompanyBankAccount,public_id=public_id)
try:
with transaction.atomic():
services.audit(request.user,'BANK_DELETED',account,account.snapshot())
account.delete()
except ProtectedError:
messages.error(request,'این حساب سابقه مالی دارد؛ به جای حذف آن را غیرفعال کنید.')
return redirect('staff_payments:banks')
@financial_required(allow_payment_link=True)
def receipt_revise(request, public_id):
link = getattr(request, 'payment_access_link', None)
receipt=get_object_or_404(access.visible_payments(request, PaymentReceipt.objects.all()),public_id=public_id,customer=request.payment_customer,status='REJECTED')
import jdatetime
current = receipt.revisions.get(version=receipt.current_version)
paid = jdatetime.date.fromgregorian(date=current.paid_date)
initial={'receipt_mode':'linked' if receipt.notice_id else 'independent','notice_id':receipt.notice_id,
'paid_amount':str(current.amount),'destination_account':current.destination_id,'deposit_id':current.deposit_id,
'paid_day':str(paid.day),'paid_month':str(paid.month),'paid_year':str(paid.year),
'origin_bank':current.origin_bank_id,'origin_other':current.origin_other,
'tracking_number':current.tracking_number,'description':current.description}
form=ReceiptForm(request.POST or None,request.FILES or None,customer=request.payment_customer,initial=initial,
notice_ids=[link.notice_id] if link else None)
if request.method=='POST' and form.is_valid():
try:
services.revise_receipt(request.payment_customer,receipt.public_id,int(request.POST.get('version','0')),form.cleaned_data)
messages.success(request,'نسخه اصلاحی ثبت شد و دوباره بررسی می‌شود.')
return redirect('payments:receipt_detail',public_id=receipt.public_id)
except (ValidationError,ValueError) as error:
form.add_error(None,error if isinstance(error,ValidationError) else 'نسخه فیش معتبر نیست.')
from apps.attachments.models import AttachmentPolicy
policy=AttachmentPolicy.objects.filter(attachment_type='IMAGE',is_enabled=True).first()
notice_data=[selectors.notice_form_data(n) for n in selectors.recent_pending_notice_choices(form, retain_original=receipt.notice_id)]
return render(request,'payments/receipt_form.html',{'form':form,'notice_data':notice_data,'max_bytes':policy.max_file_size if policy else 0,
'payment_link_access': link,
'account_requirements':{str(a.pk):a.requires_deposit_id for a in form.fields['destination_account'].queryset},
'financial_page':True,'revision_version':receipt.current_version})
+9
View File
@@ -264,3 +264,12 @@ class UnitSMSTemplateForm(forms.ModelForm):
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
self.fields["unit"].queryset = OrganizationalUnit.objects.filter(pk__in=unit_ids if unit_ids is not None else TicketPolicy.unit_ids(user), is_active=True) self.fields["unit"].queryset = OrganizationalUnit.objects.filter(pk__in=unit_ids if unit_ids is not None else TicketPolicy.unit_ids(user), is_active=True)
self.fields["unit"].required = True self.fields["unit"].required = True
def clean(self):
data = super().clean()
if data.get('event_type') in {'PAYMENT_NOTICE_CREATED', 'PAYMENT_RECEIPT_APPROVED'}:
from apps.payments.notifications import TAGS
self.instance.allowed_tags = list(TAGS)
elif data.get('event_type'):
self.instance.allowed_tags = SMSTemplate._meta.get_field('allowed_tags').default()
return data
+8 -2
View File
@@ -444,6 +444,9 @@ def staff_customer_search(request):
@staff_required @staff_required
@never_cache @never_cache
def staff_detail(request,public_id): def staff_detail(request,public_id):
from apps.payments import policies as payment_policies, selectors as payment_selectors
from apps.payments.models import PaymentNotice
ticket=get_object_or_404(Ticket.objects.select_related("customer__customer_profile","category","owner__staff_profile","selected_expert__staff_profile","group_dispatch__unit","group_dispatch__contact_group"),public_id=public_id) ticket=get_object_or_404(Ticket.objects.select_related("customer__customer_profile","category","owner__staff_profile","selected_expert__staff_profile","group_dispatch__unit","group_dispatch__contact_group"),public_id=public_id)
visible=visible_messages(ticket,request.user) visible=visible_messages(ticket,request.user)
conversation,has_older,older_cursor=conversation_page(visible.select_related("author").prefetch_related("attachments","read_receipts__user__staff_profile","read_receipts__user__customer_profile")) conversation,has_older,older_cursor=conversation_page(visible.select_related("author").prefetch_related("attachments","read_receipts__user__staff_profile","read_receipts__user__customer_profile"))
@@ -466,7 +469,10 @@ def staff_detail(request,public_id):
group_dispatch = ticket.group_dispatch group_dispatch = ticket.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"))) 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 group_open_count = group_dispatch.tickets.exclude(status__in=(Ticket.Status.RESOLVED, Ticket.Status.CLOSED)).count() if group_dispatch else 0
return render(request,"tickets/staff_detail.html",{"ticket":ticket,"conversation":conversation,"has_older":has_older,"older_cursor":older_cursor,"form":StaffReplyForm(),"transfer_form":TransferForm(),"share_form":StaffTicketShareForm(actor=request.user),"can_claim":TicketPolicy.can_claim(request.user,ticket),"can_transfer":TicketPolicy.can_transfer(request.user,ticket),"can_share":TicketPolicy.can_share(request.user,ticket),"can_delete":TicketPolicy.can_delete(request.user,ticket),"can_resolve_dispatch":can_resolve_dispatch,"group_open_count":group_open_count,"statuses":Ticket.Status.choices,"priorities":Ticket.Priority.choices,"grants":ticket.access_grants.filter(revoked_at__isnull=True).select_related("user__staff_profile"),"customer_history":history,"sms_jobs":sms_jobs,"announcement_jobs":announcement_jobs,"can_request_announcement":can_request_announcement,"announcement_ready":announcement_ready,"viewer_user":request.user,"voip_call_methods":_voip_call_methods(request.user),"poll_version":conversation_version(ticket,latest),"poll_cursor":latest.public_id if latest else "",**message_sharing,**sharing_context(ticket,request.user)}) customer_notices = payment_selectors.notices_with_totals(
payment_policies.visible(PaymentNotice.objects.filter(customer=ticket.customer), request.user)
).order_by('-created_at', '-pk')[:3]
return render(request,"tickets/staff_detail.html",{"ticket":ticket,"conversation":conversation,"has_older":has_older,"older_cursor":older_cursor,"form":StaffReplyForm(),"transfer_form":TransferForm(),"share_form":StaffTicketShareForm(actor=request.user),"can_claim":TicketPolicy.can_claim(request.user,ticket),"can_transfer":TicketPolicy.can_transfer(request.user,ticket),"can_share":TicketPolicy.can_share(request.user,ticket),"can_delete":TicketPolicy.can_delete(request.user,ticket),"can_resolve_dispatch":can_resolve_dispatch,"group_open_count":group_open_count,"statuses":Ticket.Status.choices,"priorities":Ticket.Priority.choices,"grants":ticket.access_grants.filter(revoked_at__isnull=True).select_related("user__staff_profile"),"customer_history":history,"customer_notices":customer_notices,"sms_jobs":sms_jobs,"announcement_jobs":announcement_jobs,"can_request_announcement":can_request_announcement,"announcement_ready":announcement_ready,"viewer_user":request.user,"voip_call_methods":_voip_call_methods(request.user),"poll_version":conversation_version(ticket,latest),"poll_cursor":latest.public_id if latest else "",**message_sharing,**sharing_context(ticket,request.user)})
@staff_required @staff_required
@@ -1021,4 +1027,4 @@ def staff_sms_templates(request, template_id=None):
raise PermissionDenied raise PermissionDenied
form.save(); messages.success(request, "الگوی پیامک واحد ذخیره شد.") form.save(); messages.success(request, "الگوی پیامک واحد ذخیره شد.")
return redirect("staff_tickets:sms_templates") return redirect("staff_tickets:sms_templates")
return render(request, "tickets/staff_sms_templates.html", {"form": form, "templates": templates, "allowed_tags": SMSTemplate._meta.get_field("allowed_tags").default(), "active_staff_section": "templates", "active_tab_title": "الگوهای پیامک"}) return render(request, "tickets/staff_sms_templates.html", {"form": form, "templates": templates, "allowed_tags": SMSTemplate.tags_for_event(form['event_type'].value()), "sms_tag_map": {event: SMSTemplate.tags_for_event(event) for event in SMSTemplate.Event.values}, "active_staff_section": "templates", "active_tab_title": "الگوهای پیامک"})
+5 -4
View File
@@ -21,20 +21,20 @@ DEBUG = env_bool("DJANGO_DEBUG")
ALLOWED_HOSTS = list(dict.fromkeys(env_list("DJANGO_ALLOWED_HOSTS") + ["localhost", "127.0.0.1"])) ALLOWED_HOSTS = list(dict.fromkeys(env_list("DJANGO_ALLOWED_HOSTS") + ["localhost", "127.0.0.1"]))
INSTALLED_APPS = [ INSTALLED_APPS = [
"django.contrib.admin", "django.contrib.auth", "django.contrib.contenttypes", "django.contrib.admin", "django.contrib.auth", "django.contrib.contenttypes",
"django.contrib.sessions", "django.contrib.messages", "django.contrib.staticfiles", "django.contrib.sessions", "django.contrib.messages", "django.contrib.staticfiles", "django.contrib.humanize",
"apps.core", "apps.accounts", "apps.customers", "apps.staff", "apps.tickets", "apps.core", "apps.accounts", "apps.customers", "apps.staff", "apps.tickets",
"apps.messaging", "apps.attachments", "apps.notifications", "apps.phone_contacts_api", "apps.messaging", "apps.attachments", "apps.notifications", "apps.phone_contacts_api", "apps.payments",
] ]
MIDDLEWARE = [ MIDDLEWARE = [
"django.middleware.security.SecurityMiddleware", "django.contrib.sessions.middleware.SessionMiddleware", "django.middleware.security.SecurityMiddleware", "django.contrib.sessions.middleware.SessionMiddleware",
"django.middleware.locale.LocaleMiddleware", "django.middleware.common.CommonMiddleware", "django.middleware.locale.LocaleMiddleware", "django.middleware.common.CommonMiddleware",
"django.middleware.csrf.CsrfViewMiddleware", "django.contrib.auth.middleware.AuthenticationMiddleware", "django.middleware.csrf.CsrfViewMiddleware", "django.contrib.auth.middleware.AuthenticationMiddleware",
"django.contrib.messages.middleware.MessageMiddleware", "apps.core.middleware.NoStoreHTMLMiddleware", "django.contrib.messages.middleware.MessageMiddleware", "apps.payments.middleware.FinancialGrantAuditMiddleware", "apps.core.middleware.NoStoreHTMLMiddleware",
"django.middleware.clickjacking.XFrameOptionsMiddleware", "django.middleware.clickjacking.XFrameOptionsMiddleware",
] ]
ROOT_URLCONF = "config.urls" ROOT_URLCONF = "config.urls"
TEMPLATES = [{"BACKEND": "django.template.backends.django.DjangoTemplates", "DIRS": [BASE_DIR / "templates"], "APP_DIRS": True, TEMPLATES = [{"BACKEND": "django.template.backends.django.DjangoTemplates", "DIRS": [BASE_DIR / "templates"], "APP_DIRS": True,
"OPTIONS": {"context_processors": ["django.template.context_processors.request", "django.contrib.auth.context_processors.auth", "django.contrib.messages.context_processors.messages", "apps.core.context_processors.static_asset_version", "apps.core.context_processors.site_branding", "apps.core.context_processors.staff_operations_access"]}}] "OPTIONS": {"context_processors": ["django.template.context_processors.request", "django.contrib.auth.context_processors.auth", "django.contrib.messages.context_processors.messages", "apps.core.context_processors.static_asset_version", "apps.core.context_processors.site_branding", "apps.core.context_processors.staff_operations_access", "apps.payments.context_processors.payment_navigation"]}}]
WSGI_APPLICATION = "config.wsgi.application" WSGI_APPLICATION = "config.wsgi.application"
ASGI_APPLICATION = "config.asgi.application" ASGI_APPLICATION = "config.asgi.application"
database_url = os.getenv("DATABASE_URL", "").strip() database_url = os.getenv("DATABASE_URL", "").strip()
@@ -95,6 +95,7 @@ OTP_RATE_LIMIT_PER_IP = int(os.getenv("OTP_RATE_LIMIT_PER_IP", "30"))
DEV_OTP_MOBILE = "" DEV_OTP_MOBILE = ""
DEV_OTP_CODE = "" DEV_OTP_CODE = ""
MAGIC_LINK_TTL_DAYS = int(os.getenv("MAGIC_LINK_TTL_DAYS", "7")) MAGIC_LINK_TTL_DAYS = int(os.getenv("MAGIC_LINK_TTL_DAYS", "7"))
PAYMENT_LINK_TTL_DAYS = int(os.getenv("PAYMENT_LINK_TTL_DAYS", str(MAGIC_LINK_TTL_DAYS)))
MAX_NOTIFICATION_ATTEMPTS = int(os.getenv("MAX_NOTIFICATION_ATTEMPTS", "5")) MAX_NOTIFICATION_ATTEMPTS = int(os.getenv("MAX_NOTIFICATION_ATTEMPTS", "5"))
SMS_MAX_RETRIES = int(os.getenv("SMS_MAX_RETRIES", "3")) SMS_MAX_RETRIES = int(os.getenv("SMS_MAX_RETRIES", "3"))
SMS_RETRY_DELAY_SECONDS = int(os.getenv("SMS_RETRY_DELAY_SECONDS", "180")) SMS_RETRY_DELAY_SECONDS = int(os.getenv("SMS_RETRY_DELAY_SECONDS", "180"))
+12 -2
View File
@@ -39,7 +39,7 @@ def web_manifest(request):
icon_url = static_url("images/pwa-icon.png") icon_url = static_url("images/pwa-icon.png")
response = JsonResponse({ response = JsonResponse({
"id": "/", "name": branding.pwa_name, "short_name": branding.pwa_title, "id": "/", "name": branding.pwa_name, "short_name": branding.pwa_title,
"description": branding.pwa_description, "start_url": "/tickets/", "scope": "/", "description": branding.pwa_description, "start_url": "/", "scope": "/",
"display": "standalone", "background_color": "#eeeeee", "theme_color": "#08376b", "display": "standalone", "background_color": "#eeeeee", "theme_color": "#08376b",
"lang": "fa", "dir": "rtl", "lang": "fa", "dir": "rtl",
"icons": [{"src": icon_url, "sizes": "1024x1024", "type": "image/png", "purpose": "any maskable"}], "icons": [{"src": icon_url, "sizes": "1024x1024", "type": "image/png", "purpose": "any maskable"}],
@@ -55,11 +55,21 @@ urlpatterns = [
path("x5admin/", admin.site.urls), path("x5admin/", admin.site.urls),
path("", include("apps.accounts.urls")), path("", include("apps.accounts.urls")),
path("tickets/", include("apps.tickets.customer_urls")), path("tickets/", include("apps.tickets.customer_urls")),
path("chaharsu/", include("apps.payments.urls")),
path("staff/payments/", include("apps.payments.staff_urls")),
path("staff/", include("apps.tickets.staff_urls")), path("staff/", include("apps.tickets.staff_urls")),
path("messages/", include("apps.messaging.urls")), path("messages/", include("apps.messaging.urls")),
path("files/", include("apps.attachments.urls")), path("files/", include("apps.attachments.urls")),
path("api/phone-contacts", include("apps.phone_contacts_api.urls")), path("api/phone-contacts", include("apps.phone_contacts_api.urls")),
] ]
def debug_media(request, path):
import posixpath
if posixpath.normpath(path.replace("\\", "/")).lstrip("/").startswith("payments/"):
from django.http import Http404
raise Http404
return serve(request, path, document_root=settings.MEDIA_ROOT)
if settings.DEBUG: if settings.DEBUG:
urlpatterns += static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT) urlpatterns += [path("media/<path:path>", debug_media)]
+166
View File
@@ -0,0 +1,166 @@
# چهارسو — نمونهٔ مستقل با تمرکز بر خدمات
فایل `index.html` را مستقیم در Chrome یا Edge باز کنید. اجرای Django، ورود به حساب و اتصال به دیتابیس لازم نیست.
## راهنمای طراحی‌های بعدی
[راهنمای سبک طراحی چهارسو](STYLE_GUIDE.md) شامل توکن‌ها، تایپوگرافی، فاصله‌ها، آیکون‌ها، اجزا، مثال‌ها و موارد نیازمند یکدست‌سازی است. برای طراحی جدید این سند را مبنا قرار دهید؛ توالی تصمیم‌های قدیمی README جایگزین آن نیست.
## نسخهٔ اصلاح‌شده
صفحهٔ اول فقط برای شناخت و انتخاب خدمات است:
- عنوان کوتاه «خدمات چهارسو».
- سه کارت خدمات: «ارتباط با کارشناسان»، «ارسال فیش واریزی» و «شماره حساب‌ها». کارت ارتباط با کارشناسان دو اقدام مستقل و مشخص دارد: ثبت درخواست جدید و مشاهده درخواست‌های من.
- هیچ فهرست درخواست، آمار درخواست‌ها، بخش «پیگیری‌ها» یا یادآوری تکمیل پروفایل در خانه نمایش داده نمی‌شود.
- بنر بزرگ و خوشامدگویی جدا حذف شده‌اند تا خدمات بالاتر دیده شوند.
- نوار پایین به سه مقصد «خدمات، درخواست‌ها، پروفایل» محدود شده؛ خانه و خدمات یک مقصد هستند.
- «درخواست‌های من» نمای جدا در همین نمونه دارد؛ انتخاب کارت یا منو آن را باز می‌کند. بازگشت به خدمات و دکمه‌های Back/Forward مرورگر کار می‌کنند.
- وضعیت خدمات مالی آینده با «به‌زودی» مشخص است؛ کلیک روی آن‌ها به دو فایل HTML مستقل می‌رود؛ هیچ پنجرهٔ modal برای خدمات مالی باز نمی‌شود.
نام چهارسو محدودیتی برای تعداد خدمات ایجاد نمی‌کند. خدمات بعدی می‌توانند به شبکهٔ کارت‌ها اضافه شوند؛ برای هر خدمت جدید نیازی به افزودن گزینه به نوار پایین نیست.
## فایل‌های بازبینی
- `index.html`: نمونهٔ تعاملی خانه
- `payment-receipt.html`: صفحهٔ مستقل ارسال فیش واریزی
- `bank-accounts.html`: صفحهٔ مستقل شماره حساب‌ها
- `service-pages.css`: سبک‌های مشترک دو صفحهٔ خدمات مالی
- `receipt-mobile.png` و `accounts-mobile.png`: تصاویر موبایل صفحات مالی
- `mobile.png`: صفحهٔ نخست موبایل
- `desktop.png`: صفحهٔ نخست دسکتاپ
- `requests-mobile.png`: نمای مستقل درخواست‌ها
نمای جدا در نمونه با `index.html#requests` نیز باز می‌شود. این یک مسیر اجرایی جدید در Django نیست.
## مبنای بررسی معماری
در مرحلهٔ نخست، Graphify برای معماری و Ripwire برای تطبیق با سورس فعلی استفاده شد. گراف موجود مربوط به گزارش ۲۰۲۶/۱۰/۰۴ و commit `39e64854` بود؛ تصمیم‌های طراحی با سورس فعلی تطبیق داده شدند. گراف بازسازی یا تغییر داده نشد.
- پروژه Django Modular Monolith است. حساب‌ها، مشتریان، کارکنان، درخواست‌ها، پیام‌ها، پیوست‌ها، اعلان‌ها و core مسئولیت‌های جدا دارند.
- مسیر `/tickets/` به `customer_list` می‌رسد و مخصوص مشتری است. `customer_tickets` درخواست‌های همان مشتری را برمی‌گرداند.
- صفحه فعلی از `templates/base.html` ارث می‌برد. برند و متن‌های معرفی با SiteBranding و عنوان‌ها با PageTitle تنظیم می‌شوند.
- پوسته فارسی و RTL است و از وزیرمتن، Tailwind 4، HTMX و Alpine استفاده می‌کند. Compressor نیز در پروژه موجود است.
- شماره حساب، شناسه واریز و ثبت فیش در مسیرهای فعلی مشتری وجود ندارند؛ خدمات مالی در این طراحی امکانات آینده‌اند.
- این نمونه برند را از فایل لوگوی محلی نمایش می‌دهد و متن‌های پیشنهادی دارد؛ دیتابیس را نمی‌خواند.
منابع: `config/settings/base.py`، `config/urls.py`، `apps/tickets/customer_urls.py`، `apps/tickets/views.py:customer_list`، `apps/tickets/selectors.py:customer_tickets`، `apps/core/context_processors.py`، `templates/base.html`، `templates/tickets/customer_list.html`، `static/src/tailwind.css`، `package.json` و `docs/THEMING.md`.
## منطق UI/UX
خانه یک منوی خدمات قابل‌فهم است. کاربر پیش از دیدن سوابق، خدمت موردنیاز را انتخاب می‌کند. کارت مشترک «ارتباط با کارشناسان» سرمه‌ای با تأکید نارنجی است. عنوان مشترک و دو اقدام کوتاه «ثبت درخواست» و «درخواست‌های من» کاربردهای این خدمت را روشن می‌کنند؛ جملهٔ کامل راهنمای پرسش، درخواست، پیگیری و هماهنگی در موبایل و دسکتاپ نمایش داده می‌شود. ثبت درخواست آبی و مشاهده سوابق با حاشیهٔ روشن نمایش داده می‌شود. خود کارت مقصد کلیک ندارد تا انتخاب کاربر مبهم نشود. سایر کارت‌ها سفیدند؛ خدمات آینده برچسب متنی دارند و توضیحشان خوانا است.
سوابق فقط پس از انتخاب «درخواست‌های من» دیده می‌شوند. جست‌وجو، فیلتر «همه / در جریان / بسته» و نمایش همه، پیشنهادهای این نمونه‌اند؛ آمار نمایشی نباید به حساب واقعی نسبت داده شود.
در موبایل کارت مشترک ارتباط با کارشناسان تمام عرض را می‌گیرد و دو اقدام آن کنار هم هستند؛ خدمات مالی در ردیف بعدی دو ستون دارند. در دسکتاپ کارت مشترک دو ستون از شبکهٔ چهارستونه را می‌گیرد و دو اقدام آن کنار هم هستند.
## وفاداری و جداسازی
- فونت وزیرمتن، لوگو، فایل CSS کامپایل‌شده Tailwind و رنگ‌های فعلی پروژه استفاده می‌شوند.
- سرمه‌ای `#08376b`، آبی `#0693e3`، نارنجی `#fa9f00`، زمینه `#eeeeee` و کارت‌های سفید حفظ شده‌اند.
- CSS و JavaScript خانه داخل HTML نمونه هستند؛ دو صفحهٔ مالی از `service-pages.css` مستقل در همین پوشه استفاده می‌کنند.
- URL دارایی‌ها نسخه دارد؛ مقدار نسخه هنگام ساخت بر اساس الگوریتم فعلی `static_asset_version`، یعنی بیشترین زمان تغییر فایل‌های static و templates، محاسبه شده است.
- هیچ وابستگی، فونت یا CDN جدید اضافه نشده و build اجرا نشده است.
- Service Worker ثبت نمی‌شود. کش و PWA برنامه اصلی تغییر نکرده‌اند.
- همهٔ فایل‌های نمونه در `design-prototypes/chaharsu/` هستند؛ فایل‌های فعلی templates، static و بک‌اند دست‌نخورده‌اند.
## تعامل‌های نمونه
ثبت درخواست لینک به نمونهٔ مستقل موجود فرم در `../ticket-new-wizard/index.html` دارد. درخواست‌های نمونه پنجره توضیح دارند؛ مکالمه واقعی باز نمی‌شود. پروفایل پنجره توضیح دارد. «ارسال فیش واریزی» و «شماره حساب‌ها» با لینک معمولی به صفحات HTML مستقل می‌روند و هر دو صفحه لینک بازگشت به خانه و دسترسی به خدمت مالی دیگر دارند. انتخاب فیش فقط نام فایل را نشان می‌دهد؛ محتوای تصویر برای اعتبارسنجی در مرورگر خوانده می‌شود؛ بارگذاری یا ذخیره نمی‌شود. تمام داده‌ها نمایشی‌اند.
پنجره‌ها با Escape، دکمه بستن و کلیک بیرون بسته می‌شوند. dialog بومی مدیریت فوکوس را انجام می‌دهد. جابه‌جایی نما فوکوس را به عنوان آن می‌برد.
## بررسی نسخهٔ اصلاح‌شده
Playwright موجود در محیط مجازی پروژه برای بررسی مرورگر نمونه استفاده شد:
- خانه فقط خدمات را نشان می‌دهد و سوابق در آن مخفی‌اند.
- کارت مشترک و دو اقدام آن در موبایل ۳۹۰×۸۴۴ بررسی شدند؛ هر سه کارت خدمات بالاتر از نوار پایین قرار دارند.
- نمای جدا درخواست‌ها، جست‌وجو، فیلتر، حالت بدون نتیجه، نمایش همه و بازگشت به خدمات بررسی شدند.
- Back/Forward مرورگر، ناوبری واقعی به دو صفحهٔ مالی، بازگشت به خانه، انتخاب فایل بدون ارسال و حذف انتخاب بررسی شدند.
- در عرض‌های ۳۲۰، ۳۶۰، ۳۹۰، ۶۴۰، ۷۶۸، ۱۰۲۴ و ۱۴۴۰ اسکرول افقی مشاهده نشد.
- خطای JavaScript مشاهده نشد.
- تصاویر خانه و نمای درخواست‌ها به‌روز شدند.
این بررسی‌ها مخصوص نمونهٔ مستقل‌اند؛ تست یا کد اصلی Django تغییر نکرده است.
## قرارداد پیاده‌سازی صفحات مالی
در HTML خانه کنار لینک‌های دو خدمت و در ابتدای هر صفحهٔ مستقل کامنت IMPLEMENTATION NOTE درج شده است:
- برای ارسال فیش و شماره حساب‌ها باید URL نام‌گذاری‌شده، view و template مستقل در Django تعریف شود.
- این لینک‌ها نباید به modal یا data-dialog تبدیل شوند.
- مسیرهای فعلی فایل HTML فقط مسیرهای نمونه‌اند؛ نام مسیرهای نهایی Django هنوز تعیین نشده است.
- پوستهٔ نهایی باید از base.html ارث ببرد و دارایی‌ها با static_asset_version نسخه‌گذاری شوند.
- دکمهٔ ارسال فیش تا پیاده‌سازی بک‌اند، اعتبارسنجی و رسید ثبت غیرفعال است. انتخاب فایل به معنی ثبت پرداخت نیست.
- حساب‌ها و شناسهٔ واریز باید از منبع معتبر بک‌اند بیایند؛ هیچ شمارهٔ واقعی یا ساختگی در نمونه درج نشده است.
متن کارت فیش: «تصویر فیش پرداخت خود را برای بررسی بفرستید.»
متن کارت شماره حساب‌ها: «شماره حساب، شبا و شناسهٔ واریز را دریافت کنید.»
## فشرده‌سازی برای گوشی کوچک
کارت ارتباط با کارشناسان عنوان، توضیح کامل و دو اقدام کنار هم دارد. توضیح تکراری زیر دکمه‌ها حذف شده است. عنوان خانه، فاصله‌ها و متن خدمات مالی نیز کوتاه‌تر شده‌اند؛ هدف دیده شدن همهٔ خدمات پیش از نوار پایین در صفحهٔ نخست گوشی‌های کوچک است. دکمه‌ها حداقل ۴۴ پیکسل ارتفاع دارند.
بررسی نسخهٔ فشرده: در اندازه‌های ۳۲۰×۵۶۸، ۳۶۰×۶۴۰، ۳۷۵×۶۶۷، ۳۹۰×۸۴۴ و ۴۱۲×۷۳۲ هر سه کارت به‌طور کامل، بدون اسکرول و بالاتر از نوار پایین دیده شدند. عرض‌های ۶۴۰، ۷۶۸، ۱۰۲۴ و ۱۴۴۰ نیز بدون سرریز افقی بررسی شدند. ارتفاع هر دو دکمهٔ درخواست حداقل ۴۴ پیکسل است. مسیر ثبت درخواست، سوابق و صفحات مستقل مالی پس از فشرده‌سازی مجدداً بررسی شدند. تصویر `mobile-small.png` مربوط به گوشی ۳۲۰×۵۶۸ است.
## عنوان خدمت و امضای توسعه‌دهنده
عنوان کارت مشترک «ارتباط با کارشناسان» است؛ ثبت درخواست و مشاهدهٔ درخواست‌های من دو مسیر داخل آن هستند. توضیح «سؤال بپرسید، درخواست ثبت کنید یا برای پیگیری و هماهنگی با کارشناسان در ارتباط باشید.» در تمام اندازه‌ها، از جمله موبایل، نمایش داده می‌شود تا سایر خدمات همچنان در نگاه اول دیده شوند. امضای پایین هر سه صفحه: «طراحی و توسعه: محمد جواد فصاحت 09202202129». شماره لینک تماس دارد؛ هیچ تماس خودکاری برقرار نمی‌شود. کامنت HTML یادآوری می‌کند که در نسخهٔ اصلی امضا از تنظیم developer_credit موجود در برند تأمین شود.
## خوانایی در آخرین نسخه
عنوان کارت «ارتباط با کارشناسان» است و جملهٔ «سؤال بپرسید، درخواست ثبت کنید یا برای پیگیری و هماهنگی با کارشناسان در ارتباط باشید.» در همهٔ اندازه‌ها دیده می‌شود. توضیحات و دکمه‌ها حداقل ۱۴ پیکسل‌اند، عنوان کارت در موبایل ۱۸ و در دسکتاپ ۲۰ پیکسل است، و برچسب‌های وضعیت، ناوبری و امضا ۱۲ پیکسل‌اند. خط توضیح با ضریب ۱٫۶۵ یا بیشتر تنظیم شده است. روی موبایل متن کارت‌های مالی کوتاه‌تر است و اقدام تکراری انتهای کارت نمایش داده نمی‌شود؛ خود کارت همچنان لینک صفحهٔ مستقل است. در عرض کمتر از ۳۶۰ پیکسل آیکون دکمه‌ها حذف می‌شود تا متن با همان اندازه خوانا جا بگیرد. اندازه‌های ۳۲۰×۵۶۸ تا دسکتاپ بررسی شدند: متن کامل نمایش داده شد، هیچ عنوانی سرریز نداشت و تمام خدمات بالاتر از نوار پایین موبایل قرار داشتند.
## هماهنگی خوانایی صفحات مالی
هر دو صفحهٔ شماره حساب‌ها و ارسال فیش به همان الگوی خوانایی خانه ارتقا یافته‌اند: متن‌ها، اطلاعات حساب و اقدام‌ها حداقل ۱۴ پیکسل؛ عنوان بخش‌ها ۱۸ پیکسل در موبایل و ۲۰ پیکسل در دسکتاپ؛ عنوان صفحه ۲۴/۲۸ پیکسل؛ برچسب‌ها و امضا ۱۲ پیکسل. در موبایل فاصله‌ها کمتر و متن‌های تکراری حذف شده‌اند؛ متن مهم پنهان نشده است. توضیح اصلی تمام عرض را می‌گیرد تا به ستون باریک کنار آیکون محدود نشود. اقدام‌های فعال و انتخاب فایل حداقل ۴۴ پیکسل ارتفاع دارند. اندازه و خوانایی متن با فشرده‌سازی فضا قربانی نشده است.
بررسی صفحات مالی: هر دو صفحه در عرض‌های ۳۲۰، ۳۶۰، ۳۷۵، ۳۹۰، ۶۴۰، ۷۶۸ و ۱۴۴۰ پیکسل، بدون اسکرول افقی یا بریدگی متن بررسی شدند. اندازهٔ متن‌های اصلی حداقل ۱۴ پیکسل و ارتفاع لینک‌های اصلی و انتخاب فایل حداقل ۴۴ پیکسل بود. انتخاب فایل، نام بسیار بلند فایل، حذف انتخاب، غیرفعال‌بودن ارسال و لینک‌های بین دو خدمت و خانه بررسی شدند؛ خطای JavaScript دیده نشد. تصاویر `accounts-small.png` و `receipt-small.png` برای گوشی ۳۲۰×۵۶۸ و تصاویر `accounts-desktop.png` و `receipt-desktop.png` برای دسکتاپ اضافه شدند. صفحات جزئیات مالی به اندازهٔ محتوایشان اسکرول عمودی طبیعی دارند؛ شرط دیده‌شدن همهٔ خدمات در نگاه اول همچنان مربوط به خانهٔ خدمات است.
## یکدست‌سازی نمای درخواست‌ها
نمای درخواست‌ها از نوار پایین و کارت کارشناسان به یک مقصد می‌رسد. عنوان ردیف‌ها در موبایل ۱۴ و دسکتاپ ۱۶ پیکسل، شماره درخواست ۱۴، وضعیت و اطلاعات ثانویه ۱۲ پیکسل‌اند. فیلترها، اقدام‌های اصلی و جست‌وجو حداقل ۴۴ پیکسل ارتفاع دارند. جست‌وجو ۱۶ پیکسل است. در موبایل موضوع و اطلاعات در خط بالایی و وضعیت/شماره در ردیف بعد قرار می‌گیرند و امکان wrapping حفظ می‌شود. حالت فعال «درخواست‌ها» در نوار پایین سرمه‌ای روشن‌تر است تا سفید روی آن با قواعد کنتراست راهنما هماهنگ باشد. پنجرهٔ جزئیات نمونهٔ درخواست هم متن اصلی ۱۴ پیکسل دارد.
## جریان اعلامیهٔ واریز و فیش مرتبط
نسخهٔ جدید `bank-accounts.html` فهرست عمومی حساب‌های شرکت نیست؛ فقط اعلامیه‌های واریزی را نشان می‌دهد که کارشناس برای همین مشتری فرستاده است. ابتدا موضوع، مبلغ، فاکتور، تاریخ و وضعیت فیش نمایش داده می‌شود. با باز کردن هر کارت، بانک، صاحب حساب، شماره حساب، شبا، کارت، شناسه، مبلغ، فاکتور و توضیحات همان اعلامیه دیده می‌شود. فقط مبلغ، شماره حساب، شبا، شماره کارت و شناسهٔ واریز موجود دکمهٔ کپی تکی دارند؛ مبلغ بدون جداکننده و بدون واحد کپی می‌شود. کپی کامل متن چندخطی شامل همهٔ اطلاعات و واحد ریال است. مقادیر اعلام‌نشده کپی ندارند. بازخورد کپی به موفقیت واقعی وابسته است؛ در صورت منع Clipboard، روش انتخاب دستی متن در پنجرهٔ دسترس‌پذیر ارائه می‌شود.
`payment-demo-data.js` فقط سه fixture ساختگی با حساب‌های نامعتبر دارد؛ دیتابیس نیست. `payment-services.js` رفتار محلی هر دو صفحه را مدیریت می‌کند؛ هیچ ارسال، ذخیره یا وابستگی شبکه‌ای ندارد. دادهٔ واقعی باید از دیتابیس تأمین و به مشتری مخاطب محدود شود. پنهان بودن جزئیات کارت جای کنترل دسترسی سمت سرور را نمی‌گیرد.
«ارسال فیش این واریز» فقط شناسهٔ اعلامیه را با پارامتر `notice` به فرم می‌برد. فرم همان مورد را انتخاب می‌کند و مبلغ مورد انتظار، فاکتور، بانک و شناسه را نشان می‌دهد؛ لینک بازگشت، جزئیات همان مورد را باز می‌کند. ورود مستقیم به فرم حالت مرتبط را به‌عنوان گزینهٔ اول و پیشنهادی نشان می‌دهد؛ مستقل همیشه قابل انتخاب است. انتخاب مرتبط بدون مورد، پیام راهنما دارد؛ شناسهٔ نامعتبر با خطای روشن نمایش داده می‌شود و هرگز به اعلامیهٔ دیگری وصل نمی‌شود. تغییر به مستقل، شناسهٔ ارتباط را از فرم و URL حذف می‌کند. مبلغ پرداخت‌شدهٔ مشتری و فایل انتخاب‌شده هنگام تغییر نوع/اعلامیه حفظ می‌شوند. هیچ موفقیت ارسال ساختگی نشان داده نمی‌شود؛ دکمهٔ نمونه برای بررسی کلاینت فعال است؛ ارسال واقعی تا پیاده‌سازی اصلی انجام نمی‌شود.
شماره‌ها و مبلغ می‌توانند در هر اعلامیه متفاوت باشند. پنل کارشناس، مدل داده، API، مجوزها و ذخیرهٔ فیش در محدودهٔ این طراحی نیستند. یادآوری قراردادهای ضروری در کامنت HTML هر دو صفحه آمده است.
بررسی نسخهٔ اعلامیه‌ها: هفت عرض ۳۲۰، ۳۶۰، ۳۹۰، ۶۴۰، ۷۶۸، ۱۰۲۴ و ۱۴۴۰، کپی واقعی شبا/مبلغ/متن کامل، باز و بسته شدن کارت‌ها، جست‌وجو، نبود شماره کارت، اتصال دوطرفهٔ اعلامیه و فرم، حذف ارتباط با انتخاب مستقل، حفظ مبلغ/فایل، شناسهٔ نامعتبر، انتخاب دستی هنگام منع Clipboard و Escape بررسی شدند. خطای JavaScript یا درخواست HTTP خارجی مشاهده نشد. `accounts-list-mobile.png` حالت اولیهٔ بسته و تصاویر `accounts-*.png` جزئیات باز را نشان می‌دهند. این بررسی جای تست اتصال واقعی سرور یا تأیید کامل WCAG را نمی‌گیرد.
## فشرده‌سازی کارت‌ها و اولویت واریز مرتبط
بانک و صاحب حساب خلاصه‌اند؛ فاکتور و توضیحات در «اطلاعات تکمیلی و توضیحات» همیشه در کارت بازشده دیده می‌شوند. کپی تکی فقط برای پنج نوع مقدار پرداخت موجود است. کپی کامل از تمام داده‌های اعلامیه ساخته می‌شود و بسته بودن بخش تکمیلی بر آن اثر ندارد؛ کامنت اجرای اصلی در HTML و JavaScript ثبت شده است. راهنمای کوتاه درخواست شماره حساب و شناسه از کارشناس در بالای فهرست وجود دارد. در فرم فیش، مرتبط اولین گزینه و پیش‌فرض پیشنهادی است؛ راهنمای غیر اجباری ایجاد اعلامیه همیشه دیده می‌شود. مستقل همچنان قابل انتخاب است.
## اطلاعات تکمیلی ثابت و تاریخ شمسی فیش
اطلاعات تکمیلی و توضیحات اکنون بخش ثابت داخل کارت بازشده هستند؛ نیازی به باز کردن بخش دوم نیست و کاربر نمی‌تواند آن بخش را جمع کند. کپی کامل همچنان از تمام داده‌ها تولید می‌شود.
`receipt-date.js` انتخابگر تاریخ شمسی با سه select استاندارد روز/ماه/سال است؛ کتابخانه یا فایل خارجی ندارد. ماه و سال جاری برای سهولت انتخاب آماده‌اند، ولی روز ابتدا خالی است تا تاریخ واقعی فیش با تأیید کاربر ثبت شود. «امروز» بر اساس منطقهٔ زمانی ایران تاریخ کامل را انتخاب می‌کند. طول ماه‌ها و اسفند با تقویم Persian در Intl مرورگر بررسی می‌شوند؛ تغییر به ماه/سال کوتاه‌تر، روز نامعتبر را پاک و راهنمای انتخاب دوباره را اعلام می‌کند. تاریخ انتخاب‌شده هنگام تغییر نوع فیش یا اعلامیه حفظ می‌شود. در نمونه سال‌های ده سال قبل تا یک سال بعد قابل انتخاب‌اند؛ بازهٔ نسخهٔ اصلی باید با نیاز واقعی تعیین شود.
مقدار قابل ارسال `paid_date` تاریخ میلادی `YYYY-MM-DD` با ارقام ASCII است؛ انتخابگر همچنان شمسی نمایش داده می‌شود. قرارداد اعتبارسنجی و تبدیل با ابزار جلالی موجود پروژه در کامنت HTML آمده است؛ در نمونه هیچ تاریخ یا فایلی ارسال/ذخیره نمی‌شود.
بررسی تاریخ: طول ۱۴۴ ماه در تمام سال‌های قابل انتخاب با jdatetime موجود پروژه تطبیق داده شد؛ اسفند کبیسه، حذف روز نامعتبر، دکمهٔ امروز با ساعت ایران، حفظ تاریخ بین حالت‌ها و خوانایی کنترل‌ها در هفت اندازهٔ نمایش بررسی شدند. تصاویر مالی به‌روز شدند.
## اعتبارسنجی کلاینت فیش
`receipt-validation.js` فرم را بدون ارسال شبکه‌ای بررسی می‌کند. مبلغ صحیح مثبت ریال (پذیرش ارقام فارسی/عربی و جداکنندهٔ معتبر)، روز/ماه/سال کامل، تاریخ غیرآینده و تصویر فیش الزامی‌اند. مبلغ آمادهٔ ارسال در `paid_amount` رشتهٔ عدد ASCII بدون جداکننده است؛ `paid_date` میلادی و `receipt_image` فایل است. فیش مرتبط به `notice_id` معتبر نیاز دارد؛ در حالت مستقل این select غیرفعال است و شناسه به FormData وارد نمی‌شود.
حد نمونهٔ تصویر مطابق پیش‌فرض IMAGE در bootstrap پروژه، ۱۰ مگابایت و JPG/JPEG/PNG/GIF/WEBP است. علاوه بر پسوند و MIME، امضای فایل و باز شدن تصویر در مرورگر بررسی می‌شوند؛ نتیجهٔ بررسی فایل در حافظه و فقط برای همان File نگهداری می‌شود. فایل خالی، بزرگ، خراب یا دارای نوع ناسازگار پیام مشخص دارد. محدودیت واقعی در نسخهٔ اصلی باید از AttachmentPolicy سرور دریافت شود، نه این عدد نمونه.
خطا زیر فیلد، aria-invalid و فهرست خطاهای لینک‌دار نمایش داده می‌شود. با بررسی ناموفق، اولین فیلد دارای خطا فوکوس می‌گیرد و به مرکز نمایش می‌آید؛ برای ورودی فایل پنهان، کنترل فارسی انتخاب تصویر در معرض دید قرار می‌گیرد. پس از اصلاح، خطا پاک می‌شود. ارسال دوباره حین بررسی کنترل می‌شود؛ اگر کاربر هنگام بررسی فایل فرم را تغییر دهد، نتیجهٔ قدیمی معتبر اعلام نمی‌شود. در حالت معتبر، متن صریح «این پیش‌نمایش است؛ فیش ارسال یا ذخیره نشده» نمایش داده می‌شود. اعتبارسنجی مرورگر جای اعتبارسنجی سرور نیست و کامنت قرارداد در HTML آمده است.
## نمایش مبلغ و انتخاب بانک‌ها
`receipt-amount.js` هنگام تایپ مبلغ ریالی، جداکنندهٔ هزارگان فارسی اضافه می‌کند. ارقام فارسی، عربی و انگلیسی پذیرفته می‌شوند؛ ورودی غیرعددی/اعشاری به عدد دیگری تبدیل نمی‌شود و اعتبارسنجی خطا می‌دهد. محل انتخاب و نشانگر بر اساس تعداد ارقام حفظ می‌شود؛ Backspace/Delete روی جداکننده گیر نمی‌کنند. `paid_amount` همچنان ریالی و بدون جداکننده است.
زیر مبلغ، معادل تومان به حروف نمایش داده می‌شود: ۲۸۰٬۰۰۰٬۰۰۰ ریال → «بیست و هشت میلیون تومان». محاسبه با BigInt و تقسیم/باقی‌ماندهٔ دقیق انجام می‌شود؛ مثلاً ۱۲۳۴۵ ریال → «هزار و دویست و سی و چهار و پنج دهم تومان»، بدون گرد کردن.
`receipt-banks.js` فهرست محلی بانک مبدأ با نام‌های شناخته‌شده، گروه نام‌های قدیمی روی فیش‌ها و «سایر» دارد. مبدأ اختیاری است؛ با سایر، نام بانک اختیاری نیز قابل ورود می‌شود. فهرست بانک‌ها در نسخهٔ اصلی باید به‌صورت متمرکز به‌روز نگه داشته شود.
حساب مقصد اجباری است و کاربر خودش انتخاب می‌کند؛ انتخاب اعلامیه یا نوع فیش مقصد را تغییر نمی‌دهد. فهرست مقصد فقط نام حساب‌های شرکت را نشان می‌دهد، نه شماره حساب/کارت/شبا. `chaharsuCompanyAccounts` در `payment-demo-data.js` صرفاً fixture با ID و نام است؛ در نسخهٔ اصلی این فهرست باید از دیتابیس حساب‌های فعال/مجاز شرکت بیاید. سرور ID انتخابی را دوباره اعتبارسنجی و نام حساب را از دیتابیس بخواند. این قرارداد در HTML و فایل داده کامنت شده است.
بررسی مرورگر: گروه‌بندی زنده و اصلاح وسط متن، حذف کنار جداکننده، جایگزینی انتخاب، ارقام عربی، صفرهای ابتدایی، معادل دقیق تومان، خطای مقصد و فوکوس، نام‌های بدون شماره، مبدأ اختیاری/سایر، حفظ انتخاب‌ها و FormData ریالی/میلادی بررسی شدند؛ هفت اندازهٔ نمایش بدون سرریز و هیچ منبع خارجی بودند.
+554
View File
@@ -0,0 +1,554 @@
# راهنمای سبک طراحی چهارسو
نسخه ۱ · تاریخ ۲۰۲۶/۱۰/۰۵ · برای طراحی صفحات جدید و بازبینی طرح‌های بعدی
این راهنما زبان بصری نمونهٔ چهارسو را استخراج می‌کند و قواعد استفادهٔ مجدد از آن را مشخص می‌کند. مبنا، **نمای نهایی در مرورگر** است؛ نه اولین قانون مشابه در CSS. خانه، شماره حساب‌ها، ارسال فیش، نمای سوابق و پنجره‌های نمونه بررسی شده‌اند.
**وضعیت این سند:** راهنمای طراحی است؛ هیچ تغییر اجرایی در صفحات یا کد اصلی پروژه اعمال نمی‌کند. موارد «پیشنهادی» هنوز در همهٔ اجزای نمونه پیاده نشده‌اند. این سند تأییدیهٔ دسترس‌پذیری کل برنامه نیست.
## ۱. هویت و اصول طراحی
سبک چهارسو فارسی، راست‌به‌چپ، روشن، ساده و مبتنی بر کارت خدمات است:
- زمینهٔ خاکستری روشن؛ کارت سفید؛ عنوان سرمه‌ای؛ آبی برای تعامل؛ نارنجی برای تأکید محدود.
- صفحهٔ نخست به انتخاب خدمت اختصاص دارد. سوابق و فرم‌های طولانی بعد از انتخاب خدمت نمایش داده می‌شوند.
- هر کارت باید با عنوان، آیکون و توضیح کوتاه قابل فهم باشد؛ رنگ یا آیکون به‌تنهایی حامل معنی نباشند.
- یک مسیر اصلی مشخص و مسیرهای فرعی خوانا داشته باشید.
- برای جمع‌وجور شدن موبایل ابتدا متن تکراری، تزئین و فاصلهٔ اضافی را حذف کنید؛ متن مهم را کوچک یا مخفی نکنید.
- فضای بیشتر دسکتاپ اجازهٔ توضیح و فاصلهٔ بیشتر می‌دهد؛ معنای خدمت در موبایل و دسکتاپ باید یکسان بماند.
### ساختار مرجع خانه
| خدمت | ساختار | رفتار |
|---|---|---|
| ارتباط با کارشناسان | کارت سرمه‌ای مشترک با دو اقدام داخلی | ثبت درخواست / درخواست‌های من |
| ارسال فیش واریزی | کارت سفید با آیکون فیش و وضعیت خدمت | رفتن به صفحهٔ مستقل |
| شماره حساب‌ها | کارت سفید با آیکون بانک و وضعیت خدمت | رفتن به صفحهٔ مستقل |
کارت مشترک کارشناسان خودش مقصد کلیک ندارد؛ دو کنترل داخل آن مقصد را مشخص می‌کنند. کارت‌های مالی لینک هستند. خدمات بعدی می‌توانند به شبکه اضافه شوند؛ نام چهارسو محدودیت چهارتایی ایجاد نمی‌کند.
## ۲. منابع و شیوهٔ استخراج
| منبع | کاربرد |
|---|---|
| [index.html](index.html) | خانه، کارت‌ها، ناوبری، SVGها، نمای سوابق، پنجره‌ها |
| [service-pages.css](service-pages.css) | سبک مشترک دو صفحهٔ مالی |
| [bank-accounts.html](bank-accounts.html) | اطلاعات حساب و شناسهٔ واریز |
| [payment-receipt.html](payment-receipt.html) | فرم فیش مستقل و مرتبط |
| [payment-services.js](payment-services.js) | باز شدن اعلامیه، کپی و ارتباط فرم |
| [payment-demo-data.js](payment-demo-data.js) | سه اعلامیهٔ ساختگی، بدون حساب واقعی |
| [منبع تم پروژه](../../static/src/tailwind.css) | توکن‌های رنگ، فونت و رفتار پایهٔ موجود |
| [توضیح تم](../../docs/THEMING.md) | نقش رنگ‌های برند |
| [README نمونه](README.md) | تصمیم‌های UX و مرز نمونه با برنامهٔ اصلی |
مقادیر اصلی با Playwright و `getComputedStyle` در عرض‌های ۳۲۰ و ۱۴۴۰ پیکسل استخراج شدند. بخش سوابق نیز در حالت فعال بررسی شد. قواعد responsive از سورس CSS تطبیق داده شدند. نسبت‌های کنتراست جدول این سند از رنگ‌های ثابت محاسبه شده‌اند.
در HTML خانه قوانین قدیمی بنر، یادآوری پروفایل و overrideهای مراحل قبلی باقی مانده‌اند. وجود یک selector به معنی استفاده شدن آن در طرح نهایی نیست. برای طراحی جدید، قواعد نهایی همین سند را مبنا قرار دهید و CSS انباشتهٔ نمونه را کامل کپی نکنید.
## ۳. رنگ‌ها و نقش معنایی آن‌ها
### پالت موجود
| نقش | توکن موجود / نام در نمونه | مقدار | کاربرد |
|---|---|---|---|
| متن و ساختار اصلی | `--color-ink` / `--ink` | `#08376b` | عنوان، کارت کارشناسان، ناوبری |
| سرمه‌ای روشن‌تر | `--color-ink-soft` | `#0b4c8c` | عمق و حالت تعاملی تیره |
| آبی برند | `--color-sea` / `--sea` | `#0693e3` | آیکون، سطح فعال، تأکید تعاملی |
| آبی تیره | `--color-sea-dark` / `--color-blue` | `#1977ce` | لینک، hover، سطح آیکون |
| تأکید نارنجی | `--color-coral` | `#fa9f00` | خط پایین کارت اصلی |
| زمینهٔ صفحه | `--color-cream` | `#eeeeee` | زمینهٔ عمومی |
| زمینهٔ فرعی | `--color-soft` | `#eaeaea` | سطح ثانویه |
| سطح کارت | `--color-paper` | `#ffffff` | کارت و فرم |
| آبی بسیار روشن | `--color-mint` | `#e5f4ff` | سطح آیکون، انتخاب فایل |
| زرد پشتیبان | `--color-sun` | `#ffc451` | پالت موجود؛ در طرح نهایی مصرف محدود |
| متن ثانویه | `--muted` در خانه | `#526c86` | توضیح، برچسب، امضا |
| مرز پایه | `--line` در خانه | `#dce5ed` | کادر کارت‌ها |
| متن روی سرمه‌ای | مقدار محلی | `#cadbea` | توضیح کارت کارشناسان |
### رنگ‌های اجزای خاص
| جزء | متن / خط | زمینه |
|---|---|---|
| برچسب «به‌زودی» | متن `#805300`، خط `#f3ddae` | `#fff3da` |
| اطلاع‌رسانی خدمت آینده | متن `#805300`، خط `#f3ddae` | `#fff8eb` |
| ناحیه انتخاب تصویر | خط dashed `#91bfdd` | `#f6fbff` |
| کارت شناسهٔ واریز | خط `#cde6f7` | `#f6fbff` |
| جداکننده اطلاعات | `#e7edf2` | سطح کارت |
| سطح آیکون خدمت آینده | متن `#526c86` | `#f0f3f6` |
نارنجی را به رنگ عمومی متن و همهٔ دکمه‌ها تبدیل نکنید. خاکستری روشن برای سطح و مرز است، نه متن اصلی.
### کنتراست: مشاهده و قاعدهٔ طراحی بعدی
برای متن معمولی نسبت حداقل ۴٫۵:۱ و برای متن بزرگِ واجد شرایط حداقل ۳:۱ معیار WCAG است. ۱۴ پیکسل با وزن بالا همچنان متن بزرگ محسوب نمی‌شود. [مرجع W3C: کنتراست متن](https://www.w3.org/WAI/WCAG22/Understanding/contrast-minimum.html)
| متن / زمینه | نسبت محاسبه‌شده، تقریبی | نتیجه برای متن معمولی |
|---|---:|---|
| سفید / سرمه‌ای `#08376b` | ۱۱٫۸۷:۱ | مناسب |
| `#cadbea` / سرمه‌ای | ۸٫۳۸:۱ | مناسب |
| `#526c86` / سفید | ۵٫۴۶:۱ | مناسب |
| `#526c86` / `#eeeeee` | ۴٫۷۰:۱ | مناسب |
| `#805300` / `#fff3da` | ۶٫۰۵:۱ | مناسب |
| سفید / آبی روشن `#0693e3` | ۳٫۳۴:۱ | برای متن کوچک کافی نیست |
| سفید / آبی تیره `#1977ce` | ۴٫۵۹:۱ | روی همین زمینهٔ ثابت مناسب |
| `#1977ce` / سفید | ۴٫۵۹:۱ | روی همین زمینهٔ ثابت مناسب |
| `#1977ce` / `#eeeeee` | ۳٫۹۶:۱ | برای متن کوچک کافی نیست |
| `#1977ce` / `#e5f4ff` | ۴٫۰۹:۱ | برای متن کوچک کافی نیست |
این اعداد برای جفت‌رنگ ثابت‌اند؛ شفافیت، hover، disabled و پس‌زمینهٔ واقعی باید جدا بررسی شوند.
**قاعدهٔ پیشنهادی برای طراحی جدید:** دکمهٔ اصلی با متن سفید روی سرمه‌ای ساخته شود. اگر دکمه آبی لازم است، سفید روی آبی تیره انتخاب و در حالت‌های مختلف بررسی شود. لینک متنی روی زمینهٔ خاکستری یا آبی روشن از سرمه‌ای استفاده کند. آبی روشن همچنان برای سطح آیکون، تزئین و نشان فعال قابل استفاده است.
دکمهٔ آبی و ناوبری فعال نمونه هنوز از سفید روی آبی روشن استفاده می‌کنند؛ این ظاهر به معنی عبور از معیار کنتراست نیست و نباید بی‌بررسی تکثیر شود. برچسب انتخاب تصویر در نسخهٔ جدید مالی به کنترل فارسی با سفید روی سرمه‌ای تبدیل شده است.
## ۴. تایپوگرافی
### خانوادهٔ فونت
وزیرمتن متغیر، وزن ۱۰۰ تا ۹۰۰، با `font-display: swap`.
فایل‌های موجود:
- `static/fonts/vazirmatn-arabic-wght-normal.woff2`
- `static/fonts/vazirmatn-latin-wght-normal.woff2`
نام خانواده در برنامه `Vazirmatn` است؛ نمونه برای جداسازی از `ChaharsuVazirmatn` و `ChaharsuLatin` استفاده می‌کند. این نام‌ها فونت‌های جدید نیستند. کتابخانه یا فونت خارجی لازم نیست.
### مقیاس مرجع
| نقش | موبایل | دسکتاپ | وزن | ارتفاع خط |
|---|---:|---:|---:|---:|
| عنوان خانه | ۲۲px | ۲۸px | ۹۰۰ | ۱٫۶ |
| عنوان صفحهٔ خدمت | ۲۴px | ۲۸px | ۹۰۰ | ۱٫۵ |
| عنوان بخش / کارت اصلی | ۱۸px | ۲۰px | ۹۰۰ | ۱٫۶ |
| عنوان کارت مالی | ۱۴px | ۱۷px | ۹۰۰ | حدود ۱٫۷ تا ۱٫۸ |
| توضیح و اطلاعات حساب | ۱۴px | ۱۴px | ۴۰۰ | ۱٫۶۵ تا ۱٫۷ |
| دکمه و لینک اصلی | ۱۴px | ۱۴px | ۷۰۰ تا ۸۰۰ | حدود ۱٫۵ تا ۱٫۷ |
| label انتخاب فایل | ۱۶px | ۱۶px | ۸۰۰ | ۱٫۶ |
| وضعیت، مسیر صفحه، ناوبری موبایل | ۱۲px | ۱۲px | ۴۰۰ تا ۷۰۰ | ۱٫۵ تا ۱٫۸ |
| امضای توسعه‌دهنده | ۱۲px | ۱۲px | ۴۰۰ | ۱٫۸ تا ۱٫۹ |
اندازه‌ها تصمیم طراحی چهارسو هستند؛ WCAG یک حداقل عمومی «۱۴ پیکسل» برای همهٔ متن‌ها تعیین نمی‌کند. خوانایی به فونت، کنتراست، طول خط، فاصله و امکان بزرگ‌نمایی نیز وابسته است. برای متن طولانی‌تر، ۱۶px با ارتفاع خط حدود ۱٫۷ پیشنهاد می‌شود؛ این حالت در نمونهٔ فعلی صفحهٔ متن طولانی آزمایش نشده است.
قواعد:
- متن عملیاتی، توضیح اصلی، عنوان دکمه و عدد حساب را از ۱۴px کوچک‌تر نکنید.
- ۱۲px را فقط برای اطلاعات کم‌اولویت به کار ببرید؛ مبلغ، شماره حساب یا پیام خطا اطلاعات کم‌اولویت نیستند.
- حروف فارسی را با letter-spacing فشرده نکنید.
- عنوان‌ها ۸۰۰ یا ۹۰۰؛ متن عادی ۴۰۰؛ دکمه‌ها ۷۰۰ یا ۸۰۰.
- متن چندخطی با خط مناسب نمایش داده شود؛ برای ارتفاع ثابت کارت بریده نشود.
- به‌جای تعداد خط ثابت، توضیح را کوتاه بنویسید و اجازهٔ رشد ارتفاع بدهید.
- واحدهای `rem` برای الگوی جدید ترجیح دارند: ۱۴px برابر ۰٫۸۷۵rem در ریشهٔ ۱۶px. اندازهٔ ریشه را برای جا دادن محتوا کاهش ندهید.
### نمونهٔ متن
عنوان: **ارتباط با کارشناسان**
توضیح، در موبایل و دسکتاپ:
> سؤال بپرسید، درخواست ثبت کنید یا برای پیگیری و هماهنگی با کارشناسان در ارتباط باشید.
دو اقدام: **ثبت درخواست** / **درخواست‌های من**
متن موبایل کارت مالی می‌تواند کوتاه‌تر باشد؛ اما جملهٔ اصلی کارت کارشناسان باید کامل و در هر دو اندازه دیده شود.
## ۵. فاصله‌ها و هندسه
مقیاس پایهٔ پیشنهادی برای اجزای جدید: **۴، ۸، ۱۲، ۱۶، ۲۰، ۲۴، ۳۲px**. نمونه در چند نقطه برای چیدمان فشرده از ۶، ۱۰، ۱۴ و ۲۲px هم استفاده می‌کند. همهٔ این مقادیر توکن سراسری موجود نیستند.
| مورد مشاهده‌شده | موبایل | دسکتاپ |
|---|---|---|
| فاصله افقی خانه از لبه | ۱۶px؛ زیر ۳۶۰px، ۱۲px | ۲۴px داخل container |
| فاصله افقی صفحه مالی | ۱۲px | ۲۴px |
| فاصله کارت‌های خانه | ۱۰px | ۱۶px |
| padding کارت کارشناسان | ۱۲px عمودی / ۱۴px افقی | ۲۰px |
| padding کارت مالی خانه | ۱۱px عمودی / ۱۲px افقی | ۲۴px |
| padding کارت جزئیات مالی | ۱۶px؛ زیر ۳۶۰px، ۱۴px | ۲۲px |
| فاصله عنوان تا توضیح | ۶px | ۶ تا ۷px |
| فاصله دو اقدام داخلی | ۸px | ۱۰px |
| فاصله قبل از اقدام‌ها | ۱۰px | ۱۴px |
| ردیف اطلاعات حساب | ۹px عمودی، gap ۸px | ۱۲px عمودی، gap ۱۲px |
| ناحیه انتخاب فایل | ۱۴px عمودی / ۱۲px افقی | ۲۲px عمودی / ۱۸px افقی |
### گوشه‌ها، خط و سایه
- کارت خانه: radius ۲۰px موبایل / ۲۲px دسکتاپ.
- کارت جزئیات: ۲۰px موبایل / ۲۳px دسکتاپ.
- دکمهٔ داخلی: حدود ۱۱–۱۲px؛ دکمهٔ پایهٔ فرم موجود ۱۶px.
- ورودی و وضعیت انتخاب فایل: ۱۲px.
- ناحیه upload: ۱۸px و خط dashed یک‌پیکسلی.
- نوار پایین: ۲۲px؛ گزینهٔ فعال داخل آن ۱۶px.
- برچسب وضعیت: گرد یا pill، حدود ۲۰px.
- مرز کارت: ۱px؛ خط تأکید کارت اصلی: ۳px نارنجی.
- کارت‌ها عموماً بدون سایهٔ دائمی‌اند. نوار ثابت یا پنجره به سایه نیاز دارد؛ همهٔ سطوح را برجسته نکنید.
- hover کارت مستقل: حداکثر جابه‌جایی ۳px و سایهٔ ملایم. کارت مشترک با دو اقدام داخلی جابه‌جا نشود.
برای طراحی جدید مقادیر نزدیک را با خانوادهٔ منظم توکن‌ها مدیریت کنید؛ تفاوت ۲۲/۲۳px از نمونه الزام هویتی نیست.
## ۶. چیدمان و responsive
| محدوده | رفتار مرجع |
|---|---|
| کمتر از ۳۶۰px | فاصله افقی ۱۲px؛ حذف آیکون کنار دو دکمهٔ کارشناسان؛ متن ۱۴px حفظ شود |
| تا ۶۳۹px | پوستهٔ موبایل، شبکهٔ دو ستون، ناوبری پایین |
| ۶۴۰ تا ۸۹۹px | شبکهٔ دو ستون؛ ناوبری دسکتاپ |
| از ۹۰۰px | شبکهٔ چهار ستون؛ کارت کارشناسان دو ستون را می‌گیرد |
حداکثر عرض خانه و header: **۱۱۲۰px**. حداکثر عرض صفحهٔ مالی: **۹۰۰px**. این اندازه‌ها شامل padding و border-box هستند.
در موبایل، توضیح کارت کارشناسان زیر عنوان تمام عرض را می‌گیرد. متن را در ستون باریک کنار آیکون قرار ندهید. همین الگو برای عنوان و subtitle صفحات مالی استفاده شده است.
دو اقدام کارشناسان کنار هم باقی می‌مانند. اگر در یک زبان، بزرگ‌نمایی یا عرض خاص متن جا نشد، ابتدا آیکون غیرضروری حذف و سپس دکمه‌ها به یک ستون تبدیل شوند؛ اندازهٔ متن کاهش نیابد. تبدیل در بزرگ‌نمایی قاعدهٔ پیشنهادی است و هنوز در نمونه اجرا/اعتبارسنجی نشده است.
**هدف خانه:** هر سه خدمت در viewport ۳۲۰×۵۶۸ و اندازه‌های معمول موبایل بالاتر از نوار پایین دیده شوند. این هدف با سه خدمت فعلی آزمایش شده است؛ تضمین برای تعداد نامحدود خدمات یا همهٔ تنظیمات فونت نیست.
**صفحهٔ جزئیات:** اسکرول عمودی طبیعی مجاز است. فرم، اطلاعات حساب و توضیحات را برای حذف کامل اسکرول کوچک نکنید.
## ۷. آیکون‌ها و لوگو
### الزام قطعی: تمام دارایی‌ها داخل پروژه
هیچ صفحه‌ای نباید برای آیکون، فونت، CSS، JavaScript، تصویر یا لوگو به CDN یا فایل روی دامنهٔ خارجی وابسته باشد. این الزام برای طراحی و پیاده‌سازی آینده نیز پابرجاست.
آیکون‌های این نمونه با مسیرهای SVG مستقیماً در HTML نوشته شده‌اند و از بستهٔ بیرونی یا سرویس آیکون دریافت نمی‌شوند. تعریف در `<symbol id="i-bank">…</symbol>` و نمایش با `<use href="#i-bank">` انجام می‌شود؛ ارجاع فقط به شناسهٔ داخل همان سند است. `xmlns="http://www.w3.org/2000/svg"` شناسهٔ استاندارد فضای نام SVG است، نه نشانی دانلود؛ مرورگر برای آن درخواست شبکه نمی‌فرستد.
اگر در آینده آیکون‌ها در یک فایل مشترک یا include جمع شدند، آن فایل باید در خود مخزن نگهداری و از static همان پروژه سرو شود. URL دارایی‌های محلی برنامه باید با static_asset_version نسخه‌گذاری شود. کتابخانهٔ موردنیاز نیز باید با خروجی محلی داخل پروژه ارائه شود؛ لینک CDN، import از URL، Google Fonts، icon API یا fallback شبکه‌ای مجاز نیست.
فونت وزیرمتن در static/fonts، لوگو و favicon در static/images و Tailwind کامپایل‌شده در static/css موجودند. JavaScript نمونه داخل HTML است.
لینک‌های W3C در این سند فقط منابع مطالعه‌اند و وابستگی اجرایی صفحات نیستند. برای بازبینی بعدی، صفحه باید با قطع دسترسی اینترنت و منابع محلی بارگذاری شود؛ در برنامهٔ اصلی اتصال به سرور خود پروژه همچنان ممکن است لازم باشد.
آیکون‌های نمونه SVG خطی محلی‌اند؛ بستهٔ آیکون یا فونت آیکون خارجی استفاده نشده است.
الگوی پایه:
- `viewBox="0 0 24 24"`
- `fill="none"`
- `stroke="currentColor"`
- `stroke-width="1.7"`
- `stroke-linecap="round"` و `stroke-linejoin="round"`
- اندازه متداول ۲۰–۲۴px؛ مقدار پایهٔ نمونه ۲۳px.
- آیکون داخل دکمه ۱۶–۲۰px؛ آیکون نوار پایین ۲۰px.
- قاب آیکون خانه ۲۸–۳۰px موبایل؛ قاب عنوان صفحه ۳۶px موبایل / ۴۸px دسکتاپ.
- اندازهٔ آیکون با اندازهٔ ناحیهٔ قابل لمس یکی نیست.
| شناسه موجود | مفهوم |
|---|---|
| `i-chat` | ارتباط با کارشناسان |
| `i-plus` | ثبت درخواست |
| `i-history` | درخواست‌های من |
| `i-receipt` | فیش واریزی |
| `i-bank` | شماره حساب‌ها |
| `i-grid` | خدمات |
| `i-user` | پروفایل |
| `i-arrow` | رفتن به مقصد |
| `i-upload` | انتخاب تصویر |
| `i-search` | جست‌وجو |
| `i-home`، `i-shield` | در sprite موجودند؛ در خانهٔ نهایی نقش اصلی ندارند |
آیکون کنار متن `aria-hidden="true"` باشد. کنترل فقط‌آیکون باید نام قابل فهم مانند `aria-label="بستن پنجره"` داشته باشد. از emoji یا کاراکتر تصادفی به‌عنوان سبک آیکون جدید استفاده نکنید.
لوگو از `static/images/company-logo.png` یا منبع برند برنامه می‌آید. نسبت طول به عرض حفظ شود؛ `height:auto` و در صورت نیاز `object-fit:contain`. اندازهٔ مشاهده‌شده در desktop ۸۵px است؛ نمونه‌های موبایل خانه و صفحات مالی برای جا دادن header اندازه‌های متفاوت دارند. لوگو را برای ساخت آیکون خدمت تغییر شکل ندهید.
## ۸. اجزا و رفتار تعاملی
### کارت خدمت
عنوان واضح + آیکون مرتبط + توضیح کوتاه + وضعیت، در صورت لزوم.
- یک مقصد: کل کارت لینک `a` باشد.
- دو اقدام مستقل: ظرف `section` و دو کنترل جدا؛ لینک تو‌در‌تو نسازید.
- «به‌زودی» وضعیت خدمت است؛ برای نمونه می‌تواند به صفحهٔ preview برود.
- قابلیت آماده‌نشده را با شماره حساب یا موفقیت جعلی واقعی جلوه ندهید.
- عنوان کارت به‌جای زبان داخلی سیستم، نتیجهٔ قابل فهم برای مشتری را بیان کند.
### دکمه‌ها
- ارتفاع اقدام اصلی حداقل ۴۴px؛ عرض کنترل نیز برای لمس مناسب باشد.
- ۴۴×۴۴px یک هدف طراحی و معیار **Target Size (Enhanced)، سطح AAA** است؛ همهٔ لینک‌های متنی بدون استثنا ملزم به این ابعاد نیستند. [مرجع W3C](https://www.w3.org/WAI/WCAG22/Understanding/target-size-enhanced.html)
- اقدام اصلی پررنگ؛ اقدام ثانویه با border یا زمینهٔ ملایم.
- متن دکمه یک فعل/مقصد مشخص داشته باشد: «ثبت درخواست»، «مشاهده حساب‌ها»، «حذف انتخاب».
- disabled با ویژگی واقعی HTML اعمال شود؛ دلیل غیرفعال بودن نزدیک آن نوشته شود.
- لینک برای ناوبری، button برای اقدام یا بازکردن پنجره.
### فرم انتخاب فیش
label ۱۶px؛ ورودی و راهنما ۱۴px؛ ناحیه روشن با کادر dashed.
- به‌جای چند جمله تکراری، یک راهنمای کیفیت تصویر و یک توضیح دربارهٔ نتیجهٔ انتخاب فایل کافی است.
- نام بلند فایل با `overflow-wrap:anywhere` قابل نمایش باشد.
- انتخاب فایل به معنی ارسال یا تأیید پرداخت نیست.
- در نمونه دکمه فقط اعتبارسنجی می‌کند و فیش ارسال نمی‌شود؛ در برنامهٔ نهایی پس از ثبت واقعی نتیجه نمایش داده شود.
- کنترل file فعلی بومی مرورگر است؛ زبان و ظاهر بخشی از آن وابسته به مرورگر/سیستم عامل است. عبارت انگلیسی آن را جزء زبان بصری فارسی پروژه تلقی نکنید.
### اطلاعات حساب
از `dl / dt / dd` برای ارتباط برچسب و مقدار استفاده کنید. متن مقدار حساب ۱۴px یا بیشتر باشد. حساب، شبا و شناسه با جهت LTR و بدون بریدگی نمایش داده شوند. نمونه اکنون اعداد ساختگی نامعتبر دارد؛ نمایش و کپی این مقادیر در اندازه‌های موبایل و دسکتاپ بررسی شده است. دادهٔ حساب واقعی همچنان فقط باید از بک‌اند بیاید.
کپی جداگانهٔ فیلدها و کپی کامل متن اکنون در نمونه وجود دارد؛ کنترل نام مشخص و بازخورد قابل مشاهده/اعلان وضعیت دارد. در صورت منع کپی خودکار، انتخاب دستی متن ارائه می‌شود. قواعد کامل در بخش «الگوی مالی» آمده‌اند.
### ناوبری
خانهٔ خدمات، درخواست‌ها و پروفایل سه مقصد نوار موبایل هستند. از `aria-current="page"` برای مقصد فعال استفاده شود.
در نمونه:
- نوار پایین با فاصلهٔ افقی ۱۲px و فاصلهٔ پایین حداقل ۱۰px قرار دارد.
- safe-area با `env(safe-area-inset-bottom)` لحاظ می‌شود.
- ارتفاع هر گزینه حداقل ۵۲px است؛ کل نوار در بررسی ۳۲۰px حدود ۶۷px بود.
- خانه برای پایین صفحه ۹۵px padding دارد تا امکان اسکرول محتوا بالاتر از نوار فراهم شود.
در طراحی جدید، فضای رزروشده باید با ارتفاع واقعی نوار و safe-area هماهنگ باشد. فوکوس، پیام خطا و پایان فرم نباید زیر آن پنهان بمانند.
### پنجره و وضعیت‌ها
پنجره فقط برای کار کوتاه و محدود مناسب است. خدمات مالی صفحهٔ مستقل دارند و به modal تبدیل نشوند.
الگوی موجود: `dialog` بومی، backdrop تیره، عرض حداکثر ۵۲۰px، بستن با Escape/دکمه/کلیک بیرون، اسکرول داخلی در محتوای بلند. برای کنترل بستن جدید، ناحیهٔ لمس ۴۴×۴۴px پیشنهاد می‌شود؛ نمونهٔ فعلی ۴۰×۴۰px است.
فوکوس نمایان باشد. برای حالت loading، خطا، موفقیت و empty علاوه بر رنگ، متن معنادار نمایش داده شود. سبک loading و خطای کامل فرم مالی هنوز در نمونه تعریف نشده است؛ از ساخت چند نسخهٔ متفاوت بدون توکن مشترک پرهیز کنید.
## ۹. متن، راست‌به‌چپ و امضا
- سند `lang="fa" dir="rtl"` داشته باشد.
- اصطلاحات فعلی: «ارتباط با کارشناسان»، «ثبت درخواست»، «درخواست‌های من»، «ارسال فیش واریزی»، «شماره حساب‌ها».
- خانه محل فهرست سوابق نیست؛ آن‌ها در نمای درخواست‌ها قرار دارند.
- جملهٔ راهنمای کارشناسان در تمام اندازه‌ها قابل نمایش بماند.
- متن کارت‌های مالی در موبایل کوتاه‌تر می‌شود؛ وضعیت و مقصد حذف نمی‌شوند.
- از نام متغیر، API، اصطلاح backend یا توضیح پیاده‌سازی در متن محصول استفاده نکنید.
- برای دادهٔ LTR از `bdi dir="ltr"` یا عنصر مستقل با `dir="ltr"` استفاده کنید.
- در CSS جدید `padding-inline`، `margin-inline` و `text-align:start` را ترجیح دهید.
- مثال: `<bdi dir="ltr">IR…</bdi>`؛ این مثال شماره حساب واقعی نیست.
امضای پایین صفحات:
> طراحی و توسعه: محمد جواد فصاحت 09202202129
اندازه ۱۲px، وزن ۴۰۰، متن ثانویه و شماره با جهت LTR. لینک تماس `tel:09202202129` فقط با انتخاب کاربر اجرا شود. در نسخهٔ اصلی محتوا از `developer_credit` تنظیمات برند دریافت شود؛ مقدار را در همهٔ templateها تکثیر نکنید.
## ۱۰. مثال برای ساخت جزء جدید
این مثال **پیشنهاد برای طراحی بعدی** است؛ کلاس‌هایش در CSS فعلی تعریف نشده‌اند. رنگ دکمه و لینک به‌قصد رفع موارد کنتراست بخش ۳ انتخاب شده است.
### توکن‌ها و CSS نمونه
```css
:root {
--ui-ink: var(--color-ink, #08376b);
--ui-page: var(--color-cream, #eeeeee);
--ui-surface: var(--color-paper, #ffffff);
--ui-muted: #526c86;
--ui-border: #dce5ed;
--ui-action: var(--color-ink, #08376b);
--ui-action-hover: var(--color-ink-soft, #0b4c8c);
--ui-link: var(--color-ink, #08376b);
--ui-space-2: .5rem;
--ui-space-3: .75rem;
--ui-space-4: 1rem;
--ui-radius-card: 1.25rem;
}
.ui-card {
padding: 1rem;
border: 1px solid var(--ui-border);
border-radius: var(--ui-radius-card);
background: var(--ui-surface);
color: var(--ui-ink);
}
.ui-title {
margin: 0;
font-size: 1.125rem;
font-weight: 900;
line-height: 1.6;
}
.ui-description {
margin: .375rem 0 0;
color: var(--ui-muted);
font-size: .875rem;
line-height: 1.7;
}
.ui-button {
display: inline-flex;
align-items: center;
justify-content: center;
gap: .5rem;
min-height: 2.75rem;
min-width: 2.75rem;
padding: .5rem .75rem;
border: 1px solid transparent;
border-radius: .75rem;
font: inherit;
font-size: .875rem;
font-weight: 800;
background: var(--ui-action);
color: #fff;
}
.ui-button:hover { background: var(--ui-action-hover); }
.ui-button:focus-visible {
outline: 3px solid var(--color-sea, #0693e3);
outline-offset: 4px;
}
.ui-icon {
width: 1.25rem;
height: 1.25rem;
fill: none;
stroke: currentColor;
stroke-width: 1.7;
stroke-linecap: round;
stroke-linejoin: round;
flex-shrink: 0;
}
@media (min-width: 640px) {
.ui-card { padding: 1.375rem; }
.ui-title { font-size: 1.25rem; }
}
@media (prefers-reduced-motion: reduce) {
.ui-button { transition: none; }
}
```
### HTML نمونهٔ صفحهٔ مستقل در Django
```django
{% extends "base.html" %}
{% load static %}
{% block content %}
<section class="ui-card" aria-labelledby="receipt-heading">
<h1 class="ui-title" id="receipt-heading">ارسال فیش واریزی</h1>
<p class="ui-description">
تصویر فیش را برای بررسی پرداخت ارسال کنید.
</p>
</section>
{% endblock %}
```
برای CSS جدید از نقطهٔ توسعهٔ مناسب پوسته استفاده شود و URL آن به شکل
`{% static 'css/…' %}?v={{ static_asset_version }}` باشد. `…` مسیر مثال است، نه فایل موجود. این قطعه فرم اجرایی، route یا ذخیرهٔ فایل را پیاده نمی‌کند.
نمونهٔ آیکون، بعد از تعریف sprite در همان سند:
```html
<svg class="ui-icon" aria-hidden="true" focusable="false">
<use href="#i-receipt"></use>
</svg>
```
برای ساخت خدمت مستقل، لینک Django باید به URL نام‌گذاری‌شده همان خدمت متصل شود؛ نام routeهای مالی هنوز در پروژه تعریف نشده است. نام فرضی route را به‌عنوان مسیر موجود کپی نکنید.
## ۱۱. ارتباط با فناوری موجود
پروژه Tailwind 4، Alpine، HTMX و Compressor را دارد؛ ظاهر این راهنما وابسته به افزودن کتابخانهٔ دیگری نیست. SVGهای محلی و وزیرمتن موجود برای ادامه کافی‌اند.
در پیاده‌سازی برنامه:
- رنگ‌های برند از توکن‌های مرکزی موجود مصرف شوند.
- کامپوننت مشترک و کلاس‌های معنادار ایجاد شوند؛ CSS تکراری یا overrideهای مرحله‌ای تکثیر نشوند.
- پوستهٔ صفحه از `base.html` و برند از تنظیمات پروژه بیاید.
- فایل‌های محلی با `static_asset_version` نسخه‌گذاری شوند.
- از ثبت Service Worker جدا در صفحهٔ یک خدمت پرهیز شود؛ چرخهٔ PWA متعلق به پوستهٔ برنامه است.
- تغییرات کش/PWA باید با تست‌های مقرر پروژه بررسی شوند.
- نمونهٔ مستقل خارج از templates/static است؛ انتقال آن به برنامهٔ اصلی یک کار اجرایی جدا است.
## ۱۲. مواردی که نباید از نمونه تکثیر شوند
| مشاهده | وضعیت واقعی | قاعدهٔ طراحی بعدی |
|---|---|---|
| سفید روی آبی روشن دکمه/ناوبری | کنتراست حدود ۳٫۳۴ | رنگ متن/سطح طبق بخش ۳ اصلاح و دوباره سنجیده شود |
| لینک آبی تیره روی خاکستری روشن | کنتراست حدود ۳٫۹۶ | سرمه‌ای یا رنگ آزموده‌شده مناسب همان زمینه |
| label انتخاب فایل | اصلاح شد: کنترل فارسی سفید روی سرمه‌ای، ارتفاع حداقل ۴۴px | وضعیت focus ورودی پنهان روی label نیز دیده شود |
| نام برند فرعی خانه | ۱۱px؛ صفحات مالی ۱۲px | برای اجزای جدید ۱۲px |
| متن‌های نمای سوابق | اصلاح شد: عنوان ۱۴/۱۶، شماره ۱۴، metadata و وضعیت ۱۲px | همین مقیاس در صفحات بعدی حفظ شود |
| فیلتر سوابق / ورودی جست‌وجو | اصلاح شد: حداقل ارتفاع ۴۴px؛ متن فیلتر ۱۴ و جست‌وجو ۱۶px | برای کنترل جدید حداقل ۴۴px هدف‌گذاری شود |
| بستن پنجره / دکمه پروفایل موبایل | ۴۰px | کنترل جدید ۴۴×۴۴px |
| پنجره‌ها | متن عادی ۱۳ و یادداشت ۱۱px | برای پنجره جدید متن اصلی ۱۴، فرعی ۱۲px |
| قوانین بنر و پروفایل قدیمی | هنوز در CSS، ولی خانه نهایی استفاده نمی‌کند | جزء مرجع نهایی محسوب نشوند |
| اعداد ثابت و اطلاعات نمونه | دادهٔ نمایشی، بدون backend | فقط منبع معتبر حساب/مشتری در نسخهٔ اصلی |
| متن ۱۴px برای همه شرایط | تصمیم نمونه، نه تضمین خوانایی همگانی | بزرگ‌نمایی، متن بلند و دستگاه واقعی نیز بررسی شوند |
موارد دارای عبارت «اصلاح شد» اکنون در نمونه اجرا شده‌اند؛ سایر موارد همچنان نیازمند بررسی یا اصلاح‌اند.
## ۱۳. معیار بازبینی طرح بعدی
- [ ] عنوان و مقصد هر خدمت با نگاه اول مشخص است.
- [ ] متن کامل راهنمای کارشناسان روی موبایل و دسکتاپ دیده می‌شود.
- [ ] متن اصلی و داده‌های مهم حداقل ۱۴px؛ metadata کم‌اولویت حداقل ۱۲px است.
- [ ] کنتراست متن و کنترل روی زمینهٔ واقعی، در حالت عادی و تعاملی سنجیده شده است.
- [ ] رنگ تنها نشانهٔ خطا، انتخاب یا وضعیت نیست.
- [ ] هدف لمس کنترل‌های اصلی ۴۴×۴۴px است.
- [ ] فونت، وزن، stroke آیکون، رنگ و گوشه‌ها با خانوادهٔ این راهنما هماهنگ‌اند.
- [ ] خانه در ۳۲۰×۵۶۸، ۳۶۰×۶۴۰، ۳۹۰×۸۴۴ و دسکتاپ بازبینی شده است.
- [ ] متن بلند، عنوان چندخطی، نام فایل بلند و عدد LTR سرریز ندارند.
- [ ] صفحهٔ جزئیات اسکرول طبیعی دارد و فرم پشت نوار ثابت پنهان نیست.
- [ ] Tab، Enter و Escape، فوکوس نمایان و بازگشت مرورگر بررسی شده‌اند.
- [ ] بزرگ‌نمایی متن تا ۲۰۰٪ بدون از دست‌رفتن محتوا یا عملکرد بررسی شده است.
- [ ] حرکت با `prefers-reduced-motion` محدود می‌شود.
- [ ] خدمت مستقل با لینک و صفحه مستقل پیاده می‌شود؛ modal جای آن را نگرفته است.
- [ ] label ورودی، نام کنترل فقط‌آیکون، اعلان نتیجه و پیام خطا وجود دارند.
- [ ] دادهٔ نمایشی، placeholder و وضعیت به‌زودی از اطلاعات واقعی قابل تشخیص‌اند.
- [ ] برند، امضای توسعه‌دهنده و نسخهٔ دارایی‌ها از منبع مشترک برنامه می‌آیند.
بزرگ‌نمایی ۲۰۰٪ معیار مستقل W3C است؛ تعیین font-size مناسب جای بررسی آن را نمی‌گیرد. [مرجع W3C: تغییر اندازه متن](https://www.w3.org/WAI/WCAG22/Understanding/resize-text.html)
**حدود بررسی فعلی:** استخراج computed style، اندازه‌های نمایش، نمونه‌های رنگ و بررسی‌های مرورگر قبلی مبنای سند هستند. تست کامل screen reader، تمامی حالت‌های خطا/loading، بزرگ‌نمایی ۲۰۰٪، حساب واقعی و ارزیابی کامل WCAG در این نوبت انجام نشده‌اند.
## ۱۴. نمونه‌های بصری
برای مشاهدهٔ زبان بصری فعلی، با درنظرگرفتن موارد نیازمند اصلاح بخش ۱۲:
- [خانه در گوشی کوچک](mobile-small.png)
- [خانه دسکتاپ](desktop.png)
- [شماره حساب‌ها در گوشی کوچک](accounts-small.png)
- [شماره حساب‌ها دسکتاپ](accounts-desktop.png)
- [ارسال فیش در گوشی کوچک](receipt-small.png)
- [ارسال فیش دسکتاپ](receipt-desktop.png)
برای هر صفحهٔ جدید، ابتدا همین نقش‌ها و اجزا را انتخاب کنید؛ سپس فقط آنچه نیاز مشخص صفحه است اضافه کنید.
### به‌روزرسانی نمای درخواست‌ها
نمای درخواست‌ها با مقیاس تایپوگرافی این سند هماهنگ شد. شمارهٔ درخواست از metadata مهم‌تر است و ۱۴ پیکسل باقی می‌ماند. روی موبایل عنوان و اطلاعات بالاتر از وضعیت و شماره قرار می‌گیرند تا متن مجبور به ستون باریک نشود. رنگ فعال گزینهٔ درخواست‌ها در نوار پایین از آبی روشن به ink-soft تغییر کرده است؛ رنگ فعال سایر گزینه‌ها هنوز باید طبق جدول کنتراست بررسی شود.
### الگوی مالی: اعلامیهٔ واریز اختصاصی مشتری
صفحهٔ «شماره حساب‌ها» فقط موارد اعلام‌شده توسط کارشناس به مشتری را نمایش می‌دهد؛ حساب عمومی یا حساب پیش‌فرض جایگزین ندارد. هر اعلامیه یک کارت بازشونده با موضوع، مبلغ و واحد، فاکتور، تاریخ و وضعیت فیش است. شماره‌های بانکی در فهرست بسته نمایش داده نمی‌شوند؛ باز شدن کارت همهٔ جزئیات همان مورد را نشان می‌دهد. هویت حساب و مبلغ به هر اعلامیه تعلق دارند، نه به کل صفحه.
- مبلغ کارت: موبایل ۱۸px، دسکتاپ ۲۰px، وزن ۹۰۰؛ شماره‌های جزئیات ۱۴/۱۶px، راست‌چین با جهت LTR و شکست خط امن.
- عنوان کارت ۱۴/۱۶px، توضیح و کنترل کپی ۱۴px؛ label فرم و مقدار ورودی ۱۶px؛ وضعیت و تاریخ ۱۲px.
- فاصلهٔ کارت‌ها ۱۲px؛ گوشه ۲۰px؛ ردیف اطلاعات در دسکتاپ label/value/copy و در موبایل label بالای value با دکمهٔ کپی کنار آن است.
- دکمهٔ «کپی» حداقل ۴۴px؛ aria-label نام همان فیلد را مشخص کند. مقدار خالی «اعلام نشده» نمایش داده شود و دکمهٔ کپی نداشته باشد.
- عدد کپی‌شده فاقد جداکنندهٔ نمایشی است؛ مبلغِ کپی جداگانه بدون واحد است. «کپی کامل اطلاعات» متن چندخطی خوانا شامل موضوع، تاریخ، شناسهٔ اعلامیه، مبلغ و واحد، بانک، صاحب حساب، شبا، حساب، کارت، شناسهٔ واریز، فاکتور و توضیحات فراهم کند. در نمونه، هشدار ساختگی بودن هم در متن کپی قرار دارد.
- پیام «کپی شد» فقط پس از کپی موفق نمایش داده شود، به screen reader اعلام شود و تمرکز روی کنترل حفظ شود. اگر کپی خودکار ممکن نیست، textarea فقط‌خواندنی برای انتخاب دستی در dialog با عنوان، کنترل بستن و پشتیبانی Escape نمایش داده شود.
- وضعیت «فیش در حال بررسی» به معنی تأیید پرداخت نیست؛ هر وضعیت نام متنی داشته باشد و به رنگ وابسته نباشد.
- ارسال فیش عمل اصلی و کپی کامل عمل ثانویه است؛ هر دو در پایین جزئیات همان کارت باشند. در موبایل تمام عرض و در دو ردیف قرار گیرند.
### الگوی فرم فیش مستقل و مرتبط
دو گزینهٔ radio با label واضح و تمام سطح قابل لمس: «واریز مستقل» و «واریز مرتبط». ورود از اعلامیه، گزینهٔ مرتبط و همان اعلامیه را از ابتدا انتخاب می‌کند؛ ورود مستقیم گزینهٔ مرتبط را به‌عنوان اولین گزینهٔ پیشنهادی انتخاب می‌کند؛ مستقل همواره قابل انتخاب است. انتخاب مرتبط، select و خلاصهٔ مبلغ اعلام‌شده، فاکتور، بانک و شناسه را نشان دهد. لینک مشاهدهٔ جزئیات به همان اعلامیه بازگردد. انتخاب مستقل ارتباط قبلی را حذف کند؛ فایل و مبلغ پرداخت‌شده را بی‌دلیل پاک نکند.
مبلغ مورد انتظار و مبلغ واقعاً پرداخت‌شده دو مفهوم متفاوت‌اند. مبلغ مشتری را با تغییر اعلامیه خودکار بازنویسی نکنید. واحد «ریال» کنار label مبلغ بماند. ورودی مبلغ و پیگیری در موبایل صفحه‌کلید عددی دارند. کنترل انتخاب تصویر، عنوان فارسی و سطح حداقل ۴۴px دارد؛ نام فایل انتخاب‌شده و عبارت «ارسال نشده» مستقل نمایش داده شوند. اطلاعات طولانی wrap شوند؛ حذف اسکرول عمودی با کوچک کردن متن مجاز نیست.
رنگ متن و لینک این اجزا روی زمینهٔ خاکستری/آبی روشن سرمه‌ای است؛ دکمهٔ اصلی سفید روی سرمه‌ای، ثانویه سرمه‌ای روی سفید با مرز مشخص. فایل‌ها و SVGها کاملاً محلی هستند. این الگو دیتابیس یا مجوزهای سرور را پیاده‌سازی نمی‌کند؛ قرارداد مربوط به دریافت اختصاصی اعلامیه و ارتباط معتبر فیش در کامنت HTML ثبت شده است.
### نسخهٔ فشردهٔ اطلاعات مالی
در کارت بازشده فقط مبلغ، حساب، شبا، کارت و شناسهٔ واریز موجود، دکمهٔ کپی تکی دارند. بانک و صاحب حساب در سطر خلاصه نمایش داده شوند؛ فاکتور و توضیحات در بخش ثابت با عنوان «اطلاعات تکمیلی و توضیحات» نمایش داده شوند؛ این بخش کنترل جمع‌شونده ندارد. شماره‌های اعلام‌نشده از فهرست اصلی حذف شوند. کنترل باز و بسته شدن خود کارت اعلامیه حداقل ۴۴px و با صفحه‌کلید قابل استفاده باشد. اطلاعات تکمیلی ۱۴px، راهنمای صفحه ۱۴px با ارتفاع خط ۱٫۷؛ تعداد دکمه‌ها با حذف کپی‌های کم‌کاربرد کاهش یابد، نه با کوچک کردن سطح لمس.
«کپی کامل اطلاعات» همواره از مدل کامل اعلامیه تولید شود، مستقل از نمایش یا پنهان بودن هر بخش. بانک، صاحب حساب، فاکتور، توضیحات و تمام مقادیر اعلام‌شده باید در متن کامل باقی بمانند. این قرارداد در HTML و JavaScript نیز کامنت دارد.
در فرم فیش، «واریز مرتبط» همیشه در ترتیب DOM و نمایش اولین گزینه، با نشان متنی «پیشنهادی» و تأکید بصری محدود است. پیش‌فرض ورود مستقیم مرتبط است؛ مستقل برای مشتری بدون اعلامیه نیز در دسترس بماند. راهنمای کوتاه و همیشه قابل مشاهده توضیح دهد: ایجاد اعلامیه توسط کارشناس برای ثبت دقیق‌تر توصیه می‌شود و اجباری نیست.
### تاریخ شمسی و اطلاعات تکمیلی همیشه نمایان
اطلاعات تکمیلی هر اعلامیه، پس از باز کردن کارت اصلی، بدون کنترل بازشوندهٔ دیگری نمایش داده شود. عنوان بخش ۱۴px/وزن ۸۰۰، متن ۱۴px با ارتفاع خط ۱٫۸؛ همچنان کپی تکی مخصوص مقادیر پرداخت و کپی کامل شامل تمام داده‌هاست.
انتخابگر تاریخ فیش: سه select استاندارد «روز، ماه، سال»، RTL، labelهای ۱۴px، مقادیر ۱۶px و ارتفاع حداقل ۴۴px. فاصلهٔ ستون‌ها ۸px موبایل / ۱۲px دسکتاپ؛ نام ماه‌ها و ارقام نمایش فارسی باشند. این انتخاب برای ورود تاریخ معلوم روی فیش ساده است و تعامل‌های پیچیدهٔ wheel سفارشی یا تقویم بزرگ را به فرم اضافه نمی‌کند. ظاهر فهرست بازشدهٔ select تابع مرورگر/سیستم‌عامل است؛ در برخی گوشی‌ها کنترل بومی ممکن است به‌صورت چرخشی نمایش داده شود. هیچ wheel سفارشی، CDN یا کتابخانهٔ تاریخ لازم نیست.
روز ابتدا خالی؛ ماه/سال جاری برای سهولت آماده‌اند. دکمهٔ «امروز» با منطقهٔ زمانی `Asia/Tehran` و یک بازخورد متنی کامل مثل «۱۳ مهر ۱۴۰۵» انتخاب را روشن می‌کند. طول ماه‌ها از تقویم شمسی مرورگر بررسی شود، نه فرض کبیسهٔ هر چهار سال. روز نامعتبر با تغییر ماه/سال پاک شود و پیام ۱۴px با aria-live کاربر را به انتخاب دوباره راهنمایی کند؛ روز واقعی پرداخت نباید خودکار به روز دیگری تبدیل شود.
دادهٔ خروجی نمونه تاریخ میلادیِ فقط‌روز در `paid_date` است؛ سرور باید آن را اعتبارسنجی کند. تاریخ پرداخت مستقل از تاریخ اعلامیه است و با تغییر اعلامیه یا نوع فیش نباید بازنویسی شود.
### خطا و اعتبارسنجی فرم فیش
مبلغ، تاریخ کامل و تصویر با برچسب اجباری مشخص شوند. خطای هر فیلد با متن فارسی سادهٔ ۱۴px و ارتفاع خط ۱٫۸، رنگ `#9f1f1f` و aria-describedby به کنترل متصل شود. کنترل نامعتبر aria-invalid و مرز قرمز داشته باشد؛ رنگ به‌تنهایی کافی نیست. خطا با اصلاح مقدار به‌روز/پاک شود؛ خطای همهٔ فیلدها قبل از تعامل نشان داده نشود.
پس از تلاش ناموفق، فهرست لینک‌دار خطاها در بالای فرم و فوکوس روی اولین کنترل نامعتبر ارائه شود. لینک خطا همان کنترل را فوکوس و به نمایش بیاورد؛ برای فایل پنهان، label فارسی قابل لمس نیز در معرض دید قرار گیرد. اسکرول با prefers-reduced-motion هماهنگ باشد. همهٔ مقادیر واردشده حفظ شوند.
نمونه دکمهٔ «بررسی و ادامه» دارد؛ پس از بررسی معتبر، به‌روشنی اعلام می‌کند هیچ فایلی ارسال نشده است. نتیجهٔ معتبر نباید با موفقیت ثبت پرداخت اشتباه شود. محدودیت فایل در UI و اعتبارسنجی از یک منبع معتبر سرور در نسخهٔ اصلی تغذیه شوند.
### مبلغ با هزارگان و بانک‌های پرداخت
مبلغ همیشه با label ریال نمایش داده شود. جداکنندهٔ هزارگان فقط نمایش است؛ مقدار آمادهٔ ارسال ریالی و بدون جداکننده باقی می‌ماند. زیر ورودی، معادل به حروف تومان با متن ۱۴px، وزن ۷۰۰، رنگ سرمه‌ای و ارتفاع خط ۱٫۸ نمایش داده شود. تبدیل از عدد صحیح دقیق و باقی‌مانده استفاده کند؛ بخش کمتر از یک تومان را به «دهم تومان» بیان کند و گرد نکند. هنگام ویرایش، نشانگر و انتخاب متن باید نسبت به ارقام حفظ شوند و حذف روی جداکننده گیر نکند.
بانک مبدأ با select اختیاری و گزینهٔ «سایر»؛ حساب مقصد با select اجباری و label «به کدام حساب ما واریز کردید؟». گزینه‌های مقصد فقط نام نمایشی حساب را نشان دهند؛ شماره‌های بانکی در فهرست نباشند. حساب از دیتابیس شرکت تأمین شود و ID آن ارسال شود؛ انتخاب مشتری با تغییر اعلامیه/نوع فیش بازنویسی نشود. فیلدها در دسکتاپ دو ستون و در موبایل یک ستون، متن کنترل ۱۶px و ارتفاع حداقل ۴۴px باشند.
Binary file not shown.

After

Width:  |  Height:  |  Size: 136 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 72 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 117 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 116 KiB

@@ -0,0 +1,42 @@
<!doctype html>
<html lang="fa" dir="rtl" data-static-version="1791111429240969400">
<head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover"><meta name="theme-color" content="#08376b"><title>شماره حساب‌ها · چهارسو</title>
<link rel="icon" href="../../static/images/favicon.ico?v=1791111429240969400"><link rel="stylesheet" href="../../static/css/tailwind.css?v=1791111429240969400"><link rel="stylesheet" href="service-pages.css?v=1791111429240969400">
<script defer src="payment-demo-data.js?v=1791111429240969400"></script><script defer src="payment-services.js?v=1791111429240969400"></script></head>
<body data-finance-page="accounts">
<!-- DESIGN PROTOTYPE ONLY: صفحهٔ مستقل با URL/view/template مستقل در پیاده‌سازی اصلی؛ به modal خانه تبدیل نشود.
پوسته از base.html و دارایی‌ها از static_asset_version استفاده کنند. هیچ اتصال به دیتابیس یا ارسال واقعی وجود ندارد.
اطلاعات پرداخت تنها برای مشتری مخاطب اعلامیه از بک‌اند تأمین شود؛ فهرست عمومی حساب‌ها نمایش داده نشود.
پنهان کردن شماره‌ها در UI کنترل دسترسی نیست: مجوز مشاهده/ثبت فیش و ارتباط اعلامیه با مشتری باید در سرور بررسی شود.
پارامتر notice فقط شناسهٔ اعلامیه است، نه مبلغ یا شماره حساب؛ سرور دادهٔ معتبر را دوباره دریافت کند. -->
<svg xmlns="http://www.w3.org/2000/svg" style="position:absolute;width:0;height:0;overflow:hidden" aria-hidden="true">
<symbol id="i-receipt" viewBox="0 0 24 24"><path d="M5 3h14v18l-3-2-4 2-4-2-3 2ZM9 8h6M9 12h6"/></symbol>
<symbol id="i-bank" viewBox="0 0 24 24"><path d="m3 7 9-4 9 4v3H3ZM5 10v7M10 10v7M14 10v7M19 10v7M3 17h18v4H3"/></symbol>
<symbol id="i-copy" viewBox="0 0 24 24"><rect x="8" y="8" width="12" height="13" rx="2"/><path d="M16 8V3H3v13h5"/></symbol>
<symbol id="i-chevron" viewBox="0 0 24 24"><path d="m6 9 6 6 6-6"/></symbol>
<symbol id="i-upload" viewBox="0 0 24 24"><path d="M12 16V3m-5 5 5-5 5 5M4 16v5h16v-5"/></symbol>
<symbol id="i-search" viewBox="0 0 24 24"><circle cx="10" cy="10" r="6"/><path d="m15 15 6 6"/></symbol></svg><div class="preview-bar"><b>پیش‌نمایش طراحی چهارسو</b> · داده‌ها ساختگی‌اند؛ پرداخت و ارسال واقعی انجام نمی‌شود.</div>
<header class="page-header"><div class="header-inner"><a class="brand" href="index.html?v=1791111429240969400"><img src="../../static/images/company-logo.png?v=1791111429240969400" alt="لوله گستر گلپایگان" width="300" height="130"><span class="brand-copy"><b>چهارسو</b><small>مرکز خدمات مشتریان</small></span></a><a class="header-home" href="index.html?v=1791111429240969400">بازگشت به خدمات ←</a></div></header>
<main class="service-main"><nav class="breadcrumb" aria-label="مسیر صفحه"><a href="index.html?v=1791111429240969400">خدمات چهارسو</a><span aria-hidden="true">/</span><span aria-current="page">شماره حساب‌ها</span></nav>
<div class="title-row"><span class="page-icon"><svg class="icon" aria-hidden="true"><use href="#i-bank"/></svg></span><div><h1>شماره حساب‌ها</h1><p class="subtitle">اطلاعات واریزهای اعلام‌شده توسط کارشناس.</p></div></div>
<p class="finance-guidance" role="note">از کارشناس بخواهید شماره حساب‌ها و شناسهٔ واریز مرتبط را برای شما ارسال کند.</p>
<section class="notice-section" aria-labelledby="notices-title"><div class="list-heading"><div><h2 id="notices-title">واریزهای شما</h2></div><span id="notice-count" class="meta" role="status"></span></div>
<label class="finance-search" for="notice-search"><svg class="icon" aria-hidden="true"><use href="#i-search"/></svg><span class="sr-only">جست‌وجو در اعلامیه‌های واریز</span><input id="notice-search" type="search" placeholder="جست‌وجو با فاکتور، بانک یا موضوع…" autocomplete="off"></label>
<div id="payment-notices" class="payment-notices"></div>
<div id="notices-empty" class="empty-notices" hidden><h3>موردی پیدا نشد</h3><p>شمارهٔ فاکتور یا عبارت دیگری را جست‌وجو کنید.</p><button id="clear-search" type="button" class="finance-button secondary">پاک کردن جست‌وجو</button></div>
<!-- EMPTY STATE: اگر اعلامیه‌ای وجود ندارد، عنوان «هنوز اطلاعات واریزی برای شما ارسال نشده است» و توضیح انتظار برای کارشناس نمایش داده شود؛ هیچ حساب عمومی یا حساب جایگزین نمایش داده نشود. -->
</section>
<template id="notice-template"><article class="payment-notice"><h3><button type="button" class="notice-toggle" aria-expanded="false"><span class="notice-summary"><span class="notice-top"><strong class="notice-title"></strong><span class="payment-state"></span></span><span class="notice-amount"></span><span class="notice-meta"></span></span><svg class="icon" aria-hidden="true"><use href="#i-chevron"/></svg></button></h3>
<div class="notice-detail" hidden><div class="notice-bank"><b class="notice-bank-name"></b><span class="notice-holder"></span></div><dl class="payment-fields essential-fields"></dl>
<section class="notice-extra" aria-label="اطلاعات تکمیلی و توضیحات"><h4>اطلاعات تکمیلی و توضیحات</h4><div class="notice-context"><span class="notice-author"></span><span class="notice-date"></span></div><dl class="extra-fields"></dl></section>
<!-- IMPLEMENTATION NOTE: کپی تکی فقط مبلغ، شماره حساب، شبا، شماره کارت و شناسهٔ واریز موجود را شامل می‌شود.
بانک، صاحب حساب، فاکتور و توضیحات دکمهٔ کپی تکی ندارند؛ اطلاعات تکمیلی همیشه در کارت بازشده نمایان‌اند و کنترل جمع‌شونده ندارند.
«کپی کامل اطلاعات» باید همیشه از دادهٔ کامل اعلامیه ساخته شود، نه از متنِ قابل مشاهدهٔ DOM؛ خلاصه شدن نمایش یا حذف دکمه‌های کپی نباید بانک، صاحب حساب، فاکتور، تاریخ، مبلغ، شماره‌ها، شناسه یا توضیحات را از متن کامل حذف کند. -->
<div class="notice-actions"><button type="button" class="finance-button secondary copy-complete"><svg class="icon" aria-hidden="true"><use href="#i-copy"/></svg>کپی کامل اطلاعات</button><a class="finance-button receipt-link"><svg class="icon" aria-hidden="true"><use href="#i-receipt"/></svg>ارسال فیش این واریز</a></div></div></article></template>
<div class="service-footer"><a href="index.html?v=1791111429240969400">→ بازگشت به خدمات چهارسو</a></div>
<!-- در نسخهٔ اصلی امضا از developer_credit در تنظیمات برند تأمین شود. -->
<p class="page-footer">طراحی و توسعه: محمد جواد فصاحت <a href="tel:09202202129" dir="ltr">09202202129</a></p></main>
<div id="copy-feedback" class="copy-feedback" role="status" aria-live="polite" aria-atomic="true"></div>
<dialog id="manual-copy" class="manual-copy" aria-labelledby="manual-copy-title"><h2 id="manual-copy-title">کپی اطلاعات</h2><p>کپی خودکار در این مرورگر ممکن نشد. متن را انتخاب کنید و از گزینهٔ کپی دستگاه استفاده کنید.</p><textarea id="manual-copy-text" readonly aria-label="متن آماده برای کپی" dir="auto"></textarea><div><button type="button" id="select-copy-text" class="finance-button">انتخاب متن</button><button type="button" id="close-copy" class="finance-button secondary">بستن</button></div></dialog>
<noscript><p class="service-notice">برای مشاهدهٔ تعامل‌های این نمونه، جاوااسکریپت را فعال کنید.</p></noscript></body></html>
Binary file not shown.

After

Width:  |  Height:  |  Size: 67 KiB

+491
View File
@@ -0,0 +1,491 @@
<!doctype html>
<html lang="fa" dir="rtl" data-static-version="1791111429240969400">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover">
<meta name="theme-color" content="#08376b">
<title>چهارسو · پیش‌نمایش مرکز خدمات مشتریان</title>
<link rel="icon" href="../../static/images/favicon.ico?v=1791111429240969400">
<link rel="stylesheet" href="../../static/css/tailwind.css?v=1791111429240969400">
<style>
@font-face{font-family:ChaharsuVazirmatn;src:url('../../static/fonts/vazirmatn-arabic-wght-normal.woff2?v=1791111429240969400') format('woff2');font-weight:100 900;font-display:swap}
@font-face{font-family:ChaharsuLatin;src:url('../../static/fonts/vazirmatn-latin-wght-normal.woff2?v=1791111429240969400') format('woff2');font-weight:100 900;font-display:swap}
:root{--ink:var(--color-ink);--sea:var(--color-sea);--muted:#526c86;--line:#dce5ed}
html{scroll-padding-top:110px}
body{font-family:ChaharsuVazirmatn,ChaharsuLatin,sans-serif}
button,a,input{touch-action:manipulation}
button{font-family:inherit}
button:focus-visible,a:focus-visible,input:focus-visible,summary:focus-visible{outline:3px solid var(--sea);outline-offset:4px}
[hidden]{display:none!important}
svg.icon{width:23px;height:23px;fill:none;stroke:currentColor;stroke-width:1.7;stroke-linecap:round;stroke-linejoin:round;flex-shrink:0}
.preview-bar{background:#e5f4ff;color:var(--ink);font-size:11px;text-align:center;padding:7px 14px;line-height:1.8;border-bottom:1px solid #cde6f7}
.preview-bar b{font-weight:800}
.cs-header{position:sticky;top:0;z-index:30;background:#eeeeeeed;border-bottom:1px solid #dce2e8;backdrop-filter:blur(14px)}
.cs-header-inner{max-width:1120px;margin:auto;padding:0 24px;min-height:76px;display:flex;align-items:center;justify-content:space-between;gap:16px}
.cs-brand{display:flex;align-items:center;gap:15px;color:var(--ink)}
.cs-brand img{width:85px;height:auto}
.brand-divider{width:1px;height:32px;background:#cdd7e0}
.cs-brand b{display:block;font-size:20px;font-weight:900;line-height:1.3}
.cs-brand small{display:block;font-size:10px;color:var(--muted);margin-top:4px}
.cs-desktop-nav{display:flex;align-items:center;gap:8px}
.cs-desktop-nav a{padding:11px 18px;border-radius:12px;font-size:13px;font-weight:700;color:var(--muted)}
.cs-desktop-nav a[aria-current]{color:var(--ink);background:white}
.cs-profile{background:white;border:1px solid var(--line);border-radius:14px;color:var(--ink);min-height:44px;box-shadow:none;font-size:12px;padding:8px 12px;gap:8px}
.cs-profile:hover{background:#e5f4ff;color:var(--ink);box-shadow:none}
.avatar{display:grid;place-items:center;border-radius:10px;width:30px;height:30px;background:#e5f4ff;color:var(--ink);font-weight:800}
.cs-main{max-width:1120px;margin:auto;padding:28px 24px 40px}
.welcome{display:flex;align-items:center;justify-content:space-between;margin-bottom:17px;gap:14px}
.welcome p{font-size:13px;color:var(--muted);margin:0}
.welcome b{color:var(--ink);font-weight:800}
.welcome small{font-size:11px;color:var(--muted)}
.cs-hero{position:relative;isolation:isolate;display:flex;align-items:center;justify-content:space-between;gap:30px;overflow:hidden;background:var(--ink);color:white;border-radius:28px;padding:30px 34px;border-bottom:4px solid var(--color-coral)}
.cs-hero:before{content:'';position:absolute;z-index:-1;width:360px;height:360px;border-radius:50%;left:-160px;top:-210px;background:#0693e340}
.hero-copy{max-width:600px}
.hero-eyebrow{display:flex;align-items:center;gap:8px;color:#c0dff4;font-size:11px;font-weight:700;margin:0 0 12px}
.hero-eyebrow span{width:6px;height:6px;border-radius:50%;background:var(--color-coral)}
.cs-hero h1{font-size:30px;line-height:1.6;margin:0;font-weight:900;letter-spacing:0}
.cs-hero p:last-child{font-size:13px;line-height:1.9;color:#cfdfed;margin:8px 0 0}
.crossroads{width:156px;height:156px;position:relative;flex-shrink:0;display:grid;grid-template-columns:1fr 1fr;gap:9px;padding:10px;transform:rotate(-8deg)}
.crossroads span{border:1px solid #ffffff28;border-radius:18px;background:#ffffff0a;display:grid;place-items:center;color:#92d5ff}
.crossroads span:nth-child(2){background:#0693e33b}
.crossroads span:nth-child(3){background:#fa9f0015;color:#ffc451}
.crossroads:after{content:'';position:absolute;width:20px;height:20px;background:#ffc451;border:6px solid var(--ink);border-radius:50%;top:calc(50% - 10px);left:calc(50% - 10px)}
.section-head{display:flex;justify-content:space-between;align-items:center;gap:16px;margin:28px 0 15px}
.section-head h2{font-size:19px;line-height:1.6;margin:0;font-weight:900}
.section-head p{font-size:12px;color:var(--muted);margin:3px 0 0}
.section-head a{display:flex;align-items:center;gap:6px;font-size:12px;color:#1977ce;font-weight:800;min-height:44px;white-space:nowrap}
.section-label{font-size:10px;font-weight:700;color:#1977ce;display:block;margin-bottom:3px}
.service-grid{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:14px}
.service-card{position:relative;display:flex;flex-direction:column;align-items:stretch;text-align:right;gap:0;padding:20px;border-radius:22px;border:1px solid var(--line);background:white;color:var(--ink);box-shadow:none;min-height:200px;overflow:hidden}
.service-card:hover{background:white;color:var(--ink);box-shadow:0 15px 35px -25px #08376b70;border-color:#0693e350;transform:translateY(-3px)}
.service-card.featured{border-color:#badff5;background:#e5f4ff}
.service-card.featured:hover{background:#dff1fd}
.service-top{display:flex;align-items:center;justify-content:space-between;margin-bottom:20px;gap:6px}
.service-icon{width:44px;height:44px;border-radius:14px;background:#e5f4ff;display:grid;place-items:center;color:#1977ce}
.featured .service-icon{background:#0693e3;color:white}
.future .service-icon{background:#f0f3f6;color:#526c86}
.soon{color:#805300;background:#fff3da;border:1px solid #f3ddae;border-radius:20px;padding:3px 8px;font-size:10px;font-weight:700;white-space:nowrap}
.service-card strong{font-size:15px;font-weight:900;line-height:1.7}
.service-card p{font-size:11px;font-weight:400;color:var(--muted);line-height:1.9;margin:4px 0 14px}
.service-bottom{display:flex;align-items:center;justify-content:space-between;margin-top:auto;color:#1977ce;font-size:11px;font-weight:800}
.future .service-bottom{color:var(--muted)}
.service-bottom svg{width:17px;height:17px}
.profile-note{display:flex;align-items:center;gap:12px;margin-top:18px;border:1px solid #d6e1ea;background:#ffffff80;border-radius:16px;padding:12px 16px}
.profile-note>.icon{color:#1977ce;width:20px;height:20px}
.profile-note p{margin:0;font-size:11px;color:var(--muted);flex:1;line-height:1.9}
.profile-note b{color:var(--ink);font-weight:800}
.text-button{min-height:40px;padding:5px 8px;background:transparent;color:#1977ce;box-shadow:none;font-size:11px;white-space:nowrap}
.text-button:hover{background:#e5f4ff;color:var(--ink);box-shadow:none;transform:none}
.recent-head{margin-top:32px}
.history-panel{background:white;border:1px solid var(--line);border-radius:24px;overflow:hidden}
.history-toolbar{display:flex;align-items:center;justify-content:space-between;padding:14px 18px;gap:16px;border-bottom:1px solid #e7edf2}
.filter-group{display:flex;gap:5px;padding:4px;border-radius:12px;background:#f2f5f7}
.filter-group button{min-height:36px;padding:6px 12px;border-radius:9px;font-size:11px;background:transparent;box-shadow:none;color:var(--muted);font-weight:700}
.filter-group button[aria-pressed=true]{background:white;color:var(--ink);box-shadow:0 2px 5px #08376b0a}
.filter-group button:hover{transform:none;color:var(--ink);background:white}
.search{display:flex;gap:8px;align-items:center;max-width:250px;background:#f6f8fa;border:1px solid #e1e8ef;border-radius:12px;padding:0 10px}
.search svg{width:18px;height:18px;color:var(--muted)}
.search input[type=search]{min-width:0;background:transparent;border:0;box-shadow:none;border-radius:10px;padding:10px 0;font-size:12px;width:100%;min-height:42px}
.search input:focus{box-shadow:none}
.ticket-row{width:100%;display:grid;grid-template-columns:42px minmax(0,1fr) auto 24px;align-items:center;gap:14px;padding:20px;background:white;color:var(--ink);text-align:right;border-radius:0;box-shadow:none;border-bottom:1px solid #e7edf2;min-height:100px}
.ticket-row:last-child{border:0}
.ticket-row:hover{background:#f7fbfe;color:var(--ink);transform:none;box-shadow:none}
.ticket-symbol{display:grid;place-items:center;width:42px;height:42px;border-radius:14px;background:#f0f5f9;color:#1977ce}
.ticket-content strong{display:block;font-size:14px;font-weight:800;line-height:1.8}
.ticket-content small{display:block;margin-top:6px;font-size:10px;color:var(--muted);font-weight:400}
.ticket-side{text-align:left;display:flex;align-items:flex-end;flex-direction:column;gap:8px}
.status{display:inline-flex;align-items:center;gap:5px;font-size:10px;border-radius:20px;padding:4px 9px;font-weight:700;white-space:nowrap;background:#e5f4ff;color:#1260a3}
.status:before{content:'';width:5px;height:5px;background:currentColor;border-radius:50%}
.status.closed{background:#f0f3f6;color:#526c86}
.status.waiting{background:#fff3da;color:#805300}
.ticket-number{font-family:ChaharsuLatin,monospace;font-size:10px;color:var(--muted);font-weight:400}
.row-arrow{color:#7791a8;width:18px!important}
.history-footer{display:flex;align-items:center;justify-content:space-between;gap:12px;border-top:1px solid #e7edf2;padding:10px 18px;font-size:10px;color:var(--muted)}
.history-footer button{font-size:11px}
.empty-result{padding:36px 20px;text-align:center;color:var(--muted);font-size:13px}
.empty-result b{display:block;color:var(--ink);font-size:16px;margin:10px 0}
.cs-footer{display:flex;align-items:center;justify-content:space-between;gap:12px;color:var(--muted);font-size:10px;margin-top:25px;line-height:1.8}
.cs-footer span{display:flex;align-items:center;gap:6px}
.cs-footer svg{width:15px;height:15px}
.cs-dock{display:none}
dialog{width:min(520px,calc(100% - 28px));max-height:calc(100dvh - 40px);margin:auto;overflow-y:auto;overscroll-behavior:contain;padding:24px;border:1px solid var(--line);border-radius:24px;background:white;color:var(--ink);box-shadow:0 30px 100px #08376b35}
dialog::backdrop{background:#06284db3;backdrop-filter:blur(5px)}
.dialog-top{display:flex;align-items:center;justify-content:space-between;margin-bottom:18px}
.close-dialog{min-height:40px;width:40px;padding:0;background:#f1f5f8;color:var(--ink);box-shadow:none;border-radius:12px;font-size:22px;font-weight:400}
.close-dialog:hover{background:#e5f4ff;color:var(--ink);transform:none;box-shadow:none}
dialog h2{font-size:22px;line-height:1.7;font-weight:900}
dialog p{font-size:13px;color:var(--muted);line-height:2;margin:10px 0 18px}
.demo-note{background:#fff7e7;color:#805300;font-size:11px;line-height:1.9;padding:12px;border-radius:12px;margin:14px 0}
.dialog-action{display:flex;width:100%;margin-top:16px}
.account-placeholder{display:flex;align-items:center;justify-content:space-between;border:1px solid var(--line);border-radius:12px;padding:14px;margin-top:10px;font-size:12px;gap:12px}
.account-placeholder b{font-weight:700}
.upload-demo{display:block;border:1px dashed #a3c7df;border-radius:16px;padding:22px;background:#f5fbff;text-align:center}
.upload-demo input[type=file]{font-size:12px;margin-top:12px;padding:8px;background:white}
.upload-demo span{font-size:12px;color:var(--muted);font-weight:400;display:block}
#file-status{font-size:12px}
@media(min-width:900px){.cs-main{padding-top:30px}.cs-hero{min-height:220px}.ticket-row{grid-template-columns:42px minmax(0,1fr) 185px 24px}}
@media(max-width:899px){.service-grid{grid-template-columns:repeat(2,minmax(0,1fr))}.cs-desktop-nav{gap:0}.cs-desktop-nav a{padding:10px}.cs-hero h1{font-size:27px}.crossroads{width:130px;height:130px}}
@media(max-width:639px){
html{scroll-padding-top:85px}
.preview-bar{font-size:10px;padding:5px 12px}
.cs-header-inner{min-height:65px;padding:0 18px}
.cs-brand{gap:10px}.cs-brand img{width:68px}.brand-divider{height:28px}.cs-brand b{font-size:18px}.cs-brand small{font-size:9px}
.cs-desktop-nav{display:none}.cs-profile{width:40px;min-height:40px;padding:0;background:transparent;border:0}.cs-profile .profile-label{display:none}
.cs-main{padding:18px 18px 110px}
.welcome{margin-bottom:13px}.welcome p{font-size:12px}.welcome small{font-size:10px}
.cs-hero{padding:22px;border-radius:26px;gap:0;min-height:184px}
.cs-hero h1{font-size:24px;line-height:1.65;max-width:245px}.hero-eyebrow{font-size:10px;margin-bottom:7px}.cs-hero p:last-child{font-size:12px;max-width:270px;margin-top:7px}
.crossroads{position:absolute;left:-15px;top:16px;width:105px;height:105px;opacity:.22;z-index:-1;gap:6px}.crossroads svg{width:18px;height:18px}
.section-head{margin-top:22px;margin-bottom:12px;gap:10px}.section-head h2{font-size:18px}.section-head p{font-size:11px}.section-head a{font-size:11px}.section-label{font-size:10px}
.service-grid{gap:10px}.service-card{padding:15px;border-radius:20px;min-height:174px}.service-top{margin-bottom:13px;gap:4px}.service-icon{width:37px;height:37px;border-radius:12px}.service-icon svg{width:21px;height:21px}.soon{font-size:9px;padding:3px 7px}
.service-card strong{font-size:13px;line-height:1.8}.service-card p{font-size:10px;line-height:1.9;margin:3px 0 10px}.service-bottom{font-size:10px}
.profile-note{gap:8px;padding:11px 12px;margin-top:14px}.profile-note p{font-size:10px}.profile-note p span{display:none}.profile-note .text-button{font-size:10px}
.recent-head{margin-top:24px}
.history-panel{border-radius:22px}.history-toolbar{padding:12px;flex-direction:column;align-items:stretch;gap:10px}.filter-group{align-self:stretch;justify-content:space-between}.filter-group button{flex:1;padding:6px 5px;font-size:10px}.search{max-width:none}.search input[type=search]{font-size:12px;min-height:40px}
.ticket-row{padding:16px 14px;grid-template-columns:minmax(0,1fr) auto;gap:10px;min-height:115px}.ticket-symbol,.row-arrow{display:none!important}.ticket-content strong{font-size:13px}.ticket-content small{font-size:10px;line-height:1.9;max-width:175px}.ticket-number{font-size:9px}.ticket-side{gap:10px}.status{font-size:9px;padding:4px 7px}
.history-footer{padding:10px 12px;font-size:9px}.history-footer button{font-size:10px}.cs-footer{flex-direction:column;align-items:center;font-size:9px;gap:8px}
.cs-dock{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));position:fixed;bottom:max(10px,env(safe-area-inset-bottom));inset-inline:12px;z-index:40;background:#08376bf5;padding:6px;border:1px solid #ffffff75;border-radius:22px;box-shadow:0 15px 35px #08376b35;backdrop-filter:blur(12px)}
.cs-dock a,.cs-dock button{display:flex;flex-direction:column;align-items:center;justify-content:center;gap:5px;min-height:52px;border-radius:16px;color:#c4d7e9;font-size:10px;font-weight:700;background:transparent;padding:5px 2px;box-shadow:none}
.cs-dock [aria-current]{background:var(--sea);color:white}.cs-dock svg{width:20px;height:20px}.cs-dock button:hover{transform:none;box-shadow:none;background:#ffffff14;color:white}
dialog{padding:20px}dialog h2{font-size:20px}
}
@media(max-width:359px){.cs-main{padding-inline:12px}.cs-header-inner{padding-inline:12px}.service-card{padding:12px}.service-card strong{font-size:12px}.service-card p{font-size:10px}.ticket-row{grid-template-columns:1fr}.ticket-side{flex-direction:row;justify-content:space-between;align-items:center}.welcome small{display:none}}
@media(prefers-reduced-motion:reduce){html{scroll-behavior:auto}*,*:before,*:after{animation:none!important;transition:none!important}}
/* Service-first home: no history feed or profile reminder on this view. */
.services-intro{margin:4px 0 24px;max-width:640px}
.services-intro .section-label{margin-bottom:10px;color:#1977ce;font-size:12px}
.services-intro h1{font-size:30px;line-height:1.7;letter-spacing:0;font-weight:900;margin:0}
.services-intro p{margin:7px 0 0;font-size:13px;line-height:1.9;color:var(--muted)}
.service-grid{gap:16px}
.service-card{min-height:220px;padding:24px}
.service-card strong{font-size:17px}
.service-card p{font-size:12px}
.service-icon{width:48px;height:48px}
.service-bottom{font-size:12px}
.service-card.featured{background:var(--ink);border-color:var(--ink);color:white;border-bottom:3px solid var(--color-coral)}
.service-card.featured:hover{background:#0b4c8c;color:white}
.featured p{color:#cadbea}
.featured .service-bottom{color:white}
.featured .service-icon{background:#0693e3}
.back-to-services{display:inline-flex;align-items:center;min-height:44px;font-size:12px;font-weight:700;color:#1977ce;margin-bottom:8px}
#requests .recent-head{margin-top:8px}
#requests h1{font-size:26px;line-height:1.6;font-weight:900}
.cs-dock{grid-template-columns:repeat(3,minmax(0,1fr))}
@media(max-width:639px){
.cs-main{padding-top:22px}
.services-intro{margin:0 0 22px}
.services-intro .section-label{font-size:10px;margin-bottom:7px}
.services-intro h1{font-size:23px;line-height:1.7}
.services-intro p{font-size:12px;line-height:1.9;margin-top:6px}
.service-grid{gap:12px}
.service-card{min-height:191px;padding:17px 15px}
.service-top{margin-bottom:17px}
.service-icon{width:42px;height:42px}
.service-card strong{font-size:14px}
.service-card p{font-size:11px;margin-bottom:15px}
.service-bottom{font-size:11px}
.cs-footer{margin-top:24px}
#requests h1{font-size:22px}
}
@media(max-width:359px){.services-intro h1{font-size:21px}.service-card{padding:14px 12px;min-height:190px}.service-card strong{font-size:13px}.service-card p{font-size:10px}.service-bottom{font-size:10px}}
.request-service{grid-column:span 2;cursor:default;min-height:154px;padding:20px}
.service-card.request-service:hover{transform:none;box-shadow:none;background:var(--ink);border-color:var(--ink);border-bottom-color:var(--color-coral)}
.request-service-head{display:flex;align-items:center;gap:10px}
.request-service-head .service-icon{flex-shrink:0;width:34px;height:34px;border-radius:11px}
.request-service-head .icon{width:20px;height:20px}
.request-service h2{font-size:17px;line-height:1.6;font-weight:900;margin:0}
.request-actions{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:10px;padding-top:14px;margin-top:auto}
.request-action{display:flex;align-items:center;justify-content:center;gap:8px;min-height:46px;padding:9px 12px;border-radius:12px;color:white;text-align:center;border:1px solid transparent;box-shadow:none}
.request-action>span{min-width:0}
.request-action b{display:block;font-size:13px;line-height:1.7;font-weight:800;white-space:nowrap}
.request-action .icon{width:20px;height:20px}
.request-create{background:var(--sea)}
.request-create:hover{background:#1977ce;color:white;transform:none;box-shadow:none}
.request-history{background:#ffffff0d;border-color:#ffffff40}
.request-history:hover{background:#ffffff1a;color:white;border-color:#ffffff70}
.services-intro{margin:0 0 20px}
.services-intro h1{font-size:28px;line-height:1.6}
.service-card.future{min-height:184px}
@media(max-width:899px){.request-service{min-height:0}}
@media(max-width:639px){
.cs-main{padding:12px 16px 95px}
.services-intro{margin:0 0 12px}
.services-intro h1{font-size:22px;line-height:1.6}
.services-intro p{font-size:12px;margin-top:4px;line-height:1.8}
.service-grid{gap:10px}
.request-service{padding:12px 14px;min-height:0;border-radius:20px}
.request-service h2{font-size:15px}
.request-service-head .service-icon{width:30px;height:30px;border-radius:10px}
.request-service-head .icon{width:18px;height:18px}
.request-actions{gap:8px;padding-top:10px}
.request-action{min-height:44px;padding:9px 8px;gap:6px;border-radius:11px}
.request-action b{font-size:12px}
.request-action .icon{width:18px;height:18px}
.service-card.future{min-height:150px;padding:12px 13px;border-radius:20px}
.future .service-top{margin-bottom:8px}
.future .service-icon{width:31px;height:31px;border-radius:11px}
.future .service-icon .icon{width:20px;height:20px}
.future strong{font-size:13px}
.service-card.future p{font-size:11px;line-height:1.8;margin:4px 0 10px}
.future .service-bottom{font-size:10px}
.cs-footer{margin-top:18px}
}
@media(max-width:359px){
.cs-main{padding-inline:12px}
.request-action b{font-size:11px}
.service-card.future p{font-size:10px}
.service-card.future strong{font-size:12px}
}
.request-service .request-description{font-size:12px;font-weight:400;line-height:1.9;color:#cadbea;margin:6px 0 0;max-width:390px}
.developer-credit{justify-content:center;text-align:center}
.developer-credit p{margin:0;font-size:inherit;line-height:1.9}
.developer-credit a{color:inherit;white-space:nowrap}
.developer-credit a:hover{color:#1977ce}
/* Readable service text at every width; keep the full expert description visible. */
.mobile-copy{display:none}
.request-service h2{font-size:20px}
.request-service .request-description{display:block;font-size:14px;line-height:1.7;max-width:none}
.request-action b{font-size:14px}
.service-card.future strong{font-size:17px}
.service-card.future p{font-size:14px;line-height:1.7}
.future .service-bottom{font-size:13px}
.soon{font-size:12px}
.developer-credit{font-size:12px}
@media(max-width:639px){
.cs-main{padding-top:10px}
.services-intro{margin-bottom:12px}
.services-intro h1{font-size:22px}
.request-service-head{display:grid;grid-template-columns:minmax(0,1fr) auto;gap:0 10px;align-items:center}
.request-service-head>.service-icon{grid-column:2;grid-row:1}
.request-service-head>div{display:contents}
.request-service h2{grid-column:1;grid-row:1;font-size:18px}
.request-service .request-description{grid-column:1/-1;margin-top:6px;font-size:14px;line-height:1.65}
.request-action{gap:5px;padding-inline:7px}
.request-action b{font-size:13px}
.request-action .icon{width:16px;height:16px}
.service-card.future{min-height:120px;padding:11px 12px}
.future .service-top{margin-bottom:7px}
.future .service-icon{width:28px;height:28px}
.service-card.future strong{font-size:14px}
.service-card.future p{font-size:14px;line-height:1.65;margin-bottom:0}
.future .service-bottom{display:none}
.soon{font-size:12px;padding:2px 6px}
.desktop-copy{display:none}
.mobile-copy{display:inline}
}
.cs-brand small{font-size:11px}
.cs-desktop-nav a{font-size:14px}
@media(max-width:639px){
.request-action b{font-size:14px}
.cs-dock a,.cs-dock button{font-size:12px}
}
@media(max-width:359px){.request-action>.icon{display:none}.request-action{padding-inline:6px}}
/* Requests view: apply STYLE_GUIDE typography, spacing and touch targets. */
#requests .back-to-services{font-size:14px;color:var(--ink);min-height:44px}
#requests h1{font-size:28px;line-height:1.5;font-weight:900}
#requests .section-head p{font-size:14px;line-height:1.7;color:var(--muted)}
#requests .section-head{align-items:center;flex-wrap:wrap;gap:12px;margin-bottom:16px}
#requests .text-button{font-size:14px;min-height:44px;color:var(--ink);padding:8px 10px}
#requests .text-button:hover{color:var(--ink);background:#e5f4ff}
#requests .history-panel{border-radius:22px}
#requests .history-toolbar{padding:16px;gap:12px}
#requests .filter-group{gap:4px;border-radius:12px}
#requests .filter-group button{display:inline-flex;align-items:center;justify-content:center;gap:5px;min-height:44px;min-width:44px;padding:8px 12px;font-size:14px;line-height:1.5;color:var(--muted);white-space:nowrap}
#requests .filter-group button[aria-pressed=true]{color:var(--ink);background:white}
#requests .filter-group button span{font-size:12px}
#requests .search{max-width:300px;min-width:0;width:100%;border-color:var(--line)}
#requests .search input[type=search]{font-size:16px;line-height:1.5;min-height:44px;color:var(--ink);padding:10px 0}
#requests .search input::placeholder{color:var(--muted);opacity:1}
#requests .ticket-row{grid-template-columns:42px minmax(0,1fr) 230px 20px;gap:16px;padding:20px;min-height:112px}
#requests .ticket-content{min-width:0}
#requests .ticket-content strong{font-size:16px;line-height:1.7;font-weight:800;overflow-wrap:anywhere}
#requests .ticket-content small{font-size:12px;line-height:1.7;font-weight:400;color:var(--muted);max-width:none}
#requests .ticket-side{min-width:0;gap:8px;text-align:end}
#requests .status{font-size:12px;line-height:1.6;padding:4px 9px}
#requests .ticket-number{font-size:14px;line-height:1.7;overflow-wrap:anywhere;max-width:100%}
#requests .history-footer{padding:10px 16px;font-size:12px;line-height:1.7;flex-wrap:wrap;gap:8px}
#requests .history-footer button{font-size:14px}
#requests .empty-result{font-size:14px;line-height:1.7;padding:24px 16px}
#requests .empty-result b{font-size:18px}
#requests .ticket-symbol{background:#e5f4ff;color:var(--ink)}
#requests .row-arrow{color:var(--muted)}
/* White text on dark blue meets the guide's contrast mapping for this nav item. */
.cs-dock a[href="#requests"][aria-current]{background:var(--color-ink-soft);color:white}
#service-dialog:has(.request-detail-number) p{font-size:14px;line-height:1.7}
#service-dialog:has(.request-detail-number) .demo-note{font-size:14px;line-height:1.7}
#service-dialog:has(.request-detail-number) .close-dialog{min-height:44px;width:44px}
#service-dialog:has(.request-detail-number) h2{font-size:20px}
@media(max-width:899px){
#requests .ticket-row{grid-template-columns:42px minmax(0,1fr) 190px 20px}
#requests .history-toolbar{flex-wrap:wrap}
}
@media(max-width:639px){
#requests h1{font-size:24px}
#requests .section-head{align-items:flex-start;gap:8px}
#requests .history-panel{border-radius:20px}
#requests .history-toolbar{padding:12px;gap:10px}
#requests .filter-group button{padding:8px 5px;gap:4px;flex:1}
#requests .search{max-width:none}
#requests .ticket-row{grid-template-columns:minmax(0,1fr);gap:10px;padding:16px 14px;min-height:0}
#requests .ticket-content strong{font-size:14px;line-height:1.7}
#requests .ticket-content small{margin-top:4px}
#requests .ticket-side{flex-direction:row;justify-content:space-between;align-items:center;flex-wrap:wrap;gap:8px}
#requests .ticket-number{font-size:14px}
#requests .history-footer{padding:10px 12px}
}
</style>
</head>
<body>
<svg xmlns="http://www.w3.org/2000/svg" style="position:absolute;width:0;height:0;overflow:hidden" aria-hidden="true">
<symbol id="i-home" viewBox="0 0 24 24"><path d="m3 10 9-7 9 7v10a1 1 0 0 1-1 1h-5v-7H9v7H4a1 1 0 0 1-1-1Z"/></symbol>
<symbol id="i-plus" viewBox="0 0 24 24"><path d="M12 5v14M5 12h14"/></symbol>
<symbol id="i-chat" viewBox="0 0 24 24"><path d="M5 4h14a2 2 0 0 1 2 2v10a2 2 0 0 1-2 2H9l-6 3V6a2 2 0 0 1 2-2Z"/><path d="M7 9h10M7 13h6"/></symbol>
<symbol id="i-history" viewBox="0 0 24 24"><path d="M3 11a9 9 0 1 1 2 7M3 5v6h6M12 7v5l3 2"/></symbol>
<symbol id="i-receipt" viewBox="0 0 24 24"><path d="M5 3h14v18l-3-2-4 2-4-2-3 2ZM9 8h6M9 12h6"/></symbol>
<symbol id="i-bank" viewBox="0 0 24 24"><path d="m3 7 9-4 9 4v3H3ZM5 10v7M10 10v7M14 10v7M19 10v7M3 17h18v4H3"/></symbol>
<symbol id="i-grid" viewBox="0 0 24 24"><rect x="3" y="3" width="7" height="7" rx="2"/><rect x="14" y="3" width="7" height="7" rx="2"/><rect x="3" y="14" width="7" height="7" rx="2"/><rect x="14" y="14" width="7" height="7" rx="2"/></symbol>
<symbol id="i-user" viewBox="0 0 24 24"><circle cx="12" cy="8" r="4"/><path d="M4 21v-2a8 8 0 0 1 16 0v2"/></symbol>
<symbol id="i-arrow" viewBox="0 0 24 24"><path d="M20 12H4m6-6-6 6 6 6"/></symbol>
<symbol id="i-search" viewBox="0 0 24 24"><circle cx="10" cy="10" r="6"/><path d="m15 15 6 6"/></symbol>
<symbol id="i-shield" viewBox="0 0 24 24"><path d="m12 3 8 3v6c0 5-8 9-8 9s-8-4-8-9V6Z"/><path d="m8 12 3 3 5-6"/></symbol>
</svg>
<div class="preview-bar"><b>پیش‌نمایش طراحی چهارسو</b> · اطلاعات نمونه است و هیچ درخواستی ارسال نمی‌شود.</div>
<header class="cs-header">
<div class="cs-header-inner">
<a class="cs-brand" href="#home" aria-label="چهارسو، خانه"><img src="../../static/images/company-logo.png?v=1791111429240969400" alt="لوله گستر گلپایگان" width="300" height="130"><span class="brand-divider" aria-hidden="true"></span><span><b>چهارسو</b><small>مرکز خدمات مشتریان</small></span></a>
<nav class="cs-desktop-nav" aria-label="منوی اصلی"><a href="#home" aria-current="page">خدمات چهارسو</a><a href="#requests">درخواست‌های من</a></nav>
<button class="cs-profile" data-dialog="profile" aria-label="مشاهده پروفایل"><span class="avatar">م</span><span class="profile-label">پروفایل من</span></button>
</div>
</header>
<main class="cs-main">
<section id="home" aria-labelledby="services-title">
<section id="services" aria-labelledby="services-title">
<div class="services-intro"><h1 id="services-title" tabindex="-1">خدمات چهارسو</h1></div>
<div class="service-grid">
<section class="service-card featured request-service" aria-labelledby="request-service-title">
<div class="request-service-head"><span class="service-icon"><svg class="icon" aria-hidden="true"><use href="#i-chat"/></svg></span><div><h2 id="request-service-title">ارتباط با کارشناسان</h2><p class="request-description">سؤال بپرسید، درخواست ثبت کنید یا برای پیگیری و هماهنگی با کارشناسان در ارتباط باشید.</p></div></div>
<div class="request-actions">
<button class="request-action request-create" data-dialog="new" aria-label="ثبت درخواست جدید"><svg class="icon" aria-hidden="true"><use href="#i-plus"/></svg><span><b>ثبت درخواست</b></span></button>
<a class="request-action request-history" href="#requests"><svg class="icon" aria-hidden="true"><use href="#i-history"/></svg><span><b>درخواست‌های من</b></span></a>
</div>
</section>
<!-- IMPLEMENTATION NOTE: ارسال فیش واریزی و شماره حساب‌ها دو خدمت مستقل‌اند.
در پیاده‌سازی Django برای هرکدام URL نام‌گذاری‌شده، view و template جدا تعریف شود.
این کارت‌ها باید لینک ناوبری به صفحهٔ خدمت باشند؛ آن‌ها را به modal یا data-dialog تبدیل نکنید.
نام فایل‌ها در اینجا فقط مربوط به نمونهٔ مستقل است؛ مسیر نهایی Django هنوز تعیین نشده است. -->
<a class="service-card future" href="payment-receipt.html?v=1791111429240969400">
<span class="service-top"><span class="service-icon"><svg class="icon" aria-hidden="true"><use href="#i-receipt"/></svg></span><span class="soon">به‌زودی</span></span>
<strong>ارسال فیش واریزی</strong><p><span class="desktop-copy">ارسال تصویر فیش برای بررسی پرداخت</span><span class="mobile-copy">ثبت تصویر پرداخت</span></p><span class="service-bottom">صفحهٔ ارسال فیش<svg class="icon" aria-hidden="true"><use href="#i-arrow"/></svg></span>
</a>
<a class="service-card future" href="bank-accounts.html?v=1791111429240969400">
<span class="service-top"><span class="service-icon"><svg class="icon" aria-hidden="true"><use href="#i-bank"/></svg></span><span class="soon">به‌زودی</span></span>
<strong>شماره حساب‌ها</strong><p><span class="desktop-copy">دریافت شماره حساب، شبا و شناسهٔ واریز</span><span class="mobile-copy">حساب، شبا و شناسهٔ واریز</span></p><span class="service-bottom">مشاهده حساب‌ها<svg class="icon" aria-hidden="true"><use href="#i-arrow"/></svg></span>
</a>
</div>
</section>
</section>
<section id="requests" aria-labelledby="recent-title" hidden>
<a class="back-to-services" href="#home">→ بازگشت به خدمات</a><div class="section-head recent-head"><div><h1 id="recent-title" tabindex="-1">درخواست‌های من</h1><p>پاسخ‌ها را ببینید و گفت‌وگوهای قبلی را ادامه دهید.</p></div><button class="text-button" data-dialog="new">+ درخواست جدید</button></div>
<div class="history-panel">
<div class="history-toolbar">
<div class="filter-group" role="group" aria-label="فیلتر درخواست‌ها"><button data-filter="all" aria-pressed="true">همه <span>۵</span></button><button data-filter="open" aria-pressed="false">در جریان <span>۲</span></button><button data-filter="closed" aria-pressed="false">بسته <span>۳</span></button></div>
<label class="search" style="margin:0"><svg class="icon" aria-hidden="true"><use href="#i-search"/></svg><input type="search" id="ticket-search" aria-label="جست‌وجوی درخواست‌ها" placeholder="جست‌وجوی موضوع یا شماره…"></label>
</div>
<div id="ticket-rows">
<button class="ticket-row" data-state="open" data-dialog="ticket" data-title="استعلام قیمت لوله و اتصالات" data-number="TK-2026-95742E53"><span class="ticket-symbol"><svg class="icon" aria-hidden="true"><use href="#i-chat"/></svg></span><span class="ticket-content"><strong>استعلام قیمت لوله و اتصالات</strong><small>فروش · پیش‌فاکتور و خرید · صف عمومی</small></span><span class="ticket-side"><span class="status">در حال بررسی</span><span class="ticket-number" dir="ltr">TK-2026-95742E53</span></span><svg class="icon row-arrow" aria-hidden="true"><use href="#i-arrow"/></svg></button>
<button class="ticket-row" data-state="open" data-dialog="ticket" data-title="پیگیری زمان تحویل سفارش" data-number="TK-2026-74B21A08"><span class="ticket-symbol"><svg class="icon" aria-hidden="true"><use href="#i-chat"/></svg></span><span class="ticket-content"><strong>پیگیری زمان تحویل سفارش</strong><small>فروش · پیگیری سفارش · آخرین فعالیت: ۲۴ شهریور</small></span><span class="ticket-side"><span class="status waiting">در انتظار پاسخ شما</span><span class="ticket-number" dir="ltr">TK-2026-74B21A08</span></span><svg class="icon row-arrow" aria-hidden="true"><use href="#i-arrow"/></svg></button>
<button class="ticket-row" data-state="closed" data-dialog="ticket" data-title="آزمایشی: درخواست قیمت" data-number="TK-2026-32C85F10"><span class="ticket-symbol"><svg class="icon" aria-hidden="true"><use href="#i-chat"/></svg></span><span class="ticket-content"><strong>آزمایشی: درخواست قیمت</strong><small>فروش · پیش‌فاکتور و خرید · ۲۴ شهریور ۱۴۰۵</small></span><span class="ticket-side"><span class="status closed">بسته</span><span class="ticket-number" dir="ltr">TK-2026-32C85F10</span></span><svg class="icon row-arrow" aria-hidden="true"><use href="#i-arrow"/></svg></button>
<button class="ticket-row" data-state="closed" data-dialog="ticket" data-title="مشاوره انتخاب محصول" data-number="TK-2026-AB61D922" data-older hidden><span class="ticket-symbol"><svg class="icon" aria-hidden="true"><use href="#i-chat"/></svg></span><span class="ticket-content"><strong>مشاوره انتخاب محصول</strong><small>فروش · مشاوره خرید · ۲۰ شهریور ۱۴۰۵</small></span><span class="ticket-side"><span class="status closed">بسته</span><span class="ticket-number" dir="ltr">TK-2026-AB61D922</span></span><svg class="icon row-arrow" aria-hidden="true"><use href="#i-arrow"/></svg></button>
<button class="ticket-row" data-state="closed" data-dialog="ticket" data-title="دریافت پیش‌فاکتور خرید" data-number="TK-2026-4F26C818" data-older hidden><span class="ticket-symbol"><svg class="icon" aria-hidden="true"><use href="#i-chat"/></svg></span><span class="ticket-content"><strong>دریافت پیش‌فاکتور خرید</strong><small>فروش · پیش‌فاکتور و خرید · ۱۸ شهریور ۱۴۰۵</small></span><span class="ticket-side"><span class="status closed">بسته</span><span class="ticket-number" dir="ltr">TK-2026-4F26C818</span></span><svg class="icon row-arrow" aria-hidden="true"><use href="#i-arrow"/></svg></button>
</div>
<div class="empty-result" id="no-results" role="status" hidden><b>درخواستی پیدا نشد</b>موضوع یا شمارهٔ دیگری جست‌وجو کنید.<br><button class="text-button" id="reset-search">پاک کردن جست‌وجو و فیلتر</button></div>
<div class="history-footer"><span id="result-count" aria-live="polite">نمایش ۳ درخواست از ۵ مورد</span><button class="text-button" id="show-all" aria-expanded="false">مشاهده همهٔ درخواست‌ها ←</button></div>
</div>
</section>
<!-- IMPLEMENTATION NOTE: در نسخهٔ اصلی امضای توسعه‌دهنده از تنظیمات برند (developer_credit) تأمین شود. -->
<footer class="cs-footer developer-credit"><p>طراحی و توسعه: محمد جواد فصاحت <a href="tel:09202202129" dir="ltr">09202202129</a></p></footer>
</main>
<nav class="cs-dock" aria-label="دسترسی سریع مشتری"><a href="#home" aria-current="page"><svg class="icon" aria-hidden="true"><use href="#i-grid"/></svg>خدمات</a><a href="#requests"><svg class="icon" aria-hidden="true"><use href="#i-history"/></svg>درخواست‌ها</a><button data-dialog="profile"><svg class="icon" aria-hidden="true"><use href="#i-user"/></svg>پروفایل</button></nav>
<dialog id="service-dialog" aria-labelledby="dialog-title">
<div class="dialog-top"><span class="service-icon" id="dialog-icon"><svg class="icon" aria-hidden="true"><use href="#i-chat"/></svg></span><button class="close-dialog" id="close-dialog" aria-label="بستن پنجره">×</button></div>
<h2 id="dialog-title"></h2><div id="dialog-body"></div>
<button class="dialog-action" id="dialog-done">متوجه شدم</button>
</dialog>
<script>
(() => {
const dialog=document.querySelector('#service-dialog');
const title=document.querySelector('#dialog-title');
const body=document.querySelector('#dialog-body');
const rows=[...document.querySelectorAll('.ticket-row')];
let activeFilter='all',expanded=false;
document.addEventListener('click', event=>{
const trigger=event.target.closest('[data-dialog]');
if(trigger){
const kind=trigger.dataset.dialog;
const icon={new:'plus',profile:'user',ticket:'chat'}[kind];
document.querySelector('#dialog-icon use').setAttribute('href','#i-'+icon);
if(kind==='new'){
title.textContent='ثبت درخواست جدید';
body.innerHTML='<p>موضوعتان را انتخاب کنید و گفت‌وگو با کارشناسان را آغاز کنید.</p><div class="demo-note">این نسخه برای بررسی طراحی است. فرم نمونه نیز اطلاعاتی ارسال نمی‌کند.</div><a class="button dialog-action" href="../ticket-new-wizard/index.html?v=1791111429240969400">مشاهده فرم نمونه ←</a>';
}else if(kind==='profile'){
title.textContent='پروفایل شما';
body.innerHTML='<p>نام، نام خانوادگی، استان و شهر به کارشناسان کمک می‌کند دقیق‌تر راهنمایی‌تان کنند.</p><div class="demo-note">تکمیل این اطلاعات اختیاری است و ثبت درخواست را متوقف نمی‌کند. ویرایش پروفایل در این نمونه به حساب واقعی متصل نیست.</div>';
}else{
title.textContent=trigger.dataset.title;
body.replaceChildren();
const number=document.createElement('p');number.className='request-detail-number';number.dir='ltr';number.textContent=trigger.dataset.number;
const note=document.createElement('div');note.className='demo-note';note.textContent='این درخواست نمونه است. در نسخهٔ اصلی، این انتخاب صفحهٔ گفت‌وگو، پیام‌ها و پیوست‌های همین درخواست را باز می‌کند.';
body.append(number,note);
}
dialog.showModal();
}
const filter=event.target.closest('[data-filter]');
if(filter){activeFilter=filter.dataset.filter;document.querySelectorAll('[data-filter]').forEach(b=>b.setAttribute('aria-pressed',String(b===filter)));render();}
});
const showView=(focus=false)=>{
const isHistory=location.hash==='#requests';
document.querySelector('#home').hidden=isHistory;
document.querySelector('#requests').hidden=!isHistory;
document.querySelectorAll('.cs-dock a,.cs-desktop-nav a').forEach(a=>{
a.removeAttribute('aria-current');
if(a.hash===(isHistory?'#requests':'#home'))a.setAttribute('aria-current','page');
});
document.title=isHistory?'درخواست‌های من · چهارسو':'چهارسو · پیش‌نمایش مرکز خدمات مشتریان';
if(focus){window.scrollTo({top:0,behavior:'instant'});document.querySelector(isHistory?'#recent-title':'#services-title').focus({preventScroll:true});}
};
window.addEventListener('hashchange',()=>showView(true));
showView();
const render=()=>{
const query=document.querySelector('#ticket-search').value.trim().toLowerCase().replace(/ي/g,'ی').replace(/ك/g,'ک');
let shown=0,total=0;
rows.forEach(row=>{
const matches=(activeFilter==='all'||row.dataset.state===activeFilter)&&row.textContent.toLowerCase().replace(/ي/g,'ی').replace(/ك/g,'ک').includes(query);
if(matches)total++;
row.hidden=!(matches&&(expanded||activeFilter!=='all'||query||!row.hasAttribute('data-older')));
if(!row.hidden)shown++;
});
document.querySelector('#no-results').hidden=shown!==0;
document.querySelector('#result-count').textContent='نمایش '+shown.toLocaleString('fa-IR')+' درخواست از '+total.toLocaleString('fa-IR')+' مورد';
const all=document.querySelector('#show-all');
all.hidden=expanded||total===shown;
all.setAttribute('aria-expanded',String(expanded));
};
document.querySelector('#ticket-search').addEventListener('input',render);
document.querySelector('#show-all').addEventListener('click',()=>{expanded=true;render();});
document.querySelector('#reset-search').addEventListener('click',()=>{
document.querySelector('#ticket-search').value='';activeFilter='all';
document.querySelectorAll('[data-filter]').forEach(b=>b.setAttribute('aria-pressed',String(b.dataset.filter==='all')));
render();document.querySelector('#ticket-search').focus();
});
document.querySelector('#close-dialog').addEventListener('click',()=>dialog.close());
document.querySelector('#dialog-done').addEventListener('click',()=>dialog.close());
dialog.addEventListener('click',event=>{if(event.target===dialog){const r=dialog.getBoundingClientRect();if(event.clientX<r.left||event.clientX>r.right||event.clientY<r.top||event.clientY>r.bottom)dialog.close();}});
})();
</script>
</body>
</html>
Binary file not shown.

After

Width:  |  Height:  |  Size: 49 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 53 KiB

@@ -0,0 +1,17 @@
/* DESIGN FIXTURES ONLY. These are invalid bank numbers, never payment destinations.
Production: supply only payment notices addressed to the authenticated customer.
Account, card, IBAN, deposit reference, expected amount and invoice belong to EACH notice.
Do not replace this with a client-side list of all company bank accounts. */
window.chaharsuPaymentNotices = [
{id:'DEMO-101', title:'پرداخت فاکتور خرید لوله', invoice:'DEMO-F-1405-218', issued:'۱۴۰۵/۰۷/۱۲', author:'واحد فروش', amount:'280000000', currency:'ریال', bank:'بانک ملت (نمونه)', holder:'لوله گستر گلپایگان (نمونه)', account:'000000000101', iban:'IR000000000000000000000101', card:'0000000000000101', deposit:'000101218', description:'برای فاکتور اعلام‌شده واریز کنید و شناسهٔ واریز همین مورد را درج کنید. پس از پرداخت، تصویر فیش را از دکمهٔ زیر بفرستید.', receiptReceived:false},
{id:'DEMO-102', title:'پرداخت هزینهٔ حمل سفارش', invoice:'DEMO-F-1405-219', issued:'۱۴۰۵/۰۷/۱۱', author:'واحد مالی', amount:'18500000', currency:'ریال', bank:'بانک تجارت (نمونه)', holder:'لوله گستر گلپایگان (نمونه)', account:'000000000202', iban:'IR000000000000000000000202', card:null, deposit:'000202219', description:'این مورد مربوط به هزینهٔ حمل است و شماره حساب و شناسهٔ آن با فاکتور خرید متفاوت است.', receiptReceived:false},
{id:'DEMO-103', title:'تسویهٔ ماندهٔ فاکتور', invoice:'DEMO-F-1405-203', issued:'۱۴۰۵/۰۷/۰۹', author:'واحد مالی', amount:'96000000', currency:'ریال', bank:'بانک ملی (نمونه)', holder:'لوله گستر گلپایگان (نمونه)', account:'000000000303', iban:'IR000000000000000000000303', card:'0000000000000303', deposit:'000303203', description:'فیش قبلی این مورد دریافت شده و در انتظار بررسی کارشناس است. دریافت فیش به معنی تأیید نهایی پرداخت نیست.', receiptReceived:true}
];
/* DESTINATION FIXTURES ONLY: production must query authorized, active company accounts from the database.
Return opaque ID + display_name only to this dropdown; no account/card/IBAN numbers. */
window.chaharsuCompanyAccounts=[
{id:'DEMO-ACC-101',name:'حساب فروش شرکت — ملت'},
{id:'DEMO-ACC-202',name:'حساب هزینهٔ حمل — تجارت'},
{id:'DEMO-ACC-303',name:'حساب تسویهٔ شرکت — ملی'}
];
@@ -0,0 +1,80 @@
<!doctype html>
<html lang="fa" dir="rtl" data-static-version="1791111429240969400">
<head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover"><meta name="theme-color" content="#08376b"><title>ارسال فیش واریزی · چهارسو</title>
<link rel="icon" href="../../static/images/favicon.ico?v=1791111429240969400"><link rel="stylesheet" href="../../static/css/tailwind.css?v=1791111429240969400"><link rel="stylesheet" href="service-pages.css?v=1791111429240969400">
<script defer src="payment-demo-data.js?v=1791111429240969400"></script><script defer src="payment-services.js?v=1791111429240969400"></script><script defer src="receipt-date.js?v=1791111429240969400"></script><script defer src="receipt-amount.js?v=1791111429240969400"></script><script defer src="receipt-banks.js?v=1791111429240969400"></script><script defer src="receipt-validation.js?v=1791111429240969400"></script></head>
<body data-finance-page="receipt">
<!-- DESIGN PROTOTYPE ONLY: صفحهٔ مستقل با URL/view/template مستقل در پیاده‌سازی اصلی؛ به modal خانه تبدیل نشود.
پوسته از base.html و دارایی‌ها از static_asset_version استفاده کنند. هیچ اتصال به دیتابیس یا ارسال واقعی وجود ندارد.
اطلاعات پرداخت تنها برای مشتری مخاطب اعلامیه از بک‌اند تأمین شود؛ فهرست عمومی حساب‌ها نمایش داده نشود.
پنهان کردن شماره‌ها در UI کنترل دسترسی نیست: مجوز مشاهده/ثبت فیش و ارتباط اعلامیه با مشتری باید در سرور بررسی شود.
پارامتر notice فقط شناسهٔ اعلامیه است، نه مبلغ یا شماره حساب؛ سرور دادهٔ معتبر را دوباره دریافت کند. -->
<svg xmlns="http://www.w3.org/2000/svg" style="position:absolute;width:0;height:0;overflow:hidden" aria-hidden="true">
<symbol id="i-receipt" viewBox="0 0 24 24"><path d="M5 3h14v18l-3-2-4 2-4-2-3 2ZM9 8h6M9 12h6"/></symbol>
<symbol id="i-bank" viewBox="0 0 24 24"><path d="m3 7 9-4 9 4v3H3ZM5 10v7M10 10v7M14 10v7M19 10v7M3 17h18v4H3"/></symbol>
<symbol id="i-copy" viewBox="0 0 24 24"><rect x="8" y="8" width="12" height="13" rx="2"/><path d="M16 8V3H3v13h5"/></symbol>
<symbol id="i-chevron" viewBox="0 0 24 24"><path d="m6 9 6 6 6-6"/></symbol>
<symbol id="i-upload" viewBox="0 0 24 24"><path d="M12 16V3m-5 5 5-5 5 5M4 16v5h16v-5"/></symbol>
<symbol id="i-search" viewBox="0 0 24 24"><circle cx="10" cy="10" r="6"/><path d="m15 15 6 6"/></symbol></svg><div class="preview-bar"><b>پیش‌نمایش طراحی چهارسو</b> · داده‌ها ساختگی‌اند؛ پرداخت و ارسال واقعی انجام نمی‌شود.</div>
<header class="page-header"><div class="header-inner"><a class="brand" href="index.html?v=1791111429240969400"><img src="../../static/images/company-logo.png?v=1791111429240969400" alt="لوله گستر گلپایگان" width="300" height="130"><span class="brand-copy"><b>چهارسو</b><small>مرکز خدمات مشتریان</small></span></a><a class="header-home" href="index.html?v=1791111429240969400">بازگشت به خدمات ←</a></div></header>
<main class="service-main"><nav class="breadcrumb" aria-label="مسیر صفحه"><a href="index.html?v=1791111429240969400">خدمات چهارسو</a><span aria-hidden="true">/</span><span aria-current="page">ارسال فیش واریزی</span></nav>
<div class="title-row"><span class="page-icon"><svg class="icon" aria-hidden="true"><use href="#i-receipt"/></svg></span><div><h1>ارسال فیش واریزی</h1><p class="subtitle">فیش را مستقل یا برای یکی از واریزهای اعلام‌شده ارسال کنید.</p></div></div>
<form id="receipt-form" enctype="multipart/form-data" novalidate>
<div id="receipt-errors" class="receipt-errors" role="alert" hidden><h2>لطفاً این موارد را اصلاح کنید</h2><ul></ul></div>
<section class="content-card" aria-labelledby="association-title"><h2 id="association-title">این فیش مربوط به کدام واریز است؟</h2>
<fieldset class="receipt-mode"><legend class="sr-only">نوع فیش واریزی</legend>
<label class="recommended-mode"><input type="radio" name="receipt-mode" value="linked" checked aria-describedby="linked-recommendation"><span><b>واریز مرتبط <span class="recommended-badge">پیشنهادی</span></b><small>با اعلامیهٔ واریز کارشناس</small></span></label>
<label><input type="radio" name="receipt-mode" value="independent"><span><b>واریز مستقل</b><small>بدون اعلامیهٔ واریز</small></span></label></fieldset>
<p id="linked-recommendation" class="finance-guidance">برای ثبت دقیق‌تر، از کارشناس بخواهید اعلامیهٔ واریز ایجاد کند؛ این کار توصیه می‌شود و اجباری نیست.</p>
<!-- UX CONTRACT: واریز مرتبط همیشه اولین گزینه و دارای نشان پیشنهادی است. واریز مستقل همچنان قابل انتخاب است، حتی اگر هیچ اعلامیه‌ای وجود نداشته باشد. -->
<div id="linked-payment" hidden><label for="notice-select" class="field-label">انتخاب واریز اعلام‌شده</label><select id="notice-select" name="notice_id" aria-describedby="notice-selection-help notice-error"><option value="">یک مورد را انتخاب کنید</option></select><p id="notice-selection-help" class="field-help">مبلغ و شمارهٔ فاکتور را با واریز انجام‌شده تطبیق دهید.</p><p id="notice-error" class="field-error" hidden></p><p id="notice-invalid" class="inline-error" role="alert" hidden>این اعلامیه در دسترس نیست. مورد دیگری را انتخاب کنید یا نوع فیش را به واریز مستقل تغییر دهید.</p>
<div id="selected-payment" class="selected-payment" hidden><h3 id="selected-title"></h3><dl id="selected-fields" class="payment-fields compact-fields"></dl><a id="selected-details" class="text-action">مشاهدهٔ اطلاعات و کپی شماره‌ها ←</a></div></div>
<p id="independent-help" class="field-help independent-help">اگر اطلاعات واریز را بیرون از چهارسو دریافت کرده‌اید، این گزینه را انتخاب کنید.</p></section>
<section class="content-card" aria-labelledby="receipt-title"><h2 id="receipt-title">اطلاعات فیش پرداخت</h2><p>مبلغ، حساب مقصد، تاریخ کامل و تصویر فیش الزامی‌اند.</p>
<div class="receipt-field-grid"><div><label for="paid-amount" class="field-label">مبلغ پرداخت‌شده <span>(ریال · اجباری)</span></label><input id="paid-amount" required aria-required="true" type="text" inputmode="numeric" autocomplete="off" placeholder="مثلاً ۲۸۰۰۰۰۰۰۰" aria-describedby="amount-words amount-help amount-error"><p id="amount-words" class="amount-words" role="status" aria-live="polite" aria-atomic="true"></p><p id="amount-help" class="field-help">مبلغی که واقعاً پرداخت کرده‌اید؛ ممکن است با مبلغ اعلام‌شده متفاوت باشد.</p><p id="amount-error" class="field-error" hidden></p><input id="paid-amount-value" name="paid_amount" type="hidden" value=""></div><div><label for="receipt-reference" class="field-label">شمارهٔ پیگیری بانکی <span>(اختیاری)</span></label><input id="receipt-reference" name="receipt-reference" type="text" inputmode="numeric" autocomplete="off" placeholder="شمارهٔ درج‌شده روی فیش"></div></div>
<div class="receipt-field-grid bank-fields"><div><label for="origin-bank" class="field-label">بانک مبدأ <span>(اختیاری)</span></label><select id="origin-bank" name="origin_bank"><option value="">انتخاب بانک مبدأ</option></select><div id="origin-other-field" hidden><label for="origin-bank-other" class="field-label">نام بانک دیگر <span>(اختیاری)</span></label><input id="origin-bank-other" name="origin_bank_other" type="text" maxlength="80" disabled autocomplete="off" placeholder="نام بانک را بنویسید"></div></div>
<div><label for="destination-account" class="field-label">به کدام حساب ما واریز کردید؟ <span>(اجباری)</span></label><select id="destination-account" name="destination_account_id" required aria-required="true" aria-describedby="destination-help destination-error"><option value="">انتخاب حساب مقصد</option></select><p id="destination-help" class="field-help">نام حسابی را که وجه به آن واریز کرده‌اید انتخاب کنید.</p><p id="destination-error" class="field-error" hidden></p></div></div>
<!-- BANK CONTRACT: حساب‌های مقصد از دیتابیس شرکت تأمین می‌شوند؛ در این فهرست فقط display_name و ID مجاز ارائه شوند، نه شماره حساب، شبا یا کارت.
انتخاب مقصد اجباری و توسط مشتری است؛ انتخاب اعلامیه یا تغییر نوع فیش نباید حساب مقصد را خودکار عوض کند.
سرور باید destination_account_id را در حساب‌های فعال/مجاز شرکت بررسی کند و نام حساب را از دیتابیس بازیابی کند، نه از متن ارسال‌شده توسط کاربر.
فهرست بانک مبدأ محلی و اختیاری است و گزینهٔ سایر دارد؛ فهرست اصلی باید با نام‌های بانکی معتبر به‌روز نگه داشته شود.
مبلغ نمایشی گروه‌بندی هزارگان دارد، ولی paid_amount ریالی بدون جداکننده است؛ تبدیل به حروف تومان از BigInt و باقی‌ماندهٔ دقیق استفاده می‌کند و گرد نمی‌شود. -->
<fieldset class="receipt-date" aria-describedby="receipt-date-help receipt-date-status date-error"><legend>تاریخ واریز <span>(شمسی · اجباری)</span></legend>
<div class="date-selects"><div><label for="receipt-day">روز</label><select id="receipt-day" required aria-required="true" aria-describedby="date-error"><option value="">روز</option></select></div><div><label for="receipt-month">ماه</label><select id="receipt-month" required aria-required="true" aria-describedby="date-error"><option value="">ماه</option></select></div><div><label for="receipt-year">سال</label><select id="receipt-year" required aria-required="true" aria-describedby="date-error"><option value="">سال</option></select></div></div>
<div class="date-tools"><p id="receipt-date-help">تاریخ درج‌شده روی فیش را انتخاب کنید.</p><button id="receipt-date-today" class="finance-button secondary" type="button">امروز</button></div>
<p id="receipt-date-status" role="status" aria-live="polite">روز پرداخت را انتخاب کنید.</p>
<p id="date-error" class="field-error" hidden></p>
<input id="receipt-date-value" name="paid_date" type="hidden" value="">
<!-- DATE CONTRACT: انتخابگر روز/ماه/سال شمسی با select بومی مرورگر و Intl، بدون CDN یا کتابخانهٔ خارجی.
روز از ابتدا خالی است؛ ماه و سال جاری فقط برای سهولت انتخاب‌اند. امروز بر اساس Asia/Tehran است.
فیلد paid_date تنها مقدار تاریخ قابل ارسال است: تاریخ میلادیِ فقط‌روز با ارقام ASCII و قالب YYYY-MM-DD. selectهای نمایشی name ندارند و تاریخ شمسی به سرور ارسال نمی‌کنند.
سرور باید paid_date را به‌عنوان تاریخ میلادی اعتبارسنجی کند؛ نام فیلد با قرارداد فرم/API نهایی هماهنگ شود. تبدیل در مرورگر از مبدأ دقیق سال شمسی و با کنترل برگشتی انجام می‌شود؛ رشتهٔ شمسی به Date.parse داده نشود.
بازهٔ سال‌ها در نمونه: ده سال قبل تا یک سال بعد؛ بازهٔ اجرایی با نیاز واقعی و اعتبارسنجی سرور هماهنگ شود.
تغییر نوع فیش یا اعلامیه نباید تاریخ پرداخت واردشده را تغییر دهد. تاریخ اعلامیه، تاریخ واقعی پرداخت نیست. -->
</fieldset>
<div class="upload-zone"><svg class="icon" aria-hidden="true"><use href="#i-upload"/></svg><label for="receipt-file" class="file-picker-label">انتخاب تصویر فیش <span>(اجباری)</span></label><input id="receipt-file" class="sr-only" type="file" name="receipt_image" required aria-required="true" accept=".jpg,.jpeg,.png,.gif,.webp" data-max-bytes="10485760" aria-describedby="upload-help file-error"><span id="upload-help">تصویر JPG، PNG، GIF یا WEBP؛ حداکثر ۱۰ مگابایت.</span></div>
<p id="file-error" class="field-error" hidden></p>
<div class="file-selection" id="file-selection" hidden><p id="file-status" role="status" aria-live="polite"></p><button id="clear-file" type="button">حذف انتخاب</button></div>
<label for="receipt-note" class="field-label note-label">توضیحات <span>(اختیاری)</span></label><textarea id="receipt-note" name="receipt-note" rows="3" placeholder="مثلاً شمارهٔ فاکتور یا توضیح دربارهٔ پرداخت"></textarea>
<!-- IMPLEMENTATION CONTRACT: مدل فیش دو حالت دارد: اعلامیهٔ مرتبط (شناسهٔ معتبر متعلق به مشتری) یا مستقل (بدون شناسه).
تغییر حالت باید ارتباط قبلی را حذف کند؛ داده‌های بانکی و مبلغ اعلام‌شده از سرور دریافت شوند، نه از query string یا hidden inputs.
مبلغ پرداخت‌شده با مبلغ مورد انتظار فرق دارد؛ فرم آن را خودکار بازنویسی نکند. مبلغ و واحد ریال جدا و روشن‌اند.
پس از ذخیرهٔ واقعی و اعتبارسنجی فایل، رسید ثبت و وضعیت بررسی نمایش داده شود؛ انتخاب فایل یا کلیک هرگز موفقیت ساختگی نسازد.
دکمه در این نمونه فقط اعتبارسنجی می‌کند و هیچ ارسال واقعی ندارد؛ پس از اعتبارسنجی موفق، پیام «ارسال نشده» نمایش داده شود. برای فیش مرتبط، شمارهٔ اعلامیه/فاکتور نیز همراه رسید ثبت نمایش داده شود. -->
<div class="receipt-summary" id="receipt-summary" role="status">نوع فیش: واریز مستقل</div>
<p id="receipt-validation-status" class="validation-status" role="status" aria-live="polite"></p>
<!-- VALIDATION CONTRACT: مبلغ مثبت صحیح ریال، تاریخ کامل میلادی معتبر و تصویر قابل خواندن الزامی‌اند؛ اعلامیه برای نوع مرتبط الزامی است.
کنترل‌های نمایش مبلغ و شمسی name ندارند؛ پارامترهای مالی FormData: paid_amount عدد ریالی ASCII بدون جداکننده، paid_date میلادی YYYY-MM-DD، receipt_image و destination_account_id اجباری؛ origin_bank اختیاری و notice_id فقط برای حالت مرتبط است.
حد تصویر این نمونه مطابق پیش‌فرض bootstrap پروژه است؛ در نسخهٔ اصلی حد حجم/پسوند/MIME از AttachmentPolicy معتبر سرور دریافت شوند.
اعتبارسنجی کلاینت جای اعتبارسنجی سرور را نمی‌گیرد؛ سرور مقدار، تاریخ، مالکیت اعلامیه و محتوای واقعی فایل را دوباره بررسی کند. -->
<div class="receipt-actions"><button id="submit-receipt" type="submit" disabled>بررسی و ادامه</button><a href="bank-accounts.html?v=1791111429240969400">مشاهدهٔ واریزهای اعلام‌شده ←</a></div></section></form>
<div class="service-footer"><a href="index.html?v=1791111429240969400">→ بازگشت به خدمات چهارسو</a></div>
<!-- در نسخهٔ اصلی امضا از developer_credit در تنظیمات برند تأمین شود. -->
<p class="page-footer">طراحی و توسعه: محمد جواد فصاحت <a href="tel:09202202129" dir="ltr">09202202129</a></p></main>
<div id="copy-feedback" class="copy-feedback" role="status" aria-live="polite" aria-atomic="true"></div>
<dialog id="manual-copy" class="manual-copy" aria-labelledby="manual-copy-title"><h2 id="manual-copy-title">کپی اطلاعات</h2><p>کپی خودکار در این مرورگر ممکن نشد. متن را انتخاب کنید و از گزینهٔ کپی دستگاه استفاده کنید.</p><textarea id="manual-copy-text" readonly aria-label="متن آماده برای کپی" dir="auto"></textarea><div><button type="button" id="select-copy-text" class="finance-button">انتخاب متن</button><button type="button" id="close-copy" class="finance-button secondary">بستن</button></div></dialog>
<noscript><p class="service-notice">برای مشاهدهٔ تعامل‌های این نمونه، جاوااسکریپت را فعال کنید.</p></noscript></body></html>
@@ -0,0 +1,159 @@
/* Local UI prototype. No requests, storage, upload or database writes.
Keep opaque notice IDs in links. Production must check ownership server-side. */
(() => {
'use strict';
const version=document.documentElement.dataset.staticVersion;
const notices=window.chaharsuPaymentNotices || [];
const byId=id=>notices.find(notice=>notice.id===id);
const query=selector=>document.querySelector(selector);
const money=notice=>new Intl.NumberFormat('fa-IR').format(Number(notice.amount))+' '+notice.currency;
const fields=notice=>[
['مبلغ واریز',money(notice),notice.amount,true],
['نام بانک',notice.bank,notice.bank,false],
['صاحب حساب',notice.holder,notice.holder,false],
['شماره حساب',notice.account,notice.account,true],
['شماره شبا',notice.iban,notice.iban,true],
['شماره کارت',notice.card || 'اعلام نشده',notice.card,true],
['شناسهٔ واریز',notice.deposit || 'اعلام نشده',notice.deposit,true],
['شماره فاکتور',notice.invoice || 'اعلام نشده',notice.invoice,true],
['توضیحات کارشناس',notice.description || 'توضیحی ثبت نشده',notice.description,false]
];
const link=(page,id)=>page+'?v='+version+(id?'&notice='+encodeURIComponent(id):'');
function copyTextFor(notice){
return ['اطلاعات نمایشی چهارسو — شماره‌ها ساختگی و غیرقابل پرداخت‌اند.',notice.title,
'شمارهٔ اعلامیه: '+notice.id,'تاریخ اعلام: '+notice.issued,'اعلام‌کننده: '+notice.author,
...fields(notice).map(([label,value])=>label+': '+value)].join('\n');
}
let feedbackTimer;
function feedback(message){
clearTimeout(feedbackTimer);query('#copy-feedback').textContent=message;
feedbackTimer=setTimeout(()=>{query('#copy-feedback').textContent='';},6500);
}
function fallbackCopy(text){
const area=document.createElement('textarea');area.value=text;area.setAttribute('aria-label','متن کپی');
area.style.cssText='position:fixed;top:0;left:0;width:1px;height:1px;opacity:0';
document.body.append(area);area.select();let copied=false;
try{copied=document.execCommand('copy');}catch(error){copied=false;}finally{area.remove();}
return copied;
}
async function copyValue(text,label){
const previousFocus=document.activeElement;
let copied=false;
try{if(navigator.clipboard?.writeText){await navigator.clipboard.writeText(text);copied=true;}}catch(error){copied=false;}
if(!copied)copied=fallbackCopy(text);
previousFocus?.focus({preventScroll:true});
if(copied){feedback(label+' کپی شد. اکنون می‌توانید آن را جای‌گذاری کنید.');return;}
feedback('کپی خودکار ممکن نشد؛ متن آمادهٔ انتخاب است.');
const dialog=query('#manual-copy');query('#manual-copy-text').value=text;
dialog.showModal();query('#manual-copy-text').focus();query('#manual-copy-text').select();
}
function appendField(container,field,copyable=true){
const [label,value,raw,ltr]=field;
const row=document.createElement('div');row.className='payment-field';
const term=document.createElement('dt');term.textContent=label;
const definition=document.createElement('dd');definition.textContent=value;
if(ltr){definition.dir='ltr';definition.className='numeric-value';}
row.append(term,definition);
if(copyable && raw){
const button=document.createElement('button');button.type='button';button.className='copy-field';
button.textContent='کپی';button.setAttribute('aria-label','کپی '+label);
button.addEventListener('click',()=>copyValue(raw,label));row.append(button);
}else{row.classList.add('no-copy');}
container.append(row);
}
function makeNotice(notice){
const article=query('#notice-template').content.firstElementChild.cloneNode(true);
const button=article.querySelector('.notice-toggle');const detail=article.querySelector('.notice-detail');
article.id=notice.id;button.id='toggle-'+notice.id;detail.id='details-'+notice.id;
button.setAttribute('aria-controls',detail.id);detail.setAttribute('aria-labelledby',button.id);
article.querySelector('.notice-title').textContent=notice.title;
article.querySelector('.notice-amount').textContent=money(notice);
article.querySelector('.notice-meta').textContent='فاکتور '+notice.invoice+' · '+notice.issued;
const state=article.querySelector('.payment-state');
state.textContent=notice.receiptReceived?'فیش در حال بررسی':'در انتظار فیش';
state.classList.toggle('received',notice.receiptReceived);
article.querySelector('.notice-author').textContent='اعلام‌شده توسط '+notice.author;
article.querySelector('.notice-date').textContent='اعلامیهٔ '+notice.id;
article.querySelector('.notice-bank-name').textContent=notice.bank;
article.querySelector('.notice-holder').textContent=notice.holder;
const container=article.querySelector('.essential-fields');
// Compact UI is a projection; copyTextFor always uses the full notice, including all supplementary information.
const allFields=fields(notice);
[0,3,4,5,6].forEach(index=>{const field=allFields[index];if(field[2])appendField(container,field);});
const extra=article.querySelector('.extra-fields');
[7,8].forEach(index=>appendField(extra,allFields[index],false));
article.querySelector('.copy-complete').addEventListener('click',()=>copyValue(copyTextFor(notice),'اطلاعات کامل'));
article.querySelector('.receipt-link').href=link('payment-receipt.html',notice.id);
button.addEventListener('click',()=>toggleNotice(article,notice));
return article;
}
function openNotice(article){
document.querySelectorAll('.payment-notice').forEach(item=>{
const open=item===article;item.querySelector('.notice-toggle').setAttribute('aria-expanded',String(open));
item.querySelector('.notice-detail').hidden=!open;
});
}
function toggleNotice(article,notice){
const open=article.querySelector('.notice-toggle').getAttribute('aria-expanded')==='true';
openNotice(open?null:article);
const url=new URL(location.href);url.searchParams.delete('notice');
if(!open)url.searchParams.set('notice',notice.id);
history.replaceState(null,'',url.href);
}
function initAccounts(){
const container=query('#payment-notices');notices.forEach(notice=>container.append(makeNotice(notice)));
const search=query('#notice-search');
function filter(){
const term=search.value.trim().toLocaleLowerCase('fa');let count=0;
notices.forEach(notice=>{
const article=document.getElementById(notice.id);
const match=[notice.title,notice.invoice,notice.bank,notice.id].join(' ').toLocaleLowerCase('fa').includes(term);
article.hidden=!match;if(match)count++;
});
query('#notice-count').textContent=new Intl.NumberFormat('fa-IR').format(count)+' مورد';
query('#notices-empty').hidden=count!==0;
}
search.addEventListener('input',filter);
query('#clear-search').addEventListener('click',()=>{search.value='';filter();search.focus();});filter();
if(!notices.length){query('#notices-empty h3').textContent='هنوز اطلاعات واریزی برای شما ارسال نشده است';query('#notices-empty p').textContent='پس از اعلام کارشناس، اطلاعات هر واریز در این بخش نمایش داده می‌شود.';query('#clear-search').hidden=true;search.disabled=true;}
const id=new URLSearchParams(location.search).get('notice');
if(id){const notice=byId(id);if(notice){openNotice(document.getElementById(id));}else{feedback('این اعلامیه در دسترس نیست. فقط موارد اعلام‌شده به شما نمایش داده می‌شوند.');}}
}
function initReceipt(){
const select=query('#notice-select');
notices.forEach(notice=>{const option=document.createElement('option');option.value=notice.id;option.textContent=notice.invoice+' · '+money(notice)+' · '+notice.bank;select.append(option);});
if(!notices.length)query('#notice-selection-help').textContent='هنوز اعلامیه‌ای ندارید؛ از کارشناس بخواهید ایجاد کند یا واریز مستقل را انتخاب کنید.';
const modes=document.querySelectorAll('input[name="receipt-mode"]');
let invalid=false;
function sync(){
const linked=query('input[name="receipt-mode"]:checked').value==='linked';
query('#linked-payment').hidden=!linked;query('#independent-help').hidden=linked;
select.disabled=!linked;select.required=linked;
if(!linked){select.value='';invalid=false;}
const notice=linked?byId(select.value):null;
query('#notice-invalid').hidden=!invalid;
select.setAttribute('aria-invalid',String(invalid));
query('#selected-payment').hidden=!notice;
query('#selected-fields').replaceChildren();
if(notice){
query('#selected-title').textContent=notice.title;
[['مبلغ اعلام‌شده',money(notice),false],['شماره فاکتور',notice.invoice,true],['بانک مقصد',notice.bank,false],['شناسهٔ واریز',notice.deposit || 'اعلام نشده',true]].forEach(([label,value,ltr])=>appendField(query('#selected-fields'),[label,value,null,ltr],false));
query('#selected-details').href=link('bank-accounts.html',notice.id);
}
query('#receipt-summary').textContent=linked?(notice?'این فیش به فاکتور '+notice.invoice+' مرتبط می‌شود.':'برای فیش مرتبط، ابتدا واریز اعلام‌شده را انتخاب کنید.'):'نوع فیش: واریز مستقل؛ ارتباطی با اعلامیه‌های قبلی ندارد.';
const url=new URL(location.href);url.searchParams.delete('notice');if(notice)url.searchParams.set('notice',notice.id);
history.replaceState(null,'',url.href);
}
const incoming=new URLSearchParams(location.search).get('notice');
if(incoming){query('input[value="linked"]').checked=true;if(byId(incoming)){select.value=incoming;}else{invalid=true;}}
modes.forEach(mode=>mode.addEventListener('change',sync));select.addEventListener('change',()=>{invalid=false;sync();});sync();
const input=query('#receipt-file');const selection=query('#file-selection');const status=query('#file-status');
input.addEventListener('change',()=>{const file=input.files[0];selection.hidden=!file;status.textContent=file?'فایل انتخاب‌شده: '+file.name+' · ارسال نشده':'';});
query('#clear-file').addEventListener('click',()=>{input.value='';selection.hidden=true;status.textContent='';input.focus();});
query('#receipt-form').addEventListener('submit',event=>event.preventDefault());
}
query('#select-copy-text').addEventListener('click',()=>{query('#manual-copy-text').focus();query('#manual-copy-text').select();});
query('#close-copy').addEventListener('click',()=>query('#manual-copy').close());
if(document.body.dataset.financePage==='accounts')initAccounts();
if(document.body.dataset.financePage==='receipt')initReceipt();
})();
@@ -0,0 +1,77 @@
/* Local amount presentation: never convert rials to tomans for the server.
Exact BigInt arithmetic; preserve fractional toman (one rial = one tenth toman).
Visible grouping and Persian words do not replace paid_amount validation. */
(() => {
'use strict';
const input=document.querySelector('#paid-amount');if(!input)return;
const words=document.querySelector('#amount-words');
const small=['صفر','یک','دو','سه','چهار','پنج','شش','هفت','هشت','نه','ده','یازده','دوازده','سیزده','چهارده','پانزده','شانزده','هفده','هجده','نوزده'];
const tens=['','','بیست','سی','چهل','پنجاه','شصت','هفتاد','هشتاد','نود'];
const hundreds=['','صد','دویست','سیصد','چهارصد','پانصد','ششصد','هفتصد','هشتصد','نهصد'];
const scales=['','هزار','میلیون','میلیارد','تریلیون','کوادریلیون'];
const ascii=value=>value.replace(/[۰-۹]/g,digit=>String(digit.charCodeAt(0)-1776)).replace(/[٠-٩]/g,digit=>String(digit.charCodeAt(0)-1632));
const digitCount=value=>(ascii(value).match(/\d/g) || []).length;
const separators=/[,٬ \u00a0]/g;
const grouped=value=>value.replace(/\B(?=(\d{3})+(?!\d))/g,'٬').replace(/\d/g,digit=>'۰۱۲۳۴۵۶۷۸۹'[digit]);
function threeDigits(value){
const parts=[];
if(value>=100)parts.push(hundreds[Math.floor(value/100)]);
const rest=value%100;
if(rest && rest<20)parts.push(small[rest]);
if(rest>=20){parts.push(tens[Math.floor(rest/10)]);if(rest%10)parts.push(small[rest%10]);}
return parts.join(' و ');
}
function numberWords(value){
if(value===0n)return 'صفر';
const parts=[];let index=0;
while(value>0n){
const group=Number(value%1000n);
if(group){const text=group===1 && index===1?'هزار':threeDigits(group)+(scales[index]?' '+scales[index]:'');parts.unshift(text);}
value/=1000n;index++;
}
return parts.join(' و ');
}
function tomanWords(rials){
const whole=rials/10n,fraction=rials%10n;
if(!fraction)return numberWords(whole)+' تومان';
return (whole?numberWords(whole)+' و ':'')+numberWords(fraction)+' دهم تومان';
}
function caretAfterDigits(value,count){
if(!count)return 0;
for(let index=0;index<value.length;index++)if(/\d/.test(ascii(value[index])) && --count===0)return index+1;
return value.length;
}
function formatAmount(event){
if(event?.isComposing)return;
const raw=input.value,start=input.selectionStart ?? raw.length,end=input.selectionEnd ?? start;
const normalized=ascii(raw).trim();
const valid=/^\d+$/.test(normalized) || /^\d{1,3}(?:[,٬ \u00a0]\d{3})+$/.test(normalized);
// Re-group after genuine digit edits/deletion, but never sanitize letters, signs or decimal input.
const editing=event?.inputType?.startsWith('delete') || (event?.inputType==='insertText' && /^\d+$/.test(ascii(event.data || '')));
if(!valid && !(editing && /^[\d,٬ \u00a0]+$/.test(normalized))){words.textContent='';return;}
const digits=normalized.replace(separators,'').replace(/^0+(?=\d)/,'');
if(!digits || digits.length>16 || BigInt(digits)>BigInt(Number.MAX_SAFE_INTEGER)){words.textContent='';return;}
const leading=normalized.replace(separators,'').length-digits.length;
const left=Math.max(0,digitCount(raw.slice(0,start))-leading),right=Math.max(0,digitCount(raw.slice(0,end))-leading);
input.value=grouped(digits);
input.setSelectionRange(caretAfterDigits(input.value,left),caretAfterDigits(input.value,right));
words.textContent='معادل: '+tomanWords(BigInt(digits));
}
input.addEventListener('input',formatAmount);
input.addEventListener('compositionend',()=>formatAmount());
input.addEventListener('beforeinput',event=>{
const start=input.selectionStart,end=input.selectionEnd;
if(start!==end || event.isComposing)return;
const backward=event.inputType==='deleteContentBackward',forward=event.inputType==='deleteContentForward';
const adjacent=backward?start-1:start;
if((!backward && !forward) || !/[٬,]/.test(input.value[adjacent] || ''))return;
// Deleting a separator removes the adjacent digit, so grouping cannot trap the caret.
const target=backward?adjacent-1:adjacent+1;
const count=digitCount(input.value.slice(0,target));
const raw=input.value.slice(0,target)+input.value.slice(target+1);
event.preventDefault();input.value=ascii(raw).replace(separators,'');
input.setSelectionRange(count,count);input.dispatchEvent(new InputEvent('input',{bubbles:true,inputType:event.inputType}));
});
input.addEventListener('blur',()=>formatAmount());
formatAmount();
})();
@@ -0,0 +1,29 @@
/* All option labels are local text. No logos/CDN/bank APIs.
Destination fixtures are ID + display name ONLY; production supplies them from the company DB.
Origin catalog includes familiar and historical names for matching older receipts; maintain centrally. */
(() => {
'use strict';
const origin=document.querySelector('#origin-bank');if(!origin)return;
const banks=[
['melli','ملی ایران'],['mellat','ملت'],['saderat','صادرات ایران'],['sepah','سپه'],['tejarat','تجارت'],
['refah','رفاه کارگران'],['keshavarzi','کشاورزی'],['maskan','مسکن'],['sanat-madan','صنعت و معدن'],
['tosee-saderat','توسعه صادرات ایران'],['tosee-taavon','توسعه تعاون'],['post','پست بانک ایران'],
['pasargad','پاسارگاد'],['parsian','پارسیان'],['saman','سامان'],['eghtesad-novin','اقتصاد نوین'],
['karafarin','کارآفرین'],['sina','سینا'],['shahr','شهر'],['sarmayeh','سرمایه'],['dey','دی'],
['gardeshgari','گردشگری'],['iran-zamin','ایران زمین'],['khavarmianeh','خاورمیانه'],
['resalat','قرض‌الحسنه رسالت'],['mehr-iran','قرض‌الحسنه مهر ایران'],['iran-venezuela','مشترک ایران و ونزوئلا'],
['melal','مؤسسه اعتباری ملل']
];
function option(container,id,name){container.append(new Option(name,id));}
banks.sort((left,right)=>left[1].localeCompare(right[1],'fa')).forEach(([id,name])=>option(origin,id,name));
const historical=document.createElement('optgroup');historical.label='نام‌های قدیمی روی فیش‌ها';
[['ayandeh','آینده'],['ansar','انصار'],['ghavamin','قوامین'],['hekmat','حکمت ایرانیان'],['mehr-eghtesad','مهر اقتصاد'],['kosar','مؤسسه کوثر'],['noor','مؤسسه نور'],['tosee','مؤسسه توسعه']].forEach(([id,name])=>option(historical,id,name));
origin.append(historical);option(origin,'other','سایر');
origin.addEventListener('change',()=>{
const other=origin.value==='other';document.querySelector('#origin-other-field').hidden=!other;
document.querySelector('#origin-bank-other').disabled=!other;
});
const destination=document.querySelector('#destination-account');
(window.chaharsuCompanyAccounts || []).forEach(account=>option(destination,account.id,account.name));
if(!destination.options[1])document.querySelector('#destination-help').textContent='هنوز حسابی برای انتخاب در دسترس نیست؛ با کارشناس هماهنگ کنید.';
})();
@@ -0,0 +1,81 @@
/* Local Persian date selector. No libraries, CDN, network or production writes.
Use Intl's Persian calendar to determine leap Esfand; don't approximate with year % 4.
Display is Jalali; submission is Gregorian YYYY-MM-DD, date-only (no timezone timestamp). */
(() => {
'use strict';
const day=document.querySelector('#receipt-day');
if(!day)return;
const month=document.querySelector('#receipt-month');
const year=document.querySelector('#receipt-year');
const output=document.querySelector('#receipt-date-value');
const status=document.querySelector('#receipt-date-status');
const todayButton=document.querySelector('#receipt-date-today');
const monthNames=['فروردین','اردیبهشت','خرداد','تیر','مرداد','شهریور','مهر','آبان','آذر','دی','بهمن','اسفند'];
const digits=value=>String(value).replace(/\d/g,digit=>'۰۱۲۳۴۵۶۷۸۹'[digit]);
const formatter=new Intl.DateTimeFormat('en-US',{calendar:'persian',numberingSystem:'latn',timeZone:'Asia/Tehran',year:'numeric',month:'numeric',day:'numeric'});
function parts(date){
const values=Object.fromEntries(formatter.formatToParts(date).map(part=>[part.type,part.value]));
return {year:Number(values.year),month:Number(values.month),day:Number(values.day)};
}
function newYear(selectedYear){
// The native Persian calendar provides the exact first day; bounded March search.
for(let marchDay=18;marchDay<=24;marchDay++){
const date=new Date(Date.UTC(selectedYear+621,2,marchDay,12));
const value=parts(date);
if(value.year===selectedYear && value.month===1 && value.day===1)return date.getTime();
}
throw new Error('Persian new year unavailable');
}
const yearDays=new Map();
function monthLength(selectedYear,selectedMonth){
if(selectedMonth<=6)return 31;
if(selectedMonth<=11)return 30;
if(!yearDays.has(selectedYear))yearDays.set(selectedYear,(newYear(selectedYear+1)-newYear(selectedYear))/86400000);
return yearDays.get(selectedYear)===366?30:29;
}
function addOption(select,value,label){
const option=document.createElement('option');option.value=String(value);option.textContent=label;select.append(option);
}
function updateDays(){
const previous=day.value;
const length=year.value && month.value?monthLength(Number(year.value),Number(month.value)):31;
day.replaceChildren();addOption(day,'','روز');
for(let value=1;value<=length;value++)addOption(day,value,digits(value));
const invalid=previous && Number(previous)>length;
day.value=invalid?'':previous;
if(invalid){status.textContent='این ماه '+digits(length)+' روز دارد؛ روز پرداخت را دوباره انتخاب کنید.';output.value='';}
else updateValue();
}
function updateValue(){
if(!day.value || !month.value || !year.value){day.setCustomValidity('');output.value='';status.textContent='روز، ماه و سال پرداخت را کامل انتخاب کنید.';return;}
const selectedYear=Number(year.value),selectedMonth=Number(month.value),selectedDay=Number(day.value);
if(!Number.isInteger(selectedYear) || selectedMonth<1 || selectedMonth>12 || selectedDay<1 || selectedDay>monthLength(selectedYear,selectedMonth)){output.value='';day.setCustomValidity('تاریخ معتبر انتخاب کنید.');return;}
const offset=selectedMonth<=6?(selectedMonth-1)*31:186+(selectedMonth-7)*30;
// Anchor at UTC noon, add whole calendar days, serialize UTC date: no local midnight drift.
const gregorian=new Date(newYear(selectedYear)+(offset+selectedDay-1)*86400000);
const check=parts(gregorian);
if(check.year!==selectedYear || check.month!==selectedMonth || check.day!==selectedDay){
output.value='';day.setCustomValidity('تاریخ معتبر انتخاب کنید.');status.textContent='تاریخ معتبر انتخاب کنید.';return;
}
day.setCustomValidity('');output.value=gregorian.toISOString().slice(0,10);
status.textContent=digits(day.value)+' '+monthNames[Number(month.value)-1]+' '+digits(year.value);
}
if(formatter.resolvedOptions().calendar!=='persian'){
status.textContent='این مرورگر تقویم شمسی را پشتیبانی نمی‌کند. از نسخهٔ جدید مرورگر استفاده کنید.';
[day,month,year,todayButton].forEach(control=>control.disabled=true);return;
}
const today=parts(new Date());
month.replaceChildren();year.replaceChildren();
monthNames.forEach((name,index)=>addOption(month,index+1,name));
for(let value=today.year+1;value>=today.year-10;value--)addOption(year,value,digits(value));
year.value=String(today.year);month.value=String(today.month);updateDays();
document.querySelector('#receipt-form').addEventListener('receipt-date:validate',updateValue);
day.addEventListener('change',updateValue);
month.addEventListener('change',updateDays);year.addEventListener('change',updateDays);
todayButton.addEventListener('click',()=>{
const current=parts(new Date());
if(![...year.options].some(option=>option.value===String(current.year)))addOption(year,current.year,digits(current.year));
year.value=String(current.year);month.value=String(current.month);day.value='';updateDays();
day.value=String(current.day);updateValue();day.focus();
});
})();
Binary file not shown.

After

Width:  |  Height:  |  Size: 202 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 112 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 182 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 179 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 159 KiB

@@ -0,0 +1,152 @@
/* Browser-only design prototype: submit is ALWAYS prevented, nothing uploaded.
Production must validate ownership, amount, date and real file content again.
Limits here follow bootstrap IMAGE defaults, not a read of live AttachmentPolicy. */
(() => {
'use strict';
const form=document.querySelector('#receipt-form');
if(!form)return;
const q=selector=>form.querySelector(selector);
const amount=q('#paid-amount'),file=q('#receipt-file'),button=q('#submit-receipt');
const summary=q('#receipt-errors'),status=q('#receipt-validation-status');
const groups={
notice:{controls:[q('#notice-select')],error:q('#notice-error')},
amount:{controls:[amount],error:q('#amount-error')},
destination:{controls:[q('#destination-account')],error:q('#destination-error')},
date:{controls:[q('#receipt-day'),q('#receipt-month'),q('#receipt-year')],error:q('#date-error')},
file:{controls:[file],error:q('#file-error')}
};
const messages=new Map();
const touched=new Set();
const imageChecks=new WeakMap();
let submitted=false,busy=false,revision=0,fileRevision=0;
function focusField(control){
const visible=control===file?q('.file-picker-label'):control;
visible.scrollIntoView({block:'center',behavior:matchMedia('(prefers-reduced-motion: reduce)').matches?'auto':'smooth'});
control.focus({preventScroll:true});
}
function firstControl(key){
const controls=groups[key].controls;
return controls.find(control=>!control.value) || controls[0];
}
function renderSummary(){
const list=summary.querySelector('ul');list.replaceChildren();
messages.forEach((message,key)=>{
const item=document.createElement('li'),link=document.createElement('a');
link.href='#'+firstControl(key).id;link.textContent=message;
link.addEventListener('click',event=>{event.preventDefault();focusField(firstControl(key));});
item.append(link);list.append(item);
});
summary.hidden=!submitted || messages.size===0;
}
function showError(key,message){
const group=groups[key];group.error.textContent=message;group.error.hidden=!message;
group.controls.forEach(control=>control.setAttribute('aria-invalid',String(Boolean(message))));
if(message)messages.set(key,message);else messages.delete(key);
renderSummary();
}
function normalizeDigits(value){
return value.replace(/[۰-۹]/g,digit=>String(digit.charCodeAt(0)-1776)).replace(/[٠-٩]/g,digit=>String(digit.charCodeAt(0)-1632)).trim();
}
function amountError(){
q('#paid-amount-value').value='';
const value=normalizeDigits(amount.value);
if(!value)return 'مبلغ واریز را به ریال وارد کنید.';
if(value.startsWith('-'))return 'مبلغ باید بیشتر از صفر باشد.';
if(/[.٫]/.test(value))return 'مبلغ را به ریال و بدون اعشار وارد کنید.';
if(!/^\d+$/.test(value) && !/^\d{1,3}(?:[,٬ \u00a0]\d{3})+$/.test(value))return 'مبلغ را فقط با عدد بنویسید؛ مثلاً ۲۸۰۰۰۰۰۰۰.';
const digits=value.replace(/[,٬ \u00a0]/g,'').replace(/^0+(?=\d)/,'');
if(digits==='0')return 'مبلغ باید بیشتر از صفر باشد.';
// Avoid imprecise conversion: payload remains an integer string.
if(digits.length>16 || BigInt(digits)>BigInt(Number.MAX_SAFE_INTEGER))return 'مبلغ بسیار بزرگ است؛ عدد روی فیش را دوباره بررسی کنید.';
q('#paid-amount-value').value=digits;return '';
}
function noticeError(){
if(q('input[name="receipt-mode"]:checked').value!=='linked')return '';
const selected=q('#notice-select').value;
if(!(window.chaharsuPaymentNotices || []).some(notice=>notice.id===selected))return 'واریز مرتبط را انتخاب کنید؛ اگر اعلامیه ندارید، واریز مستقل را بزنید.';
return '';
}
function destinationError(){
const selected=q('#destination-account').value;
if(!(window.chaharsuCompanyAccounts || []).some(account=>account.id===selected))return 'حساب مقصد را انتخاب کنید؛ مبلغ را به کدام حساب شرکت واریز کرده‌اید؟';
return '';
}
function dateError(){
form.dispatchEvent(new Event('receipt-date:validate'));
const controls=groups.date.controls;
if(controls.some(control=>!control.value))return 'تاریخ را کامل کنید؛ روز، ماه و سال لازم‌اند.';
if(controls.some(control=>control.disabled || !control.checkValidity()))return 'تاریخ معتبر انتخاب کنید؛ روز باید در ماه و سال انتخاب‌شده وجود داشته باشد.';
const value=q('#receipt-date-value').value;
if(!/^\d{4}-\d{2}-\d{2}$/.test(value))return 'تاریخ معتبر انتخاب کنید؛ روز، ماه و سال را دوباره بررسی کنید.';
const todayParts=new Intl.DateTimeFormat('en-US',{timeZone:'Asia/Tehran',year:'numeric',month:'2-digit',day:'2-digit'}).formatToParts(new Date());
const today=Object.fromEntries(todayParts.map(part=>[part.type,part.value]));
if(value>[today.year,today.month,today.day].join('-'))return 'تاریخ واریز نمی‌تواند بعد از امروز باشد.';
return '';
}
function detectedType(bytes){
const text=(start,end)=>String.fromCharCode(...bytes.slice(start,end));
if(bytes[0]===255 && bytes[1]===216 && bytes[2]===255)return 'image/jpeg';
if([...bytes.slice(0,8)].join(',')==='137,80,78,71,13,10,26,10')return 'image/png';
if(['GIF87a','GIF89a'].includes(text(0,6)))return 'image/gif';
if(text(0,4)==='RIFF' && text(8,12)==='WEBP')return 'image/webp';
return '';
}
async function checkImage(selected){
const max=Number(file.dataset.maxBytes);
if(!selected.size)return 'تصویر انتخاب‌شده خالی است؛ تصویر دیگری انتخاب کنید.';
if(selected.size>max)return 'حجم تصویر بیشتر از ۱۰ مگابایت است؛ تصویر کم‌حجم‌تری انتخاب کنید.';
const allowed={jpg:'image/jpeg',jpeg:'image/jpeg',png:'image/png',gif:'image/gif',webp:'image/webp'};
const extension=selected.name.split('.').pop().toLowerCase();
if(!allowed[extension])return 'فقط تصویر JPG، PNG، GIF یا WEBP انتخاب کنید.';
const type=detectedType(new Uint8Array(await selected.slice(0,16).arrayBuffer()));
if(type!==allowed[extension] || (selected.type && selected.type!==type))return 'محتوای فایل با نوع تصویر سازگار نیست؛ تصویر اصلی فیش را انتخاب کنید.';
const readable=await new Promise(resolve=>{
const image=new Image(),url=URL.createObjectURL(selected);
image.onload=()=>{URL.revokeObjectURL(url);resolve(image.naturalWidth>0 && image.naturalHeight>0);};
image.onerror=()=>{URL.revokeObjectURL(url);resolve(false);};image.src=url;
});
return readable?'':'تصویر باز نمی‌شود یا خراب است؛ تصویر دیگری انتخاب کنید.';
}
async function fileError(){
const selected=file.files[0];
if(!selected)return 'تصویر فیش واریز را انتخاب کنید.';
if(!imageChecks.has(selected))imageChecks.set(selected,checkImage(selected).catch(()=> 'تصویر قابل بررسی نیست؛ دوباره آن را انتخاب کنید.'));
return imageChecks.get(selected);
}
const validators={notice:noticeError,amount:amountError,destination:destinationError,date:dateError,file:fileError};
function refresh(key){
status.textContent='';
if(key==='file')return refreshFile();
if(submitted || touched.has(key))showError(key,validators[key]());
}
async function refreshFile(){
const token=++fileRevision;touched.add('file');
const message=await fileError();if(token===fileRevision)showError('file',message);
}
form.addEventListener('input',()=>{revision++;status.textContent='';});
form.addEventListener('change',()=>{revision++;status.textContent='';});
amount.addEventListener('blur',()=>{touched.add('amount');refresh('amount');});
amount.addEventListener('input',()=>{q('#paid-amount-value').value='';refresh('amount');});
groups.date.controls.forEach(control=>control.addEventListener('change',()=>refresh('date')));
q('#receipt-date-today').addEventListener('click',()=>{revision++;refresh('date');});
q('#notice-select').addEventListener('change',()=>refresh('notice'));
q('#destination-account').addEventListener('change',()=>refresh('destination'));
q('#destination-account').addEventListener('blur',()=>{touched.add('destination');refresh('destination');});
form.querySelectorAll('input[name="receipt-mode"]').forEach(control=>control.addEventListener('change',()=>refresh('notice')));
file.addEventListener('change',()=>refresh('file'));
q('#clear-file').addEventListener('click',()=>{revision++;refresh('file');});
form.addEventListener('submit',async event=>{
event.preventDefault();if(busy)return;
submitted=true;busy=true;button.disabled=true;button.textContent='در حال بررسی…';status.textContent='';
const current=revision;
try{
const results=[['notice',noticeError()],['amount',amountError()],['destination',destinationError()],['date',dateError()],['file',await fileError()]];
if(current!==revision){status.textContent='اطلاعات فرم تغییر کرده است؛ دوباره دکمهٔ بررسی را بزنید.';return;}
messages.clear();results.forEach(([key,message])=>showError(key,message));
if(messages.size){focusField(firstControl(messages.keys().next().value));return;}
status.textContent='اطلاعات فرم کامل و معتبر است. این پیش‌نمایش است؛ فیش ارسال یا ذخیره نشده است.';
}finally{busy=false;button.disabled=false;button.textContent='بررسی و ادامه';}
});
// Enable only when prevention and validation handlers are installed; no accidental native submit.
button.disabled=false;
})();
Binary file not shown.

After

Width:  |  Height:  |  Size: 87 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 74 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 72 KiB

@@ -0,0 +1,256 @@
/* Independent design prototype: never edit production styles for these pages.
Match home typography: main text/actions >=14px, secondary labels 12px.
Keep text readable on small screens; compact spacing instead of shrinking text. */
@font-face{font-family:ChaharsuVazirmatn;src:url('../../static/fonts/vazirmatn-arabic-wght-normal.woff2?v=1791111429240969400') format('woff2');font-weight:100 900;font-display:swap}
@font-face{font-family:ChaharsuLatin;src:url('../../static/fonts/vazirmatn-latin-wght-normal.woff2?v=1791111429240969400') format('woff2');font-weight:100 900;font-display:swap}
body{font-family:ChaharsuVazirmatn,ChaharsuLatin,sans-serif;color:var(--color-ink);font-size:14px}
[hidden]{display:none!important}
a,button,input{touch-action:manipulation}
a:focus-visible,button:focus-visible,input:focus-visible{outline:3px solid var(--color-sea);outline-offset:4px}
.icon{width:23px;height:23px;fill:none;stroke:currentColor;stroke-width:1.7;stroke-linecap:round;stroke-linejoin:round;flex-shrink:0}
.preview-bar{background:var(--color-mint);text-align:center;padding:7px 14px;font-size:12px;line-height:1.65;border-bottom:1px solid #cde6f7}
.page-header{border-bottom:1px solid #dce2e8;background:#eeeeee}
.header-inner{max-width:1120px;margin:auto;min-height:76px;display:flex;align-items:center;justify-content:space-between;padding:0 24px;gap:16px}
.brand{display:flex;align-items:center;gap:14px;color:var(--color-ink)}
.brand img{width:85px;height:auto}
.brand-copy{border-right:1px solid #cdd7e0;padding-right:14px}
.brand b{display:block;font-size:20px;font-weight:900;line-height:1.3}
.brand small{display:block;font-size:12px;color:#526c86;margin-top:4px}
.header-home{font-size:14px;font-weight:800;color:#1977ce;display:flex;align-items:center;min-height:44px}
.service-main{width:100%;max-width:900px;margin:auto;padding:24px 24px 40px}
.breadcrumb{display:flex;flex-wrap:wrap;align-items:center;gap:8px;font-size:12px;color:#526c86;margin-bottom:16px}
.breadcrumb a{color:#1977ce;min-height:44px;display:flex;align-items:center}
.title-row{display:flex;align-items:flex-start;gap:14px}
.page-icon{display:grid;place-items:center;width:48px;height:48px;flex-shrink:0;border-radius:15px;background:var(--color-mint);color:#1977ce}
.service-main h1{font-size:28px;line-height:1.5;font-weight:900;letter-spacing:0;margin:0}
.subtitle{font-size:14px;line-height:1.7;color:#526c86;margin:7px 0 0}
.soon{display:inline-block;color:#805300;background:#fff3da;border:1px solid #f3ddae;border-radius:20px;padding:3px 9px;font-size:12px;font-weight:700;white-space:nowrap}
.service-notice{display:flex;align-items:center;gap:10px;padding:12px 16px;margin:18px 0;border:1px solid #f3ddae;border-radius:15px;background:#fff8eb}
.service-notice p{font-size:14px;line-height:1.65;color:#805300;margin:0;flex:1;min-width:0}
.content-card{border:1px solid #dce5ed;border-radius:23px;padding:22px;background:white;margin:16px 0}
.content-card h2{font-size:20px;font-weight:900;line-height:1.6;margin:0 0 8px}
.content-card p{font-size:14px;line-height:1.7;color:#526c86;margin:0 0 16px}
.upload-zone{display:flex;flex-direction:column;align-items:center;gap:10px;border:1px dashed #91bfdd;border-radius:18px;padding:22px 18px;background:#f6fbff}
.upload-zone svg{color:#1977ce;width:32px;height:32px}
.upload-zone label{margin:0;font-size:16px;font-weight:800;line-height:1.6}
.upload-zone span{font-size:14px;color:#526c86;text-align:center;line-height:1.65}
.upload-zone input[type=file]{width:100%;min-width:0;max-width:390px;margin:0;font-size:14px;background:white;padding:8px;border:1px solid #dce5ed;border-radius:12px;min-height:44px}
.upload-zone input::file-selector-button{font:inherit;min-height:30px}
.file-selection{display:flex;align-items:center;justify-content:space-between;gap:10px;padding:10px 12px;border:1px solid #cde6f7;border-radius:12px;background:var(--color-mint);margin-top:12px}
.file-selection p{margin:0;min-width:0;overflow-wrap:anywhere;color:var(--color-ink);font-size:14px}
.file-selection button{background:transparent;box-shadow:none;min-height:44px;padding:6px 8px;color:#1977ce;font-size:14px;flex-shrink:0}
.file-selection button:hover{transform:none;box-shadow:none;color:var(--color-ink);background:white}
.receipt-actions{display:flex;justify-content:space-between;align-items:center;gap:12px;margin-top:16px;flex-wrap:wrap}
.receipt-actions button{background:var(--color-ink);font-size:14px;min-height:44px}
.receipt-actions a{color:#1977ce;font-size:14px;font-weight:700;min-height:44px;display:flex;align-items:center}
.account-fields{display:grid;gap:0;margin:12px 0 0}
.account-fields div{display:flex;align-items:center;justify-content:space-between;gap:12px;padding:12px 0;border-top:1px solid #e7edf2;font-size:14px;line-height:1.7}
.account-fields dt{color:#526c86}
.account-fields dd{margin:0;color:var(--color-ink);font-weight:700;text-align:left;overflow-wrap:anywhere;min-width:0}
.identifier-card{background:#f6fbff;border-color:#cde6f7}
.service-footer{display:flex;align-items:center;justify-content:space-between;gap:12px;margin:18px 0 0;font-size:14px;line-height:1.7}
.service-footer a{color:#1977ce;font-weight:800;min-height:44px;display:flex;align-items:center}
.page-footer{font-size:12px;line-height:1.8;color:#526c86;text-align:center;margin:24px 0 0}
.page-footer a{color:inherit;white-space:nowrap}
.page-footer a:hover{color:#1977ce}
@media(max-width:639px){
.preview-bar{padding:5px 12px}
.header-inner{min-height:65px;padding:0 12px;gap:8px}
.brand{gap:8px}.brand img{width:56px}.brand-copy{padding-right:8px}.brand b{font-size:18px}
.brand small{font-size:12px}
.header-home{font-size:14px}
.service-main{padding:10px 12px 28px}
.breadcrumb{margin-bottom:8px}
.title-row{display:grid;grid-template-columns:minmax(0,1fr) auto;gap:0 10px}
.title-row>.page-icon{grid-column:2;grid-row:1}
.title-row>div{display:contents}
.service-main h1{grid-column:1;grid-row:1;font-size:24px;align-self:center}
.subtitle{grid-column:1/-1;margin-top:6px}
.page-icon{width:36px;height:36px;border-radius:12px}
.service-notice{margin:14px 0;padding:10px 12px;gap:8px}
.content-card{padding:16px;border-radius:20px;margin:12px 0}
.content-card h2{font-size:18px;margin-bottom:6px}
.content-card p{margin-bottom:12px}
.upload-zone{padding:14px 12px;gap:8px}
.upload-zone>svg{display:none}
.receipt-actions{flex-direction:column;align-items:stretch;gap:6px;margin-top:12px}
.receipt-actions button{width:100%}
.receipt-actions a{justify-content:center}
.account-fields{margin-top:8px}
.account-fields div{padding:9px 0;gap:8px}
.service-footer{align-items:stretch;flex-direction:column;gap:4px;margin-top:12px}
.page-footer{margin-top:18px}
}
@media(max-width:359px){.brand img{width:44px}.brand small{font-size:12px}.header-home{font-size:14px}.content-card{padding:14px}}
@media(prefers-reduced-motion:reduce){*{scroll-behavior:auto!important;transition:none!important}}
/* Payment notice & receipt flow. All values belong to a single customer notice.
Use rem, 44px touch targets, dark text on pale surfaces; local SVG/fonts only. */
a,button,input,textarea,select,summary{touch-action:manipulation}
textarea:focus-visible,select:focus-visible{outline:3px solid var(--color-ink-soft);outline-offset:3px}
.header-home,.breadcrumb a,.service-footer a,.receipt-actions a,.file-selection button{color:var(--color-ink)}
.page-icon{color:var(--color-ink-soft)}
.finance-button,.copy-field,.notice-toggle{font-family:inherit;box-shadow:none;transform:none!important;letter-spacing:0}
.finance-button{display:inline-flex;align-items:center;justify-content:center;gap:8px;min-height:44px;padding:10px 16px;border:1px solid var(--color-ink);border-radius:12px;background:var(--color-ink);color:white;font-size:.875rem;font-weight:800;line-height:1.7;text-align:center}
.finance-button:hover{background:var(--color-ink-soft);color:white;box-shadow:none}
.finance-button.secondary{background:white;color:var(--color-ink);border-color:#bfcede}
.finance-button.secondary:hover{background:var(--color-mint);border-color:var(--color-ink-soft)}
.list-heading{display:flex;align-items:center;justify-content:space-between;gap:16px;margin:24px 0 12px}
.list-heading h2{font-size:1.25rem;font-weight:900;line-height:1.6;margin:0}
.list-heading p{font-size:.875rem;color:#526c86;line-height:1.7;margin:4px 0 0}
.meta{font-size:.75rem;color:#526c86;flex-shrink:0}
.finance-search{display:flex;align-items:center;gap:10px;background:white;border:1px solid #bfcede;padding:0 12px;border-radius:12px;margin-bottom:16px;color:#526c86}
.finance-search input{border:0!important;background:transparent!important;box-shadow:none!important;border-radius:10px;padding:10px 0;min-height:44px;min-width:0;font-size:1rem!important;color:var(--color-ink);width:100%}
.finance-search:focus-within{outline:3px solid var(--color-ink-soft);outline-offset:3px}
.finance-search input:focus{outline:none}
.finance-search input::placeholder{color:#526c86;opacity:1}
.payment-notices{display:grid;gap:12px}
.payment-notice{min-width:0;border:1px solid #dce5ed;border-radius:20px;background:white;overflow:hidden}
.payment-notice:has([aria-expanded=true]){border-color:#91b8d7}
.payment-notice h3{margin:0}
.notice-toggle{width:100%;display:flex;align-items:center;justify-content:space-between;gap:16px;padding:20px;background:white;color:var(--color-ink);text-align:right;border-radius:20px;min-height:44px}
.notice-toggle:hover{background:#f6fbff;color:var(--color-ink);box-shadow:none}
.notice-toggle:focus-visible{outline-offset:-4px;outline-color:var(--color-ink-soft)}
.notice-toggle[aria-expanded=true]>.icon{transform:rotate(180deg)}
.notice-summary{display:grid;gap:8px;min-width:0;flex:1}
.notice-top{display:flex;align-items:center;justify-content:space-between;flex-wrap:wrap;gap:8px}
.notice-title{font-size:1rem;font-weight:800;line-height:1.7;overflow-wrap:anywhere}
.notice-amount{font-size:1.25rem;line-height:1.6;font-weight:900}
.notice-meta{font-size:.75rem;line-height:1.8;color:#526c86;font-weight:400;overflow-wrap:anywhere}
.payment-state{font-size:.75rem;font-weight:700;line-height:1.7;background:#fff3da;color:#805300;padding:3px 9px;border-radius:20px;white-space:nowrap}
.payment-state.received{background:var(--color-mint);color:var(--color-ink)}
.notice-detail{padding:0 20px 20px;border-top:1px solid #e7edf2}
.notice-context{display:flex;justify-content:space-between;flex-wrap:wrap;gap:8px;padding:14px 0;color:#526c86;font-size:.75rem;line-height:1.8}
.payment-fields{margin:0}
.payment-field{display:grid;grid-template-columns:140px minmax(0,1fr) 64px;align-items:center;gap:12px;padding:10px 0;border-top:1px solid #e7edf2;font-size:.875rem;line-height:1.7;min-width:0}
.payment-field dt{color:#526c86;font-weight:400;margin:0;overflow-wrap:anywhere}
.payment-field dd{color:var(--color-ink);font-weight:700;margin:0;min-width:0;overflow-wrap:anywhere;white-space:pre-wrap}
.payment-field dd.numeric-value{text-align:right;unicode-bidi:isolate;font-size:1rem}
.payment-field.no-copy{grid-template-columns:140px minmax(0,1fr)}
.copy-field{padding:6px 10px;min-height:44px;min-width:44px;font-size:.875rem;font-weight:700;line-height:1.5;color:var(--color-ink);border:1px solid #dce5ed;background:#f6fbff;border-radius:10px}
.copy-field:hover{background:var(--color-mint);color:var(--color-ink);border-color:#91b8d7;box-shadow:none}
.notice-actions{display:grid;grid-template-columns:1fr 1fr;gap:12px;margin-top:16px}
.copy-help{font-size:.875rem;color:#526c86;margin:12px 0 0;line-height:1.7}
.empty-notices{text-align:center;padding:24px 16px;background:white;border:1px solid #dce5ed;border-radius:20px}
.empty-notices h3{font-size:1.125rem;font-weight:800;margin:0 0 8px}
.empty-notices p{font-size:.875rem;line-height:1.7;color:#526c86;margin:0 0 16px}
.copy-feedback:empty{display:none}
.copy-feedback{position:fixed;bottom:max(16px,env(safe-area-inset-bottom));left:50%;transform:translateX(-50%);width:max-content;max-width:calc(100% - 24px);z-index:50;border-radius:12px;background:var(--color-ink);color:white;padding:12px 16px;font-size:.875rem;line-height:1.7;box-shadow:0 8px 28px #08376b26;pointer-events:none}
.manual-copy{width:calc(100% - 24px);max-width:560px;border:1px solid #dce5ed;padding:24px;border-radius:20px;background:white;color:var(--color-ink)}
.manual-copy::backdrop{background:#08376b80}
.manual-copy h2{font-size:1.25rem;line-height:1.6;font-weight:900;margin:0 0 12px}
.manual-copy p{font-size:.875rem;line-height:1.7;color:#526c86;margin:0 0 16px}
.manual-copy textarea{width:100%;min-height:240px;font-size:1rem;line-height:1.7;padding:12px}
.manual-copy>div{display:flex;flex-wrap:wrap;gap:12px;margin-top:16px}
.receipt-mode{display:grid;grid-template-columns:1fr 1fr;gap:12px;border:0;padding:0;margin:16px 0}
.receipt-mode label{display:flex;align-items:center;gap:10px;margin:0;padding:12px;min-height:64px;border:1px solid #bfcede;border-radius:12px;cursor:pointer;background:white;color:var(--color-ink)}
.receipt-mode label:has(input:checked){background:var(--color-mint);border-color:var(--color-ink-soft)}
.receipt-mode input{width:20px;height:20px;flex-shrink:0;accent-color:var(--color-ink)}
.receipt-mode b{display:block;font-size:.875rem;line-height:1.7;font-weight:800}
.receipt-mode small{display:block;font-size:.75rem;line-height:1.8;color:#526c86;font-weight:400;margin-top:2px}
#receipt-form .field-label{display:block;font-size:1rem;font-weight:700;color:var(--color-ink);margin:12px 0 8px;line-height:1.7}
#receipt-form .field-label span{font-size:.875rem;font-weight:400;color:#526c86}
#receipt-form input:not([type=radio]),#receipt-form select,#receipt-form textarea{font-size:1rem;line-height:1.7;min-height:44px;min-width:0;padding:10px 12px;border:1px solid #bfcede;border-radius:12px;box-shadow:none;width:100%;color:var(--color-ink)}
#receipt-form input::placeholder,#receipt-form textarea::placeholder{color:#526c86;opacity:1}
#receipt-form .field-help{font-size:.875rem;color:#526c86;line-height:1.7;margin:8px 0 0}
#receipt-form .inline-error{background:#fff8eb;border:1px solid #f3ddae;border-radius:12px;padding:12px;color:#805300;font-size:.875rem;line-height:1.7;margin:12px 0}
.selected-payment{background:#f6fbff;border:1px solid #cde6f7;border-radius:16px;padding:16px;margin-top:16px}
.selected-payment h3{font-size:1rem;line-height:1.7;font-weight:800;margin:0 0 8px}
.compact-fields .payment-field{padding:8px 0}
.text-action{display:flex;align-items:center;min-height:44px;font-size:.875rem;font-weight:700;color:var(--color-ink);margin-top:8px;line-height:1.7}
.receipt-field-grid{display:grid;grid-template-columns:1fr 1fr;gap:16px}
.receipt-field-grid>div{min-width:0}
#receipt-form .upload-zone{margin:20px 0 0}
#receipt-form .upload-zone label{color:var(--color-ink);font-size:1rem}
#receipt-form .upload-zone input{max-width:100%;background:white}
#receipt-form .upload-zone input::file-selector-button{min-height:44px;background:var(--color-ink);color:white;border:0;border-radius:8px;padding:8px 12px;font:inherit;margin-inline-end:8px;cursor:pointer}
#receipt-form textarea{min-height:100px;resize:vertical}
.receipt-summary{padding:12px;background:var(--color-mint);border-radius:12px;margin-top:16px;font-size:.875rem;line-height:1.7;color:var(--color-ink);overflow-wrap:anywhere}
@media(max-width:639px){
.list-heading{margin-top:20px;gap:8px}.list-heading h2{font-size:1.125rem}
.finance-search{gap:8px;padding:0 10px}
.notice-toggle{padding:16px 14px;gap:8px}.notice-summary{gap:6px}.notice-title{font-size:.875rem}.notice-amount{font-size:1.125rem}
.notice-detail{padding:0 14px 16px}.notice-context{padding:12px 0}
.payment-field,.payment-field.no-copy{grid-template-columns:minmax(0,1fr) 56px;gap:6px 8px;padding:12px 0}
.payment-field dt{grid-column:1;grid-row:1}.payment-field dd{grid-column:1;grid-row:2}
.payment-field .copy-field{grid-column:2;grid-row:1/3;align-self:center}
.payment-field.no-copy dd{grid-column:1/-1}
.payment-field dd.numeric-value{font-size:.875rem}
.notice-actions{grid-template-columns:1fr;gap:8px}.notice-actions .receipt-link{grid-row:1}
.copy-help{margin-top:8px}.receipt-mode{grid-template-columns:1fr;gap:8px;margin:12px 0}
.receipt-mode label{min-height:64px;padding:10px 12px}
.receipt-field-grid{grid-template-columns:1fr;gap:0}.selected-payment{padding:12px}
.manual-copy{padding:16px}#receipt-form .upload-zone{margin-top:16px}
}
#receipt-form .upload-zone .file-picker-label{display:inline-flex;align-items:center;justify-content:center;min-height:44px;padding:10px 20px;margin:0;border-radius:12px;background:var(--color-ink);color:white;cursor:pointer;font-size:.875rem;line-height:1.7}
#receipt-form .upload-zone:has(input:focus-visible) .file-picker-label{outline:3px solid var(--color-ink-soft);outline-offset:4px}
#receipt-form input#receipt-file.sr-only{position:absolute;width:1px;height:1px;min-height:0;padding:0;margin:-1px;overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap;border:0}
@media(max-width:639px){
/* Bank identifiers need the whole mobile row: avoid an isolated final IBAN digit. */
.payment-field:has(dd.numeric-value) dd{grid-column:1/-1}
.payment-field:has(dd.numeric-value) .copy-field{grid-row:1}
}
/* Compact financial UI: keep supplementary prose compact and visible, keep payment values readable. */
.finance-guidance,#receipt-form .finance-guidance{margin:16px 0;padding:10px 12px;border-radius:12px;background:var(--color-mint);color:var(--color-ink);font-size:.875rem;line-height:1.7;font-weight:400}
.notice-bank{display:flex;align-items:baseline;justify-content:space-between;flex-wrap:wrap;gap:4px 16px;padding:12px 0;font-size:.875rem;line-height:1.7}
.notice-bank-name{font-weight:800}.notice-holder{color:#526c86;font-weight:400}
.essential-fields{display:grid;grid-template-columns:1fr 1fr;column-gap:24px}
.essential-fields .payment-field{grid-template-columns:minmax(0,1fr) 56px;gap:0 8px;padding:8px 0}
.essential-fields .payment-field dt{grid-column:1;grid-row:1}
.essential-fields .payment-field dd{grid-column:1/-1;grid-row:2}
.essential-fields .copy-field{grid-column:2;grid-row:1;align-self:center}
.notice-extra{margin-top:8px;border-top:1px solid #e7edf2}
.extra-fields .payment-field{grid-template-columns:140px minmax(0,1fr);padding:8px 0}
.extra-fields .payment-field dd{font-weight:400;line-height:1.8}
.notice-extra .notice-context{padding:4px 0 8px}
.recommended-mode{border-inline-start:3px solid var(--color-ink-soft)!important}
.recommended-badge{display:inline-block;margin-inline-start:6px;padding:1px 7px;font-size:.75rem;font-weight:700;line-height:1.8;border-radius:20px;background:var(--color-ink);color:white}
@media(max-width:639px){
.essential-fields{grid-template-columns:1fr;gap:0}
.essential-fields .payment-field{padding:6px 0;gap:0 8px}
.essential-fields .payment-field dd{margin-top:-2px}
.extra-fields .payment-field{grid-template-columns:1fr;gap:4px}
.extra-fields .payment-field dt,.extra-fields .payment-field dd{grid-column:1}
.notice-bank{display:grid;gap:2px;padding:10px 0}
.finance-guidance,#receipt-form .finance-guidance{margin:12px 0}
}
/* Supplementary notice information is permanent within the opened notice card. */
.notice-extra h4{font-size:.875rem;font-weight:800;line-height:1.7;margin:12px 0 8px;color:var(--color-ink)}
/* Native Persian day/month/year controls: readable labels and 44px targets. */
.receipt-date{border:0;padding:0;margin:20px 0 0;min-width:0}
.receipt-date legend{font-size:1rem;font-weight:700;line-height:1.7;color:var(--color-ink);padding:0;margin-bottom:8px}
.receipt-date legend span{font-size:.875rem;font-weight:400;color:#526c86}
.date-selects{display:grid;grid-template-columns:minmax(64px,.7fr) minmax(110px,1.2fr) minmax(80px,1fr);gap:12px}
.date-selects>div{min-width:0}
#receipt-form .date-selects label{font-size:.875rem;font-weight:400;line-height:1.7;color:#526c86;margin:0 0 4px}
#receipt-form .date-selects select{padding:10px 8px;font-size:1rem;min-height:44px;width:100%;text-align:right}
.date-tools{display:flex;align-items:center;justify-content:space-between;gap:12px;margin-top:8px}
#receipt-form .date-tools p{font-size:.875rem;line-height:1.7;color:#526c86;margin:0}
.date-tools button{flex-shrink:0;padding:6px 12px}
#receipt-form #receipt-date-status{font-size:.875rem;line-height:1.7;color:var(--color-ink);margin:4px 0 0;min-height:24px}
@media(max-width:639px){
.date-selects{grid-template-columns:minmax(0,.65fr) minmax(0,1.25fr) minmax(0,1fr);gap:8px}
#receipt-form .date-selects select{padding:10px 4px}
.receipt-date{margin-top:16px}
.date-tools{gap:8px}
}
/* Minimal Today action, preserving a 44px touch target and visible keyboard focus. */
#receipt-date-today{min-height:44px;min-width:44px;padding:4px 8px;border:0;background:transparent;color:var(--color-ink);box-shadow:none;font-size:.875rem;font-weight:700}
#receipt-date-today:hover{background:var(--color-mint);color:var(--color-ink)}
/* Explicit, readable field errors and a linked summary; never color alone. */
#receipt-form .field-error{font-size:.875rem;line-height:1.8;font-weight:500;color:#9f1f1f;margin:8px 0 0}
#receipt-form [aria-invalid=true]:not([type=hidden]):not([type=file]){border-color:#b93333;background:#fffafa}
#receipt-form .upload-zone:has(input[aria-invalid=true]){border-color:#b93333;background:#fffafa}
.receipt-errors{background:#fffafa;border:1px solid #e5aaaa;border-radius:16px;padding:16px;margin:16px 0;color:#9f1f1f}
#receipt-form .receipt-errors h2{font-size:1rem;font-weight:800;line-height:1.7;margin:0 0 8px;color:#9f1f1f}
.receipt-errors ul{padding:0;list-style:none;margin:0}
.receipt-errors a{display:flex;align-items:center;min-height:44px;font-size:.875rem;line-height:1.7;color:#9f1f1f;text-decoration:underline;text-underline-offset:3px}
#receipt-form .validation-status{font-size:.875rem;line-height:1.8;color:var(--color-ink);margin:12px 0 0}
#receipt-form .validation-status:empty{display:none}
#receipt-form .file-picker-label span{font-size:.875rem;color:inherit}
#receipt-form .amount-words{font-size:.875rem;line-height:1.8;font-weight:700;color:var(--color-ink);margin:8px 0 0;overflow-wrap:anywhere}
#receipt-form .amount-words:empty{display:none}
#paid-amount{text-align:right;font-variant-numeric:tabular-nums}
.bank-fields{margin-top:12px}
+6
View File
@@ -21,6 +21,12 @@ server {
access_log off; access_log off;
} }
# Financial evidence requires a current authenticated application permission check.
# Never expose it through bearer-style signed media links.
location ^~ /protected-media/payments/ {
return 404;
}
location /protected-media/ { location /protected-media/ {
alias /app/media/; alias /app/media/;
secure_link $arg_md5,$arg_expires; secure_link $arg_md5,$arg_expires;
+105
View File
@@ -0,0 +1,105 @@
# راهنمای پیاده‌سازی و راه‌اندازی چهارسو و پرداخت‌ها
تاریخ: ۲۰۲۶/۱۰/۰۵
## مسیرها و سازگاری
- ورود عادی مشتری و مسیر `/` به `/chaharsu/` هدایت می‌شوند. لینک داخلی معتبرِ ادامه ورود حفظ می‌شود؛ لینک خارجی پذیرفته نمی‌شود.
- `/tickets/` همچنان تاریخچه فعلی درخواست‌هاست. `/tickets/new/`، قالب و منطق ثبت درخواست تغییر نکرده‌اند.
- خانه چهارسو فقط خدمات دارد: ارتباط با کارشناسان، ارسال فیش و شماره حساب‌ها.
- شماره حساب‌ها: `/chaharsu/bank-accounts/`؛ فرم فیش: `/chaharsu/payment-receipt/`؛ سوابق فیش مشتری: `/chaharsu/receipts/`.
- میزکار کارشناسان: `/staff/payments/`؛ مدیریت حساب‌ها: `/staff/payments/banks/`.
- PWA از `/` شروع می‌شود تا مشتری به چهارسو و کارشناس به میزکار خودش برسد. رفتار ثبت service worker و URLهای نسخه‌دار حفظ شده‌اند.
## ساختار و قواعد مالی
اپ مستقل `apps.payments` شامل مدل‌ها، سیاست دسترسی، اعتبارسنجی، سرویس‌های تراکنشی، خواندن داده، اعلان‌ها و صفحات است. وابستگی‌های بیرونی از زیرساخت فعلی کاربران، واحدها، پیامک و سیاست تصویر استفاده می‌کنند. `PaymentReceiptRevision` هم اطلاعات نسخه و هم مدرک تصویر را نگه می‌دارد؛ مدل جداگانه Document لازم نشده است.
- اعلامیه، فیش و تصمیم تأیید مستقل‌اند. ثبت تصویر یا تأیید یک نفر از دو نفر، پرداخت را نهایی نمی‌کند.
- مبلغ عدد صحیح **ریال** است؛ نمایش حروفی تومان در مرورگر با BigInt انجام می‌شود و مقدار ریال به سرور می‌رود.
- نمایش تاریخ شمسی و انتخاب سه‌قسمتی است. سرور با jdatetime روز/ماه/سال را مستقل از JS بررسی و Gregorian را در DateField ذخیره می‌کند. تاریخ ناقص، غیرواقعی، آینده و خارج از بازه مجاز رد می‌شود.
- شناسه‌ها، شماره‌ها و شبا رشته‌اند؛ صفر ابتدایی حفظ می‌شود. شبا و کارت موجود اعتبارسنجی می‌شوند.
- حساب شناسه‌دار در صدور اعلامیه الزاماً شناسه می‌خواهد. در ثبت فیش، شناسه اعلامیه فقط برای **همان حساب** معتبر است؛ مقصد متفاوت شناسه خودش را لازم دارد.
- حساب فقط فعال در پنل کارشناسان، در زمینه اعلامیه اختصاصی همان مشتری قابل استفاده است؛ در واریز مستقل قابل استفاده نیست. غیرفعال کلی بودن، ثبت جدید را متوقف می‌کند و سوابق را پنهان نمی‌کند.
- snapshot حساب و شناسه اعلامیه حفظ می‌شود؛ ویرایش بانک، شماره تاریخی اعلامیه یا نسخه فیش را تغییر نمی‌دهد.
- فرم صدور و فرم فیش nonce دارند؛ ارسال تکراری همان داده رکورد یا پیامک جدید ایجاد نمی‌کند. استفاده مجدد از nonce برای اطلاعات مالی متفاوت رد می‌شود.
- وضعیت اعلامیه از مبلغ نسخه جاری فیش‌های تأییدشده بدون مغایرت مقصد محاسبه می‌شود؛ پرداخت جزئی و باقی‌مانده نمایش داده می‌شوند.
## تنظیمات ضروری پیش از استفاده مالی واقعی
۱. migrationها را اجرا کنید:
```bash
.venv/Scripts/python.exe manage.py migrate
```
۲. مدیرعامل فعال، superuser از نوع STAFF یا کارشناس دارای مجوز `payments.manage_company_bankaccounts`، حساب‌های **واقعی** شرکت را در منوی «بانک‌ها» تعریف کند. اطلاعات واقعی حساب در migration قرار نگرفته است. برای حساب شناسه‌دار، «شناسه واریز الزامی» را فعال کنید. سه وضعیت فعال کلی، کارشناسان و مشتریان مستقل‌اند و تغییر جمعی هم دارند. حساب دارای سابقه قابل حذف نیست؛ غیرفعال کنید. فرم ویرایش قدیمی نمی‌تواند تغییر جدید را بازنویسی کند.
۳. در مدیریت Django `/x5admin/`، بخش `PaymentApprovalPolicy`، یک سیاست عمومی یا سیاست واحد بسازید. همه افراد فهرست `reviewers` باید رأی بدهند؛ یک نفر یعنی تأیید تک‌نفره، چند نفر یعنی شرط تأیید همه اشخاص تعیین‌شده. افراد `seniors` می‌توانند با دلیل، تأیید نهایی ارشد انجام دهند.
۴. **انتخاب فرد در سیاست به‌تنهایی کافی نیست.** مجوزهای زیر را به افراد مجاز بدهید؛ مدیر مالی یا نقش FINANCE خودکار تأییدکننده نمی‌شود:
| مجوز | کاربرد |
| --- | --- |
| `payments.approve_paymentreceipt` | رأی عادی فردِ تعیین‌شده در سیاست |
| `payments.senior_approve_paymentreceipt` | تأیید ارشد فردِ تعیین‌شده در seniors |
| `payments.manage_payment_approval_policy` | مدیریت سیاست‌ها و تخصیص سیاست فعال به چرخه خالی |
| `payments.manage_company_bankaccounts` | مدیریت حساب‌ها |
| `payments.manage_payment_access` | تنظیم باز یا محدود بودن مشاهده و ایجاد |
| `payments.view_all_payments` | مشاهده مالی در همه واحدها |
| `payments.view_payment_audit` | دسترسی ممیزی مالی در مدیریت |
مجوزهای تأیید می‌توانند سراسری، در عضویت فعال واحد یا در مجوزهای واحد فعال باشند. مشاهده همان فیش و تخصیص همان چرخه نیز لازم است. ثبت‌کننده کارشناس، فیشی را که خودش ثبت کرده تأیید نمی‌کند. مدیریت Django علاوه بر مجوز مالی، شرایط ورود استاندارد پنل admin را لازم دارد.
۵. نبود سیاست فعال باعث **ماندن فیش در انتظار بررسی** می‌شود. تأیید خودکار یا تعیین فرضی مدیرعامل انجام نشده است. پس از تعریف سیاست، مدیر مجاز از جزئیات فیش، «اعمال سیاست فعال تأیید» را برای چرخه خالی بزند. سیاست چرخه دارای تخصیص/رأی بازنویسی نمی‌شود.
۶. تنظیم پیش‌فرض `PaymentAccessConfiguration` مشاهده و ایجاد را برای همه کارشناسان فعال باز می‌گذارد. برای محدودسازی بعدی این دو گزینه را غیرفعال و مجوزهای `view_paymentnotice`، `view_paymentreceipt` و `add_paymentnotice` را در دامنه موردنظر بدهید. تأیید از ابتدا محدود است.
## نسخه‌ها، تأیید و ممیزی
تصمیم در transaction و با قفل فیش و چرخه، تطبیق نسخه جاری و رأی یکتا ثبت می‌شود. PostgreSQL برای join واحد nullable فقط خود فیش را قفل می‌کند. ترتیب قفل مشتری/حساب در صدور و ثبت یکسان است. پیامک نهایی فقط با نهایی‌شدن شرط تأیید صف می‌شود.
فیش ردشده از صفحه مشتری با تصویر جدید اصلاح می‌شود. نسخه پیشین، فایل و رأی‌ها باقی می‌مانند؛ نسخه تازه از ابتدا با سیاست جدید بررسی می‌شود و رأی قدیمی در تأیید جدید شمرده نمی‌شود. رد و ارشد دلیل لازم دارند؛ مغایرت مقصد برای تأیید دلیل بررسی لازم دارد. تصویر تکراری روی فیش دیگر به کارشناس هشدار داده می‌شود.
صدور، ارسال، اصلاح، شروع چرخه، تخصیص، رأی، مشاهده مدرک و تغییر حساب با نام/شناسه عامل، زمان و جزئیات در `PaymentAuditEvent` ثبت می‌شوند. تغییرات مجوز مالی در مسیرهای مدیریت User/Group/Unit/Membership/StaffProfile نیز ممیزی می‌شوند. سیاست و داده‌های ممیزی از admin قابل حذف/ویرایش عادی نیستند. تغییر مستقیم دیتابیس خارج از مسیرهای برنامه، نیازمند کنترل عملیاتی و ممیزی مستقل دیتابیس است.
## تصاویر و امنیت مرورگر
تصاویر در `payments/` رسانه خصوصی ذخیره می‌شوند و فقط endpoint احراز هویت‌شده تصویر آن‌ها را ارائه می‌دهد. مجوز در هر بار مشاهده/دانلود بررسی می‌شود؛ Magic Link تیکت اجازه مالی نمی‌دهد. لینک مستقیم `/media/payments/` در توسعه و مسیر signed `/protected-media/payments/` در nginx مسدودند. پاسخ مالی/تصویر `no-store` است و service worker داده مالی را cache نمی‌کند.
اعتبارسنجی تصویر شامل سقف حجم سیاست فعلی، extension، MIME واقعی، decode با Pillow، سقف ابعاد و SHA-256 است. فایل ساخته‌شده در شکست transaction پاک می‌شود. دانلود نسخه قبلی همچنان کنترل دسترسی دارد.
فونت، SVG، CSS و JS داخل پروژه‌اند؛ وابستگی CDN اضافه نشده است. CSS چهارسو scoped است. کپی کامل اطلاعات از snapshot کامل، شامل شرح و فاکتور و تاریخ، ساخته می‌شود؛ فقط فیلدهای بانکی ضروری کپی تکی دارند. در HTTP شبکه محلی، اگر Clipboard مجاز نباشد، پنجره قابل انتخاب و کپی دستی نمایش داده می‌شود.
## پیامک
دو رویداد دیتابیسی `PAYMENT_NOTICE_CREATED` و `PAYMENT_RECEIPT_APPROVED` به `SMSTemplate` اضافه شده‌اند. migration الگوهای عمومی اولیه را ایجاد می‌کند؛ override واحد در الگوهای پیامک فعلی قابل تنظیم است. تگ‌ها:
`recipient_name`, `company_name`, `notice_number`, `receipt_number`, `amount_rial`, `invoice_number`, `payment_link`
صف فعلی `NotificationJob` و worker `process_notifications` به کار می‌روند. اعلامیه به معنی صف‌شدن پیامک است؛ دریافت پیامک نیازمند اجرای worker و تنظیم provider است. وضعیت ارسال و دلیوری مستقل از وضعیت مالی است. متن و نسخه الگو در اولین تلاش موفق آماده‌سازی snapshot می‌شود؛ retry همان متن را حفظ می‌کند. تلاش ناموفقِ آماده‌سازی قالب یا ارتباط provider نیز تاریخچه دارد. URL پیامک ورود کامل مشتری را لازم دارد.
در آزمون‌ها provider mock شده است؛ پیامک واقعی برای مشتری ارسال نشده و worker روی صف واقعی توسعه اجرا نشده است.
## آزمون و محدودیت محیط
نتایج اجرا (اجرای مجدد را نباید با تعداد آزمون‌های یکتا جمع کرد):
- مجموعه کامل سمت سرور: **۳۲۲ موفق، ۴ skipped**؛ ۴۶ آزمون مرورگر در این اجرای جدا انتخاب نشده‌اند (`chaharsu-backend-final.log`).
- کنترل مجدد بخش‌های مالی، view، مجوز و دامنه: **۱۸۱ موفق** (`chaharsu-focused-final.log`).
- مجموعه کامل مرورگر: **۴۴ موفق، ۲ skipped**؛ دو smoke test محیط خارجی بدون تنظیمات آن محیط اجرا نشده‌اند (`chaharsu-e2e-final.log`).
- پس از تکمیل سناریوی ایجاد اعلامیه و ارسال فیش مرتبط: **۵ آزمون مرورگر مالی و یک آزمون مجوز audit موفق** (`chaharsu-payments-e2e-final.log`).
- کنترل نهایی ثبت مقادیر قبل/بعد حساب بانکی، جلوگیری از ویرایش فرم قدیمی و مجوز حساب مقصد: **۳ موفق** (`chaharsu-audit-final.log`).
- آزمون نمونه قبلی ثبت تیکت: **۳ موفق** (`chaharsu-prototype-test.log`)؛ آزمون JavaScript موجود: **۱ موفق**. بررسی syntax هر سه فایل JS جدید نیز موفق بود.
- بررسی نهایی Django بدون خطا؛ کنترل صفحه واقعی با نشست مشتری و اندازه **۳۲۰×۵۶۸** موفق: هر سه خدمت کامل بالای نوار پایین دیده می‌شوند، overflow افقی وجود ندارد و دارایی‌ها نسخه‌دارند (`chaharsu-live-final.log`). سرور توسعه روی `0.0.0.0:8000` فعال است.
- Graphify به‌روز و روابط معماری بازبینی شد. Ripwire `--test-gate` فهرست آزمون‌ها و مسیرهای dispatch فاقد اتصال ایستای آزمون را ارائه کرد (exit 4، به معنای تعهد بررسی، نه شکست pytest). آزمون‌های فوق اجرا شدند؛ `--quality-delta` با **gating=0 و preexisting-worse=0** پایان یافت. برای کد جدید موارد پیچیدگی و callbackهای چارچوب نیز گزارش شده‌اند؛ این گزارش جایگزین تست اجرایی نیست.
گزارش‌های فوق در `graphify-out/` نگهداری می‌شوند. آزمون‌های PostgreSQL در `tests/test_payment_concurrency.py` از connectionهای جدا استفاده می‌کنند و در SQLite صریحاً skip می‌شوند. تست SQLite اثبات صحت قفل PostgreSQL نیست. پیش از انتشار مالی روی production این **۴ آزمون** باید روی پایگاه **آزمایشی جداگانه PostgreSQL** اجرا شوند؛ در این محیط Docker daemon در دسترس نیست و binary قابل اجرای PostgreSQL موجود نیست.
پشتیبان دیتابیس توسعه پیش از migration در مسیر ignoredِ `graphify-out/db-pre-chaharsu.sqlite3` تهیه شد. `bootstrap_demo` اجرا نشده، دسترسی یا حساب واقعی فرضی ایجاد نشده و عملیات git commit/push انجام نشده است.
## انتخاب مشتری و میان‌بر ایجاد اعلامیه
انتخاب مشتری در ایجاد اعلامیه و انتخاب مشتری برای ثبت فیش، جست‌وجویی است؛ فهرست کامل مشتریان در HTML بارگیری نمی‌شود. جست‌وجو با نام، موبایل فارسی/لاتین، سازمان، شماره یا موضوع تیکت انجام می‌شود. هر پاسخ حداکثر ۲۰ مشتری فعال را همراه سه سابقه اخیر قابل مشاهده برمی‌گرداند؛ سوابق خصوصی خارج از مجوز کارشناس در نتیجه یا شمارنده دیده نمی‌شوند. انتخاب خودکار نتیجه اول وجود ندارد. نام، موبایل، سازمان و موقعیت در کارت انتخاب‌شده نمایش داده می‌شوند و تغییر عبارت جست‌وجو، انتخاب قبلی را پاک می‌کند.
دکمه «ایجاد اعلامیه واریز» در باکس مشتری تیکت و کارت مخاطب، فقط برای کارشناس دارای مجوز ایجاد و مشتری فعال نمایش داده می‌شود. پارامتر `customer` در لینک، UUID مشتری است؛ GET تنها مشتری فعال را پیش‌انتخاب می‌کند و هیچ اعلامیه یا پیامکی ایجاد نمی‌کند. POST همچنان شناسه مشتری را با queryset فعال فرم و مجوز مالی سرور اعتبارسنجی می‌کند. پاسخ جست‌وجو `private, no-store` است و داده مشتری در حافظه پایدار مرورگر ذخیره نمی‌شود.
@@ -0,0 +1,401 @@
# طرح ادغام چهارسو و مدیریت اعلامیه و فیش واریز
تاریخ بررسی: ۲۰۲۶/۱۰/۰۵ — وضعیت: طرح مبنای پیاده‌سازی؛ گزارش اجرا و تفاوت مسیر نهایی در [راهنمای پیاده‌سازی](CHAHARSU_PAYMENTS_IMPLEMENTATION.md) ثبت شده است.
## ۱. نظر کارشناسی و تصمیم معماری
این قابلیت یک زیرسامانه مالی با گردش تأیید است؛ انتقال HTML نمونه به قالب‌های اصلی به‌تنهایی آن را کامل نمی‌کند. پیشنهاد، افزودن اپ مستقل `apps.payments` داخل معماری Django Modular Monolith موجود و استفاده از زیرساخت کاربران، واحدها، پیامک، رسانه خصوصی و برند فعلی است. منطق مالی در `apps/tickets/views.py` یا مدل `Ticket` انباشته نشود.
چهار مفهوم باید مستقل بمانند:
| مفهوم | معنی |
| --- | --- |
| حساب شرکت | حساب بانکی مدیریت‌شده و وضعیت مجاز بودن استفاده از آن |
| اعلامیه واریز | دستور پرداخت اختصاصی کارشناس برای یک مشتری، مبلغ و مقصد مشخص |
| فیش واریز | مدرک پرداخت مشتری؛ با اعلامیه یا مستقل از آن |
| تأیید فیش | تصمیم اشخاص مجاز درباره یک نسخه مشخص از مدرک، مطابق سیاست تأیید |
**تأیید مربوط به فیش است.** اعلامیه پیش از دریافت مدرک صرفاً صادر می‌شود. وضعیت پرداخت اعلامیه از فیش‌های تأییدشده محاسبه می‌شود؛ ایجاد اعلامیه، بارگذاری تصویر یا ارسال پیامک به معنی تأیید پرداخت نیست. این قابلیت به‌خودی‌خود تطبیق خودکار با تراکنش بانک یا سند حسابداری ایجاد نمی‌کند.
اصل مهم: دسترسی گسترده اولیه برای ایجاد و مشاهده در پنل کارشناسان، مستقل از مجوز محدود تأیید است. این تصمیم نباید دسترسی تیکت‌ها یا واحدهای سازمانی را گسترده کند.
## ۲. مبنای بررسی و حدود قطعیت
درخت پروژه `PROJECT_TREE.txt`، گراف موجود Graphify، اسناد سازمانی و طراحی، سورس بخش‌های مؤثر، فایل‌های تست پروژه و E2E و ابزارهای مرورگر بررسی شدند. مسیر تحلیل، درخت پروژه → Graphify برای معماری و ارتباط اسناد → Ripwire برای نمادها و وابستگی سورس → تطبیق مستقیم با سورس و تست‌ها بود. Graphify و Ripwire برای یک سؤال هم‌زمان اجرا نشدند.
نمونه‌های بررسی Graphify: ارتباط‌های `UnitMembership`، `SMSTemplate` و مفهوم نمونه بودن صفحه شماره حساب‌ها. Ripwire برای برنامه قابلیت، بدنه سیاست دسترسی و ارسال پیامک، استفاده‌های `StaffProfile` و شعاع اثر `_send_sms` استفاده شد. ارتباط‌های استنباطی گراف با سورس تطبیق داده شدند؛ گراف جای حقیقت اجرایی نیست. شمارش‌های Ripwire حد پایین‌اند و resolveهای مبهم و فایل‌های بدون grammar وجود دارند؛ پوشش Django ORM، template و URL باید با خواندن مستقیم تکمیل شود.
در این مرحله تست‌ها **خوانده شدند و اجرا نشدند**؛ نتیجه سبز یا صحت اجرای فعلی ادعا نمی‌شود. دیتابیس عملیاتی، افراد دارای مجوز فعلی و متن زنده الگوهای پیامک استعلام نشدند. شیوه دیتابیس‌محور از مدل‌ها، migrationها، فرم‌ها، worker و تست‌ها استخراج شده است؛ پیش از استقرار باید تنظیمات واقعی، با دسترسی فقط‌خواندنی، تطبیق داده شوند.
کد، قالب، CSS، migration و داده فعلی تغییر نمی‌کند؛ خروجی این مرحله همین سند است. تغییرات از قبل موجود در `PROJECT_TREE.txt` و `apps/customers/migrations/0005_contacteditlog.py` خارج از این کارند.
## ۳. واقعیت فعلی و نقاط حساس
| حوزه | شاهد در مخزن | نتیجه برای طراحی |
| --- | --- | --- |
| کاربران | `apps/accounts/models.py:User` از `AbstractUser` و دارای `account_type` است | `is_staff` صرفاً به معنی اختیار مالی نیست؛ مشتری و کارشناس تفکیک شوند |
| نقش‌ها | `apps/staff/models.py:StaffProfile` و `ROLE_GROUPS` | ذخیره پروفایل، گروه نقش را همگام می‌کند؛ مجوز مالی به نام اشخاص یا عنوان شغلی hardcode نشود |
| واحدها | `OrganizationalUnit.permissions` و `UnitMembership.permissions`، عضویت فعال و `ticket_scope` | مجوز مستقیم، گروهی، واحدی و شخص در واحد موجودند؛ دامنه مالی مستقل تعریف شود |
| سیاست تیکت | `apps/tickets/policies.py:TicketPolicy` | `can_operate` برای کارشناس فعالِ دارای دسترسی تیکت گسترده است؛ برای تأیید مالی قابل استفاده نیست |
| همگام‌سازی نقش | `apps/core/management/commands/bootstrap.py` | `group.permissions.set(...)` می‌تواند مجوز گروهی سفارشی را حذف کند؛ اجرای bootstrap برای اعطای مجوز مالی مناسب نیست |
| صفحه مشتری | `customer_list`، `templates/tickets/customer_list.html` | `/tickets/` هم خانه و هم سوابق است؛ سوابق باید مقصد مستقل پیدا کند |
| ورود و PWA | `apps/accounts/views.py`، `config/urls.py`، `templates/base.html` | نام مسیر `tickets:list` و start_url فعلی در مهاجرت خانه حفظ شوند |
| پیامک | `NotificationJob`، `enqueue` و `process_notifications` | صف پایدار و dedupe وجود دارد؛ worker فعلاً فقط رویدادهای شناخته‌شده تیکت را می‌فرستد |
| متن پیامک | `SMSTemplate` و `_render_sms` | الگوی فعال واحد، سپس عمومی؛ allowed_tags و context کنترل می‌شوند |
| فایل | `Attachment.message` اجباری و مسیر وابسته به تیکت است | فیش مستقل نباید با تیکت یا پیام ساختگی ذخیره شود |
| تحویل فایل | `apps/attachments/delivery.py` و Nginx | امضای کوتاه‌عمر پس از احراز مجوز؛ view مخصوص مالی لازم است |
| تاریخ | `apps/core/templatetags/jalali.py` و `jdatetime` | نمایش شمسی و ذخیره میلادی؛ برای زمان رویدادها UTC و نمایش ایران |
| طراحی | `design-prototypes/chaharsu/STYLE_GUIDE.md` و README | prototype قرارداد UX است، نه پنل، دیتابیس یا ارسال واقعی |
دو سند سازمانی قدیمی در بعضی توضیحات با وضعیت امروز یکسان نیستند: توضیح دسترسی legacy بدون واحد با `viewable_q` فعلی تفاوت دارد؛ توضیح نبود استعلام دلیوری نیز با worker و تست‌های جدید دلیوری به‌روز نیست. در این موارد سورس و تست فعلی مبناست. وضعیت تحویل SMS با تأیید مالی اشتباه نشود.
## ۴. معماری و مرز مسئولیت‌ها
```mermaid
flowchart TD
C[چهارسو: مشتری احراز هویت‌شده] --> CV[payments customer views/forms]
S[میزکار کارشناسان] --> SV[payments staff views/forms]
B[بانک‌ها و تنظیمات مجاز] --> SV
CV --> P[PaymentPolicy و selectors محدودشده]
SV --> P
P --> W[services: صدور، ثبت، تصمیم، تغییر وضعیت]
W --> D[(حساب، اعلامیه، فیش، چرخه و ممیزی)]
W --> O[(NotificationJob با dedupe)]
O --> N[worker موجود و رویدادهای مالی]
N --> T[(SMSTemplate)]
N --> SMS[provider پیامک موجود]
CV --> F[رسانه خصوصی با مجوز مالی]
SV --> F
```
ساختار پیشنهادی: `models.py`، `policies.py`، `selectors.py`، `forms.py`، `services/` برای حساب/اعلامیه/فیش/تأیید، `notifications.py`، `customer_views.py`، `staff_views.py`، URLهای جدا و `admin.py`. همه تغییرات وضعیت از سرویس واحد عبور کنند؛ فرم، admin و view مستقیماً وضعیت تأیید را ننویسند.
برای اتصال به واحدها، `owning_unit` و `created_by` در اعلامیه ثبت شود. استقلال مالی به معنی بی‌نیازی از ثبت منشأ سازمانی نیست. اطلاعات تیکت، در صورت نیاز آینده، فقط ارتباط اختیاری خواهد داشت؛ مبنای دسترسی مالی نباشد.
## ۵. مدل داده پیشنهادی
نام‌ها پیشنهادی‌اند؛ قبل از کدنویسی در migration design نهایی شوند.
| مدل | داده‌های اصلی و قرارداد |
| --- | --- |
| `BankInstitution` | نام/کد بانک مبدأ و نام‌های تاریخی، فعال بودن، ترتیب؛ «سایر» با متن اختیاری؛ کاتالوگ محلی قابل نگهداری |
| `CompanyBankAccount` | نام نمایشی حساب، بانک، صاحب حساب، شماره حساب، شبا، کارت اختیاری، سه پرچم فعال، ترتیب، سازنده/ویرایشگر، زمان، نسخه |
| `PaymentNotice` | public UUID، شماره نمایشی یکتا، مشتری، واحد، صادرکننده، موضوع، مبلغ صحیح ریال، حساب مرجع، شناسه واریز اختیاری، شماره فاکتور، توضیحات، وضعیت صدور/لغو، تاریخ صدور و snapshot اطلاعات پرداخت |
| `PaymentReceipt` | public UUID، مشتری، `notice` اختیاری، ثبت‌کننده و نوع CUSTOMER/STAFF، نسخه جاری، وضعیت جاری و زمان ثبت |
| `PaymentReceiptRevision` | نسخه تغییرناپذیر: مبلغ واقعی، تاریخ پرداخت میلادی، حساب مقصد واقعی و snapshot آن، بانک مبدأ، شماره پیگیری اختیاری، توضیحات و فایل |
| `ReceiptDocument` | فایل خصوصی با UUID، نام اصلی، MIME واقعی، اندازه، SHA-256، آپلودکننده و زمان؛ مرتبط با نسخه فیش |
| `PaymentApprovalPolicy` | نسخه تنظیمات، روش تأیید، افراد منتخب، ارشدهای مجاز و واحد/دامنه سیاست؛ ویرایش با مجوز و لاگ |
| `ReceiptApprovalCycle` | نسخه فیش، snapshot سیاست، حد نصاب/روش، وضعیت، نهایی‌کننده و زمان؛ تنها یک چرخه جاری |
| `ReceiptApprovalAssignment` | افراد و نقش عادی/ارشدِ همان چرخه؛ مستقل از تغییر تنظیمات آینده |
| `ReceiptDecision` | چرخه، کاربر تصمیم‌گیر، APPROVE/REJECT/SENIOR_APPROVE، دلیل و زمان؛ یک رأی معتبر هر شخص در هر چرخه |
| `PaymentAuditEvent` | عملیات، موضوع، actor و snapshot هویت، زمان، قبل/بعد مجاز، دلیل، request ID و ارتباط نسخه/چرخه |
| `PaymentAccessConfiguration` | حالت ALL_ACTIVE_STAFF یا EXPLICIT برای مشاهده/ایجاد؛ جدا از سیاست تأیید |
| `PaymentNotification` | پیوند مالی به `NotificationJob`، رویداد و نسخه/چرخه، context و مشخصات الگوی مصرف‌شده؛ امکان گزارش پیامک بدون تیکت |
| `PaymentNotificationAttempt` | سابقه تغییرناپذیر هر تلاش مالی، زمان شروع/پایان، نتیجه، کد و مرجع provider؛ تلاش متوقف‌شده نیز قابل تشخیص و مستقل از reset شمارنده job |
شماره حساب، کارت، شبا، شناسه واریز و فاکتور رشته‌اند تا صفر ابتدایی از بین نرود. ارقام استاندارد ذخیره ASCII؛ نمایش می‌تواند فارسی باشد. شبا با طول/کشور/checksum و کارتِ موجود با قواعد شماره معتبر بررسی شود؛ اعتبار شماره اثبات مالکیت حساب نیست. همه حساب‌ها الزاماً کارت ندارند.
مبلغ `BigIntegerField` با قید مثبت و سقف مشخص قابل پشتیبانی سیستم پیشنهاد می‌شود. در Python محاسبه صحیح، در JS رشته عددی/BigInt؛ تبدیل تومان بدون float و بدون گرد کردن. سقف عملیاتی و سقف جمع مبالغ جدا کنترل شوند. مبلغ expected اعلامیه و paid فیش دو فیلد متفاوت‌اند.
قیود پایگاه داده: شماره/public_id یکتا، نسخه یکتا در هر فیش، رأی یکتای `(cycle, reviewer)`، چرخه جاری یکتا، dedupe پیامک یکتا، مبلغ مثبت و حالت‌های سازگار. هم‌مالک بودن notice و receipt و صلاحیت حساب در سرویس و فرم دوباره بررسی شوند؛ `CheckConstraint` معمولی نمی‌تواند تمام روابط بین جدول‌ها را تضمین کند.
روی مشتری/زمان، وضعیت/زمان، واحد/زمان، حساب/زمان و چرخه/کاربر index لازم است. حذف حساب یا رکورد مرجع استفاده‌شده با `PROTECT` یا آرشیو محدود شود؛ حذف فیش تأییدشده عملیات CRUD عادی نیست.
## ۶. مجوزها و دامنه مشاهده
«همه» در این طرح یعنی **کارشناسان داخلی فعال**، نه مشتریان، کاربران غیرفعال یا هر حساب دارای `is_staff`. پروفایل غیرفعال نیز مجوز ندارد. استثنای admin بدون StaffProfile مانند رفتار فعلی باید صریح و آزموده تعریف شود.
| اقدام | وضعیت اولیه | قابلیت محدودسازی بعدی |
| --- | --- | --- |
| دیدن میزکار اعلامیه‌ها و فیش‌ها | همه کارشناسان فعال | مجوز مشاهده و دامنه سراسری/واحدی/تخصیص شخصی |
| ایجاد اعلامیه | همه کارشناسان فعال | مجوز ایجاد و واحدهای مجاز |
| ثبت فیش توسط staff برای مشتری | همه کارشناسان فعال، با ثبت هویت ثبت‌کننده | مجوز مستقل ثبت فیش برای دیگری |
| مشاهده مشتری | فقط اعلامیه‌ها/فیش‌های خودش | همیشه object-level؛ تغییرپذیر به مشاهده همگانی نیست |
| ارسال مشتری | فقط برای خودش؛ notice باید متعلق به خودش باشد | کنترل مقصد و ارتباط در سرور |
| رأی تأیید یا رد | فقط شخص دارای مجوز و تخصیص در چرخه | ALL_SELECTED یا حد نصاب تعیین‌شده |
| تأیید ارشد | شخص دارای مجوز ویژه و تعیین‌شده به‌عنوان ارشد | هرگز صرف عنوان مدیرعامل یا `is_staff` |
| مدیریت بانک‌ها | مدیرعامل فعال، superuser و افراد دارای مجوز صریح | مجوز اختصاصی؛ سایر نقش‌ها به‌صورت پیش‌فرض ممنوع |
| تغییر سیاست/افراد تأیید | افراد دارای مجوز مدیریت تأیید | جدا از مشاهده یا ایجاد اعلامیه |
| مدیریت الگوهای مالی SMS | مجوز مربوط به SMSTemplate و دامنه واحد/عمومی | ایجاد اعلامیه مجوز تغییر متن SMS نمی‌دهد |
کدهای پیشنهادی: `view_paymentnotice`، `add_paymentnotice`، `view_paymentreceipt`، `submit_customer_receipt`، `approve_paymentreceipt`، `senior_approve_paymentreceipt`، `manage_company_bankaccounts`، `manage_payment_approval_policy`، `manage_payment_access` و `view_payment_audit`. دامنه سراسری مجوز جدا داشته باشد؛ مجوز واحدی فقط برای همان واحد اعمال شود.
`PaymentPolicy` الگوی ترکیب permissionهای Django و عضویت واحدی را به کار می‌گیرد، اما به `TicketPolicy.can_operate` یا `tickets.view_all_tickets` متکی نمی‌شود. مشتری حتی با حدس UUID، تغییر notice_id، account_id، پارامتر جست‌وجو، export یا URL فایل به داده دیگری دسترسی نمی‌گیرد.
پیشنهاد برای فاز اول: حالت ALL_ACTIVE_STAFF فقط در مشاهده/ایجاد فعال باشد. مجوزهای تأیید به هیچ گروه فروش یا مالی به‌طور عمومی seed نشوند. کاربران تأییدکننده و ارشد از admin/تنظیمات انتخاب شوند. superuser بتواند سیاست را مدیریت کند، اما امضای مالیِ خارج از چرخه را به‌صورت پنهان جایگزین رأی نکند؛ برای تأیید ارشد باید در مسیر صریح آن قرار گیرد.
مجوز مالی مستقیم یا membership اختصاصی در برابر `StaffProfile.save` و bootstrap پایدارتر است؛ اگر گروه‌های مالی مستقل ایجاد شدند، bootstrap گروه‌های مدیریت‌شده موجود نباید آن‌ها را بازنویسی کند. نیازی به چند boolean مالی در مدل `User` یا بازنویسی enum نقش‌ها نیست.
## ۷. تأیید چندنفره و ارشد
روش اولیه پیشنهادی `ALL_SELECTED` است: اگر الف و ب انتخاب شوند، رأی هر دو الزامی است؛ تأیید دو شخص دیگر جای آن‌ها را نمی‌گیرد. `QUORUM` برای «دو نفر از یک مجموعه» مفهوم دیگری است و فقط با نیاز کسب‌وکار فعال شود. حالت تک‌نفره همان ALL_SELECTED با یک شخص است.
هنگام ارسال فیش، سیاست معتبر و افراد آن در چرخه snapshot شوند. هیچ چرخه‌ای با فهرست خالی، شخص غیرفعال یا حد نصاب غیرممکن قابل شروع نیست. سازنده اعلامیه یا مشتری نتواند سیاست ضعیف‌تری انتخاب کند؛ سیاست از تنظیمات مدیریت تعیین شود.
```mermaid
stateDiagram-v2
[*] --> PENDING: ثبت نسخه فیش
PENDING --> PARTIALLY_APPROVED: رأی عادی و ناتمام بودن شرط
PARTIALLY_APPROVED --> APPROVED: تکمیل شرط تأیید
PENDING --> APPROVED: تک‌نفره یا تأیید ارشد مجاز
PARTIALLY_APPROVED --> APPROVED: تأیید ارشد مجاز
PENDING --> REJECTED: رد با دلیل
PARTIALLY_APPROVED --> REJECTED: رد با دلیل
REJECTED --> PENDING: نسخه اصلاحی و چرخه جدید
```
پیشنهاد پیش‌فرض: یک رد معتبر، چرخه را خاتمه دهد؛ اصلاح مشتری نسخه و چرخه جدید ایجاد کند. این قاعده و ممنوعیت تأیید مدرکی که همان کارشناس ثبت کرده، تصمیم‌های کسب‌وکاری نیازمند تعیین‌اند؛ در بخش ۱۵ ثبت شده‌اند.
هر اقدام در `transaction.atomic` با قفل ردیف فیش و چرخه جاری انجام شود. مجوز جاری، نسخه مورد انتظار، وضعیت و رأی تکراری داخل قفل بررسی شوند. قید یکتا لایه دوم دفاع است. ترتیب قفل‌ها ثابت باشد؛ در PostgreSQL، در صورت join nullable، الگوی قفل `of=("self",)` مانند تجربه تست Announcement رعایت شود.
رأی تکراری همان کاربر شمارش را بالا نبرد. رأی آخر و تأیید ارشد هم‌زمان فقط یک انتقال نهایی و یک پیامک تولید کنند. درخواست صفحه قدیمی نتواند نسخه جدید را تأیید کند؛ پاسخ «اطلاعات تغییر کرده؛ دوباره بررسی کنید» بدهد.
ارشد با اقدام مشخص «تأیید نهایی ارشد»، نمایش اثر و ثبت دلیل، انتظار سایر افراد را پایان می‌دهد. این اختیار برای چرخه جاریِ در انتظار است؛ رأی رد یا تأیید نهایی پیشین را پاک نمی‌کند. اصلاح تصمیم نهایی باید مسیر مجاز و ممیزی‌شده جدا داشته باشد.
تغییر مبلغ، تاریخ، مقصد یا تصویر پس از شروع تصمیم‌گیری، نسخه جدید و چرخه جدید می‌خواهد. رأی روی نسخه قبلی اعتبار نسخه جدید نیست. خروج کاربر از سازمان مانع رأی آینده او می‌شود، اما سوابق رأی قبلی را حذف نمی‌کند. جایگزینی افراد چرخه معلق با مجوز مدیریت، دلیل، بستن چرخه قبلی و ثبت چرخه تازه انجام شود.
## ۸. مدیریت بانک‌ها و کنترل جریان مالی
در sidebar راست «بانک‌ها» فقط برای مدیرعامل/admin مجاز و کاربران تعیین‌شده نمایش داده شود. کنترل endpoint و عملیات bulk مستقل از پنهان‌کردن منو لازم است.
سه پرچم روی **حساب شرکت** باشند، نه فقط نام بانک؛ ممکن است دو حساب یک بانک سیاست متفاوت داشته باشند:
- `is_active`: قطع کلی انتخاب جدید.
- `available_to_staff`: قابل انتخاب برای صدور اعلامیه توسط کارشناس.
- `available_to_customers`: قابل انتخاب در فرم فیش مستقل مشتری؛ فقط نام حساب نمایش داده می‌شود.
eligibility staff برابر `is_active AND available_to_staff` و eligibility مستقل مشتری برابر `is_active AND available_to_customers` است. وضعیت کلی غیرفعال بر دو گزینه دیگر اولویت دارد. فعال کردن چند حساب یا خاموش کردن چند حساب با POST، CSRF، مجوز، transaction و لاگ هر حساب انجام شود.
ویرایش، افزودن، جست‌وجو، فیلتر سه وضعیت و صفحه‌بندی؛ انتخاب ردیف‌ها و تأیید تغییر جمعی. «انتخاب همه» روشن کند منظور صفحه جاری است یا تمام نتیجه فیلتر؛ در فاز اول صفحه جاری ساده‌تر است. رکورد استفاده‌نشده با مجوز قابل حذف؛ استفاده‌شده آرشیو شود و علت عدم حذف نمایش داده شود.
**تعارض مهم سیاست مقصد:** حساب فقط-staff ممکن است در اعلامیه اختصاصی مشتری باشد ولی در dropdown مستقل مشتری نباشد. پیشنهاد: نمایش و انتخاب همان حساب صرفاً در context اعلامیه معتبر خودش مجاز باشد؛ dropdown مستقل همچنان فقط حساب‌های customer-enabled را ارائه کند. اگر خاموش کردن customer باید اعلامیه‌های موجود را هم متوقف کند، باید اعلامیه نیز صریحاً لغو/جایگزین شود. این رفتار پیش از اجرا تعیین شود.
خاموش‌کردن حساب نباید سوابق مالی را پنهان یا بررسی فیش پرداخت گذشته را متوقف کند. در فرم قدیمی، تغییر eligibility در لحظه POST دوباره بررسی شود؛ برای پرداختی که قبلاً انجام شده، مسیر استثنای ثبت واقعیت با دلیل و بررسی کارشناس لازم است. بازگرداندن خودکار حساب دیگری یا تغییر بی‌خبر مقصد ممنوع است.
اطلاعات بانکی در اعلامیه صادرشده snapshot شوند. تغییر اطلاعات واقعی حساب بهتر است رکورد/نسخه جدید بسازد؛ تغییر نام نمایشی نیز سابقه اعلان قبلی را بازنویسی نکند. شناسه واریز و مبلغ روی اعلامیه‌اند، چون هر بار ممکن است متفاوت باشند.
## ۹. میزکار اعلامیه‌ها و بررسی فیش‌ها
نام پیشنهادی منو «اعلامیه واریز» و عنوان صفحه «اعلامیه‌ها و فیش‌های واریزی» است. دکمه اصلی «ایجاد اعلامیه واریز»؛ ثبت فیش توسط کارشناس اقدام ثانویه. در همان میزکار، تب‌های «اعلامیه‌ها»، «فیش‌ها» و فیلتر «نیازمند اقدام من» وجود داشته باشند. تب فیش‌ها **فیش مستقل را نیز** نشان دهد؛ query با inner join روی notice آن را حذف نکند.
فرم صدور: انتخاب مشتری از فهرست فعلی مخاطبان، موضوع، حساب مجاز staff، مبلغ ریال، شناسه اختیاری، فاکتور و توضیحات. نام مشتری و صاحب حساب مشخص باشد. انتخاب مشتری موجود، مشخصات تماس او را ناخواسته ویرایش نکند؛ فرم ایجاد تیکت امروز مسئولیت‌های بیشتری دارد و نباید عیناً reuse شود.
قانون انتشار: draft پیامک ندارد؛ «صدور و اطلاع‌رسانی» اعلامیه را برای مشتری قابل مشاهده و job پیامک را در همان transaction ایجاد می‌کند. شکست پیامک اعلامیه را حذف نمی‌کند؛ وضعیت جدا «پیامک ناموفق» و مسیر retry مجاز نمایش داده شود.
فیلترهای مشترک: متن/شماره، مشتری و موبایل، فاکتور، بازه تاریخ، حساب، صادرکننده و واحد. فیش‌ها: وضعیت، مرتبط/مستقل، تأییدکننده و «اقدام من». وضعیت ارسال/تحویل SMS فیلتر جداست. مرتب‌سازی allowlist؛ پیش‌فرض تازه‌ترین با tie-breaker ID و صفحه‌بندی سمت سرور، مثلاً ۲۵ ردیف با سقف مشخص.
فیلترها با query string حفظ شوند؛ تغییر فیلتر page را بازنشانی کند. تاریخ فیلتر شمسی نمایش و در سرور میلادی معتبر شود؛ فیلتر datetime بر اساس ابتدا/انتهای روز ایران باشد. جست‌وجوی ارقام فارسی و موبایل با الگوی normalize موجود سازگار شود. قبل از search/pagination/count، queryset مجاز محدود شود.
ردیف فیش: مشتری، مبلغ، تاریخ، حساب، شماره اعلامیه یا «مستقل»، وضعیت و پیشرفت «۱ از ۲ تأیید». جزئیات: تصویر قابل بزرگ‌نمایی، داده پرداخت، اختلاف با اعلامیه و timeline نام/زمان/دلیل تصمیم‌ها. رأی عادی با متن «ثبت تأیید من» و نتیجه روشن؛ در تأیید چندنفره از عبارت گمراه‌کننده «فیش تأیید شد» استفاده نشود.
دکمه رد دلیل اجباری دارد. افراد بدون مجوز، اطلاعات و سوابق را می‌بینند ولی دکمه تصمیم ندارند؛ POST جعلی نیز ممنوع است. نام کسانی که هنوز باید تأیید کنند و نهایی‌کننده عادی/ارشد مشخص باشد. جدول در موبایل به کارت خوانا تبدیل شود؛ اطلاعات اصلی در ستون‌های باریک بریده نشوند.
badge sidebar: **تعداد فیش‌های در انتظار بررسی در دامنه قابل مشاهده**، شامل مستقل و مرتبط و تأیید جزئی؛ هر فیش یک بار، نه تعداد رأی یا اعلامیه. «نیازمند اقدام من» شمارش مستقل داخل صفحه دارد. ایجاد اعلامیه بدون دریافت فیش این badge را افزایش نمی‌دهد. پس از رأی آخر کم می‌شود؛ پس از رأی جزئی باقی می‌ماند.
شمارنده از selector مشترک میزکار استفاده کند؛ `distinct`/Exists و prefetch مناسب، بدون N+1. refresh اولیه سرور و در صورت نیاز polling محلی مشابه shares با توقف در صفحه پنهان، و پاسخ JSON `private,no-store`. badge جدا از `staff_unread_shares` باشد؛ دسترسی گسترده مالی، count تیکت‌های واحدهای دیگر را تغییر ندهد.
## ۱۰. چهارسو و قرارداد UX مشتری
مسیر `/tickets/` و نام `tickets:list` برای ورود و PWA حفظ شود، اما مقصد به خانه خدمات تبدیل شود. سوابق به `/tickets/requests/` با نام `tickets:history` منتقل شود؛ لینک‌های فعلی «درخواست‌های من» به آن مقصد اصلاح شوند. جزئیات تیکت، Magic Link و ثبت درخواست فعلی باقی بمانند.
مسیرهای پیشنهادی مالی مشتری `/payments/notices/`، `/payments/receipts/new/` و `/payments/receipts/<uuid>/`؛ staff زیر `/staff/payments/` و `/staff/banks/`. include جدا با namespace مستقل؛ بدون تغییر namespace تیکت و بدون تداخل الگوهای UUID موجود.
خانه مطابق نمونه: کارت مشترک «ارتباط با کارشناسان» با دو اقدام ثبت درخواست/درخواست‌های من؛ جمله «سؤال بپرسید، درخواست ثبت کنید یا برای پیگیری و هماهنگی با کارشناسان در ارتباط باشید.» در موبایل و دسکتاپ دیده شود. دو خدمت مالی جدا؛ هیچ فهرست «درخواست‌های اخیر» در خانه قرار نگیرد.
«شماره حساب‌ها» در واقع فهرست اعلامیه‌های اختصاصی است؛ catalog کامل حساب‌های شرکت برای مشتری نمایش داده نشود. کارت‌های compact بازشونده با مبلغ/موضوع/وضعیت؛ اطلاعات تکمیلی و توضیحات **داخل کارت بازشده همیشه نمایان** و بدون accordion دوم. راهنمای کوتاه درخواست اطلاعات و شناسه از کارشناس باقی بماند.
کپی تکی فقط مبلغ، حساب، شبا، کارت و شناسه موجود؛ مقدار عددی بدون جداکننده نمایشی. «کپی کامل اطلاعات» از snapshot کامل و مجاز اعلامیه ساخته شود، نه DOM قابل مشاهده؛ بانک، صاحب حساب، فاکتور، توضیحات و واحد ریال را هم شامل شود. Clipboard فقط بعد از موفقیت بازخورد بدهد؛ fallback انتخاب دستی و Escape و بازگشت فوکوس حفظ شود.
فرم فیش: مرتبط اولین گزینه پیشنهادی و مستقل همیشه در دسترس. ورود از اعلامیه همان مورد را انتخاب کند؛ تغییر حالت فایل، مبلغ و تاریخ را پاک نکند. حساب مقصد اجباری و فقط نام‌ها در dropdown؛ مبدأ اختیاری با بانک‌های محلی، نام‌های تاریخی و «سایر». داده‌های fixture و ID ساختگی وارد production نشوند.
مبلغ ریال اجباری با هزارگان و معادل دقیق تومان به حروف؛ تاریخ کامل و تصویر اجباری. سه select روز/ماه/سال شمسی و «امروز» مینیمال حفظ شود. `paid_date` خروجی میلادی `YYYY-MM-DD`، بدون تبدیل midnight به timestamp؛ روز ابتدا خالی. بازه سال و ممنوعیت آینده از سیاست سرور بیاید. jdatetime سمت سرور، کامل بودن تاریخ، کبیسه، تاریخ غیرآینده و تطبیق شمسی/میلادی را بررسی کند؛ hidden input قابل اعتماد نیست. بدون JS نیز سه مقدار شمسی با فرم سرور قابل اعتبارسنجی و تبدیل باشند.
مقصد متفاوت با اعلامیه: مبلغ یا انتخاب کاربر خودکار بازنویسی نشود. ثبت واقعی با نشان «مغایرت مقصد» و دلیل/بررسی ویژه پیشنهاد می‌شود؛ ارتباط فیش با اعلامیه به‌تنهایی تطبیق یا تسویه آن نیست. چنین فیشی قبل از تعیین تکلیف مجاز، در جمع تسویه اعلامیه نیاید.
اعتبارسنجی مرورگر از قواعد واقعی سرور تغذیه شود؛ خطای فارسی زیر فیلد + summary لینک‌دار + aria-invalid/describedby + هدایت به اولین کنترل. پاسخ validation سرور همان اطلاعات را حفظ کند؛ مرورگر ممکن است انتخاب مجدد فایل را لازم کند و باید صریح بگوید. submit دوباره با idempotency token و وضعیت pending مدیریت شود. موفقیت فقط بعد از commit واقعی: «فیش ثبت شد و در انتظار بررسی است».
ظاهر از `STYLE_GUIDE.md`: وزیرمتن موجود، RTL، متن عملیاتی حداقل ۱۴px، ورودی ۱۶px، کنترل اصلی حداقل ۴۴px، رنگ سرمه‌ای/سفید و سطوح موجود. جفت‌رنگ‌های دارای کنتراست ناکافی نمونه تکثیر نشوند. همه فونت، SVG، JS و CSS محلی و versioned؛ CDN یا fetch دارایی خارجی ممنوع. داده مالی از سرور خود پروژه می‌آید؛ SMS بک‌اند وابستگی CDN رابط محسوب نمی‌شود.
SVGها در include محلی مشترک؛ CSS مالی scoped و asset صفحه‌ای، بدون تغییر عمومی بی‌هدف. امضا از `developer_credit` دیتابیس فعلی تأمین شود. تنظیمات hero/PageTitle برای خانه جدید جدا و بدون overwrite متن سفارشی موجود طراحی شوند. شرط دیدن سه خدمت روی ۳۲۰×۵۶۸ در حالت معمول آزموده شود؛ بزرگ‌نمایی متن و نمایش خطا نباید قربانی حذف اسکرول شوند.
## ۱۱. پیامک مالی با زیرساخت موجود
`NotificationJob.ticket` nullable است و `enqueue` می‌تواند با `ticket=None` job بسازد. اما `_send_sms` رویداد ناشناخته را رد می‌کند و `_sms_context` به `job.ticket` وابسته است. بنابراین افزودن Event به مدل به‌تنهایی کافی نیست.
پیشنهاد: `PaymentNotification` در اپ مالی، job موجود را به notice/receipt revision/cycle مربوط کند؛ worker handler مالی مشخص داشته باشد و context مالی را مستقل بسازد. نیازی به تیکت صوری، worker دوم یا تغییر nullable بودن Attachment برای این اتصال نیست. وابستگی migration از payments به notifications قابل طراحی است؛ notifications نباید به models مالی در سطح module import دوری پیدا کند.
دو رویداد جدید در `SMSTemplate.Event`:
- `PAYMENT_NOTICE_CREATED`: فقط هنگام صدور اعلامیه، یک بار برای مشتری.
- `PAYMENT_RECEIPT_APPROVED`: فقط انتقال نهایی چرخه به APPROVED؛ رأی جزئی پیامک «تأیید شد» ندارد.
تگ‌های مالی محدود مانند `{recipient_name}`، `{company_name}`، `{notice_number}`، `{receipt_number}`، `{amount_rial}`، `{invoice_number}`، `{payment_link}`. allowed_tags هر رویداد با context واقعی آن سازگار باشد؛ قالب مالی نتواند اشتباهاً `{magic_link}` تیکت طلب کند. برای فیش مستقل، فاکتور/اعلامیه غایب متن خالی یا fallback مناسب دارد. renderer عمومی موجود قابل استفاده است؛ context تیکت قابل استفاده مستقیم نیست.
defaultهای عمومی با data migration idempotent و `get_or_create` ایجاد شوند؛ متن‌های ویرایش‌شده مشتری overwrite نشوند. الگوی واحدی فعال، سپس الگوی عمومی فعال مانند امروز. متن از DB قابل تغییر بماند؛ UI ویرایش برای allowed_tags مالی و preview بدون ارسال واقعی به‌روز شود. نبود template/context معتبر، خطای قابل مشاهده و قابل پیگیری باشد.
ثبت business event، ممیزی و enqueue job در همان transaction انجام شود؛ `on_commit` صرفاً برای wake-up اختیاری و نه جای ذخیره outbox. کلید dedupe شامل نوع رویداد و notice version یا receipt cycle نهایی باشد. نتیجه SMS موفق/ناموفق اثری بر صحت تصمیم مالی ندارد.
برای ممیزی دقیق، context، template ID/updated_at و متن مصرف‌شده در اولین تلاش ثبت و در retry همان متن استفاده شود؛ ویرایش template روی اعلان‌های آینده اثر کند. متن و payload فقط در دسترس اشخاص مجاز گزارش باشد، در log عمومی یا خطای کاربر چاپ نشود. سابقه تلاش‌ها append-only لازم است؛ job فعلی شمارنده و آخرین خطا دارد و به‌تنهایی تاریخچه کامل تمام تلاش‌ها نیست.
retry پایه SMS فعلی و استعلام delivery حفظ شوند. retry خاص ۴۵ دقیقه‌ای امروز فقط برای `STAFF_TICKET_CREATED` است؛ خودکار به مالی تعمیم داده نشود. debounce/cancel مربوط به خواندن مکالمه `STAFF_REPLY` نیز به پیامک مالی تعمیم داده نشود. خرابی پیامک مالی پردازش سایر jobها را متوقف نکند.
لینک SMS مالی به صفحه احراز هویت‌شده خود پروژه و `next` امن اشاره کند. Magic Link امروز مجوز فقط یک تیکت می‌دهد و session کامل مشتری نمی‌سازد؛ برای اطلاعات بانکی/فیش استفاده نشود. ادامه مقصد پس از OTP باید با تست مسیر ورود پشتیبانی شود. ایجاد access token جدید مالی تصمیم جداست و در این طرح لازم نیست.
در سورس فعلی، ورود OTP و رمز مقصد را از `session["next"]` می‌خوانند؛ دریافت و اعتبارسنجی `GET next` برای لینک مالی مسیر آماده‌ای نیست. در طرح اجرایی باید مقصد محلی allowlistشده هنگام ورود ذخیره، پس از ورود و بررسی مالکیت مصرف و از redirect به دامنه خارجی جلوگیری شود. مسیر ورود از SMS برای کاربر واردشده، مهمان و حساب مشتری دیگر جدا آزموده شود.
dedupe ثبت job را یکتا می‌کند؛ crash پس از پذیرش provider و قبل از save می‌تواند ارسال دوباره ایجاد کند. بدون idempotency provider، ادعای exactly-once SMS نکنیم. وضعیت مبهم ارسال در ممیزی و گزارش قابل تشخیص باشد؛ رأی و تأیید نهایی داخل DB باید یکتا بمانند.
## ۱۲. فایل خصوصی و ثبت سوابق
برای فیش `ReceiptDocument` با storage فعلی ولی مسیر خصوصی مالی مستقل استفاده شود. اعتبارسنجی موجود magic/MIME/سایز قابل استخراج و reuse است؛ وابستگی به Message/Ticket حذف شود و تست پیوست‌های قدیمی حفظ شود. تصویر باید واقعاً قابل decode و محدودیت ابعاد/حجم داشته باشد؛ سیاست IMAGE واقعی DB منبع محدودیت UI و سرور است. مقادیر نمونه ۱۰MB قانون دائمی نیستند.
view تصویر/download ابتدا مالک مشتری یا دامنه staff مالی را کنترل کند، سپس از signed_media_redirect موجود استفاده کند. URL مستقیم `/media/...` مجوز نیست؛ در DEBUG و Nginx نیز این مرز آزموده شود. امضای صادرشده تا انقضای کوتاه‌عمر یک bearer URL است؛ ابطال مجوز، URL قبلاً صادرشده را فوری باطل نمی‌کند. اگر ابطال فوری لازم شد، تحویل فایل از مسیر کنترل‌شونده هر درخواست انتخاب شود.
فایل و DB transaction واحد ندارند؛ خطای validation/rollback پس از ذخیره باید orphan cleanup داشته باشد. نام ذخیره UUID و نسخه ثابت؛ جایگزینی فایل پس از رأی ممنوع. هش یکسان مدرک هشدار تکراری بدهد، اما به‌تنهایی دلیل رد قطعی نیست؛ یک تصویر ممکن است چند پرداخت را نشان دهد.
ممیزی اقدامات: ایجاد/ویرایش/صدور/لغو اعلامیه، ثبت/نسخه اصلاحی فیش، رأی/رد/ارشد، تغییر سیاست و تخصیص، CRUD/فعال‌سازی جمعی حساب، retry و تلاش ارسال پیامک. هر رویداد actor ID + snapshot نام/شناسه عمومی، زمان aware UTC و نمایش شمسی ایران، دلیل و request ID داشته باشد. timeline جزئیات فیش، تمام رأی‌دهندگان و نهایی‌کننده را نشان دهد.
تغییر permission مالی در UserAdmin، GroupAdmin، واحد یا membership نیز عملیات مالی مؤثر است و باید actor و قبل/بعد ثبت شود. صرف log سرویس تأیید این تغییرات را پوشش نمی‌دهد؛ adminهای موجود با hook ممیزی اختصاصی permissionهای payments و فرم مدیریت دسترسی به سرویس ممیزی متصل شوند. تغییر برنامه‌ای فاقد actor به نام SYSTEM و با شناسه عملیات ثبت شود؛ به signal بدون امکان تشخیص actor اکتفا نشود.
بازدید جزئیات و درخواست فایل حساس نیز access log داشته باشد. دانلود واقعی در Nginx با redirect موفق از Django یکسان نیست؛ برای اثبات انتقال فایل باید log رسانه با correlation بررسی شود. کپی Clipboard محلی، server event قطعی ندارد؛ «ثبت هر کار» برای عملیات معتبر سرور تضمین می‌شود، telemetry کپی فقط گزارش کمکی است و موفقیت واقعی انتقال به پیام‌رسان قابل اثبات نیست.
audit و decision در admin readonly و حذف/ویرایش عادی ممنوع؛ تمام مسیرهای UI/admin/service این قرارداد را رعایت کنند. نگهداری سوابق هنگام حذف کاربر با PROTECT یا SET_NULL + snapshot تعیین شود. DB admin همچنان اختیار فنی دارد؛ audit اپلیکیشن ادعای مصونیت مطلق از تغییر دیتابیس نیست. مهلت نگهداری فایل و log باید سیاست مشخص داشته باشد.
## ۱۳. دامنه تغییر و ریسک رگرسیون
| مسیر | تغییر مورد انتظار | ریسک و کنترل |
| --- | --- | --- |
| `apps/payments/` جدید | مدل، سرویس، policy، فرم، view و migration | بیشترین منطق جدید؛ تست مستقل دامنه و concurrency |
| `config/settings/base.py` و `config/urls.py` | ثبت app و URLهای مستقل | حفظ routeهای موجود و start_url |
| `apps/accounts/views.py` | ادامه امن مقصد مالی پس از OTP/رمز | حفظ ورود فعلی و جلوگیری از open redirect یا دورزدن مالکیت |
| `apps/tickets/customer_urls.py` و `customer_list` | خانه خدمات + history مستقل | تغییر آگاهانه انتظارات تست خانه، حفظ new/detail/magic |
| `templates/base.html` و context processor | منو مالی/بانک، badge و active section | desktop/mobile، permissions، shares counter و CSS isolation |
| قالب/assetهای مالی جدید | انتقال قرارداد نمونه به Django | بدون fixture، CDN یا موفقیت جعلی؛ version تمام assetها |
| `apps/notifications/models.py` و worker | Event و routing/context مالی | شکست event جدید نباید رویدادهای قدیمی را خراب کند |
| فرم/admin SMS موجود | tags و تنظیم template مالی | unit/global access و عدم overwrite متن DB |
| admin کاربران/گروه/واحد و membership | ممیزی تغییر مجوزهای مالی | ثبت actor بدون تغییر رفتار مجوزهای تیکت |
| اعتبارسنجی attachments | استخراج validator مشترک در صورت نیاز | مرز مجوز فایل تیکت تغییر نکند |
| تنظیمات branding/PageTitle | متن خانه خدمات و امضا | حفظ تنظیمات سفارشی و تست branding |
| production seed | اضافه کردن تنظیمات مالیِ مجاز | عدم وارد کردن outbox/تأییدها یا حساب واقعی ناخواسته |
| تست‌ها و اسناد QA | تست‌های جدید و اصلاح موارد تغییر محصول | عدم تضعیف assertions امنیتی موجود |
از تغییر ساختار بنیادی `User`، `StaffProfile.Role`، مجوزهای تیکت و routing Magic Link برای این قابلیت پرهیز شود. new financial permissions در existing admin قابل مدیریت‌اند؛ افزودن inline/read-only وضعیت در UserAdmin فقط اگر UX مدیریت لازم داشت انجام شود.
عدد دقیق فایل‌های نهایی تا قراردادهای بخش ۱۵ قطعی نیست؛ تغییر محدود به چند HTML نیست. پیش از هر ویرایش غیرساده، `--impact` و `--uses` برای symbol واقعی هدف، و پس از آن `--test-gate` و `--quality-delta` مطابق دستور پروژه اجرا شوند. یافته ابزار، جای آزمون dynamic URL/template/ORM را نمی‌گیرد.
## ۱۴. برنامه اجرایی مرحله‌بندی‌شده و معیار خروج
| مرحله | کار | شرط پایان |
| --- | --- | --- |
| ۰ | تثبیت قراردادهای بخش ۱۵، baseline تست‌ها و بررسی فقط‌خواندنی تنظیمات واقعی | تصمیم‌ها ثبت و رگرسیون‌های قبلی از جدید تفکیک شوند |
| ۱ | اپ payments، schema/constraints، PermissionPolicy، audit و تنظیم دسترسی | مشتری دیگر/کارشناس غیرفعال/POST جعلی دسترسی ندارد؛ ALL_STAFF فقط view/create را باز می‌کند |
| ۲ | مدیریت حساب staff/admin و bulk state | سه پرچم و snapshot/آرشیو درست؛ فقط افراد مجاز CRUD دارند |
| ۳ | صدور اعلامیه، فرم مشتری picker و SMS template/handler/outbox | یک اعلامیه اختصاصی + یک job؛ failure مستقل و retry قابل پیگیری |
| ۴ | خانه چهارسو و history مستقل و صفحه اعلامیه مشتری | UX نمونه با داده واقعی، سه خدمت خانه و عدم افشای حساب عمومی |
| ۵ | ثبت فیش مستقل/مرتبط، فایل خصوصی، تاریخ/مبلغ/مقصد و revision | validation دوطرفه، idempotency، ownership، خطاهای قابل فهم و ثبت واقعی |
| ۶ | رأی تک/چندنفره و ارشد، timeline و SMS نهایی | raceها و رأی تکراری امن؛ قبل از پایان شرط هیچ پیامک تأیید ارسال نشود |
| ۷ | میزکار جست‌وجو/فیلتر/صفحه‌بندی، شمارنده sidebar | فیش مستقل حذف نشود؛ badge دقیق و queries محدود/بدون N+1 |
| ۸ | یکپارچگی، E2E، PostgreSQL concurrency و مستند عملیات | مجموعه رگرسیون + سناریوهای جدید + بازبینی رسانه/PWA پاس شوند |
| ۹ | استقرار مرحله‌ای، آموزش، پایش و بازگشت UI | دسترسی/سیاست/worker پیش از باز شدن ارسال مشتری آماده باشند |
ترتیب وابستگی: مدل و policy → حساب‌ها → اعلامیه → فیش → تأیید → میزکار/شمارنده → یکپارچگی. UI را می‌توان پیش‌تر preview کرد، اما ارسال مشتری قبل از آماده شدن مسیر بررسی/تأیید فعال نشود. هیچ نیاز اجباری مانند چندنفره یا ارشد به «بعداً» منتقل نشود.
feature flag برای customer hub و عملیات مالی، مهاجرت‌های افزایشی و rollout ابتدا داخلی پیشنهاد می‌شود. خاموش کردن قابلیت، داده ثبت‌شده را حذف نکند و دسترسی staff برای رسیدگی باقی بماند. rollback کد تا قبل از اولین فیش ساده‌تر است؛ بعد از آن برگشت مخرب schema ممنوع و forward fix لازم است.
استقرار شامل migrate، build محلی CSS، collectstatic و rollout web/notification-worker سازگار با هر دو رویداد جدید است. worker جدید پیش از صدور event مالی فعال شود؛ worker قدیمی رویداد مالی را Unsupported می‌داند. schema تغییرات قدیم را حذف نکند. backup دیتابیس و media، آموزش افراد تأیید و چک تنظیمات template لازم‌اند.
برآورد زمان دقیق بدون تصمیم‌های سیاستی و baseline اجرا قابل اتکا نیست. پروژه باید در تحویل‌های قابل آزمون بالا مدیریت شود؛ تکمیل صرف صفحه یا مدل معیار اتمام نیست.
## ۱۵. تصمیم‌های کسب‌وکاری پیش از پیاده‌سازی
این موارد مانع تهیه طرح نیستند؛ پیش از کدنویسی سیاست مالی باید پاسخ ثبت‌شده داشته باشند:
1. افراد اولیه دارای تأیید، ارشد و مدیریت سیاست چه کسانی‌اند؟ انتخاب از DB، نه username hardcode.
2. آیا هر اعلامیه سیاست مخصوص دارد یا سیاست پیش‌فرض واحد؟ پیشنهاد اولیه سیاست نسخه‌دار واحد + fallback عمومی، انتخاب توسط مدیر مجاز.
3. «دو نفر» یعنی هر دو فرد مشخص یا دو نفر از یک مجموعه؟ پیش‌فرض این سند همه افراد منتخب است.
4. رد یک نفر چرخه را می‌بندد؟ پیشنهاد بله، اصلاح با نسخه تازه؛ تأیید ارشد روی چرخه ردشده نیازمند گردش جداست.
5. آیا ثبت‌کننده staff می‌تواند فیش ثبت‌شده خودش را تأیید کند؟ پیشنهاد جلوگیری برای تفکیک وظایف، ولی باید با نیاز شرکت تطبیق داده شود.
6. پرداخت جزئی/چند فیش برای یک اعلامیه مجاز است؟ پیشنهاد مدل یک‌به‌چند از ابتدا؛ تسویه بر جمع مبالغ معتبر تأییدشده، اضافه‌پرداخت با وضعیت مشخص. لغو اعلامیه پرداخت‌شده تسویه را پاک نکند.
7. حساب staff-only در اعلامیه اختصاصی قابل پرداخت مشتری باشد؟ پیشنهاد بله فقط در context آن اعلامیه؛ حساب globally-disabled و اعلامیه قدیمی چه رفتار/مهلت ثبت پرداخت گذشته‌ای دارند؟
8. مشتری مقصدی متفاوت با اعلامیه انتخاب کرد چه شود؟ پیشنهاد ثبت واقعیت با پرچم مغایرت و بررسی ویژه، بدون تسویه خودکار اعلامیه.
9. مبلغ دقیق چه سقفی دارد، تاریخ پرداخت تا چند سال قبل مجاز است و آیا فیش بیش از یک تصویر نیاز دارد؟ پیشنهاد اولیه یک تصویر اجباری مطابق نمونه و سیاست IMAGE سرور.
10. تغییر/لغو تصمیم نهایی، سیاست نگهداری فایل و لاگ، و پیامک اصلاح/رد لازم است؟ دو SMS الزامی فعلی صدور و تأیید نهایی‌اند؛ رویدادهای اضافی نیاز جدا هستند.
## ۱۶. تست‌های موجود بررسی‌شده و قرارداد رگرسیون
| فایل/گروه | قرارداد مؤثر |
| --- | --- |
| `tests/test_permission_architecture.py` | نقش، membership، unit isolation، SELF/UNIT، مجوز عملیات و جعل گزینه فرم |
| `tests/test_role_workflow_regression.py` | نمایندگان نقش‌ها، واحد اولیه، public/private، فرم/گزینه و rollback پیوست |
| `tests/test_organizational_ticketing.py` | dispatch ایزوله، contact shared با group واحدی، audit، template واحدی، فایل، magic و retry |
| `tests/test_domain.py` | OTP، تیکت/پیام، attachment policy، SMS outbox/template، debounce/retry/delivery و Magic Link |
| `tests/test_views.py` | home/history فعلی، branding، sidebar، PWA، magic scope، polling و no-store |
| `tests/test_staff_sharing.py` | اشتراک کل/یک پیام، فایل خصوصی، ابطال، CSRF و عدم افشای همسایه‌ها |
| `tests/test_staff_shared_queue.py` و `test_staff_inline_sharing.py` | مجوز عملیاتی همه نقش‌های فعال روی تیکت قابل مشاهده؛ قابل تعمیم به approval نیست |
| `tests/test_announcements.py` | صف تماس صوتی، قفل PostgreSQL و عدم توقف کار worker در خطای provider |
| `tests/test_voip_calls.py` | مجوز/مسیر تماس و عدم تغییر غیرضروری داده در عملیات |
| `tests/test_branding_operations.py` | متن‌ها و عنوان‌های DB، امضا و layout پنل |
| `tests/test_customer_category_catalog.py` و `test_customer_unit_categories.py` | category واحدی، گزینه‌های فعال و جست‌وجوی معنایی فرم مشتری |
| `tests/test_deployment.py` | private media/Nginx، worker، شبکه و PostgreSQL در استقرار |
| `tests/test_production_seed.py` | allowlist مدل‌ها، permissions با natural keys، import و marker تکرارپذیر |
| `tests/test_env.py` | تنظیم محیط و اولویت منبع تنظیمات |
| `tests/test_jalali_templates.py` و `test_persian_text.py` | تاریخ نمایشی شمسی و حفظ رشته‌های فنی/اعداد در normalization |
| `apps/phone_contacts_api/test_api.py` | API مخاطب، token، فعال بودن و normalization شماره؛ داده مالی نباید به این API افزوده شود |
| `tests/js/chat-coach.test.mjs` | رفتار JS مکالمه؛ انتقال CSS/JS نباید آن را خراب کند |
| `tests/e2e/test_public_ticket_workflows_e2e.py` | مسیر واقعی ثبت/پیام/فایل مشتری و کارشناس، scope Magic Link |
| `tests/e2e/test_organization_permissions_e2e.py` | رفتار واقعی نقش‌ها، گزینه‌های AJAX و مرز واحد |
| `tests/e2e/test_staff_operations_ui.py` | فرم staff، picker، برند، sidebar، گزارش دلیوری و موبایل |
| `tests/e2e/test_staff_sharing_e2e.py` | اشتراک واقعی و فایل در مرورگر |
| `tests/e2e/test_customer_unit_categories_e2e.py` | جست‌وجوی دسته مشتری و انتخاب واحد |
| `tests/e2e/test_chat_coach.py` | راهنمای مکالمه و تعامل مرورگر |
| `tests/e2e/test_mobile_ticket_viewport.py` | صفحه‌کلید، composer، viewport و اسکرول موبایل |
| `tests/e2e/test_pwa_versioning.py` | URL/cache نسخه‌دار و lifecycle service worker |
| `tests/e2e/test_external_environment_smoke.py` | smoke فقط‌خواندنی محیط خارجی؛ جدا از آزمون DB محلی |
| `design-prototypes/ticket-new-wizard/test_prototype_e2e.py` | تست مستقل prototype روی پورت ۴۱۷۳؛ تست production چهارسو نیست |
`pytest.ini`، fixtureهای `tests/e2e/conftest.py`، config/README و Page Objectهای auth/base/customer_new_ticket/sharing/tickets نیز مبنا قرار گرفتند. اجرای تست مستقل prototype نیازمند سرور خودش است و نباید با مجموعه live_server اشتباه شود.
تست‌های home که حضور `recent-requests` در `/tickets/` را الزام می‌کنند با تغییر محصول باید به history منتقل شوند؛ assertions مجوز، فایل، PWA و branding تضعیف نشوند. تیکت‌های تاریخی با دسته «فیش واریزی» خودکار به رکورد تأییدشده مالی تبدیل نشوند.
## ۱۷. پوشش آزمون جدید و معیار پذیرش نهایی
### دامنه، مجوز و دیتابیس
- مشتری الف نتواند اعلامیه/فیش/فایل مشتری ب را ببیند یا مرتبط کند؛ staff غیرفعال، مجوز لغوشده، CSRF و POST جعلی پوشش داده شوند.
- ALL_STAFF مشاهده/ایجاد بدون مجوز تأیید؛ حالت EXPLICIT واقعاً queryset/جزئیات/count/files را محدود کند؛ role finance مجوز تأیید خودکار نگیرد.
- مدیرعامل/کاربر مجاز بانک‌ها را مدیریت کند؛ سه flag، bulk، stale form، حساب استفاده‌شده و snapshot تاریخی پوشش داده شوند.
- مبلغ صفر/منفی/اعشاری/بسیار بزرگ، رقم فارسی/عربی، هزارگان نامعتبر، تاریخ ناقص/آینده/اسفند کبیسه و تغییر روز در ماه کوتاه آزموده شوند.
- فیش مستقل account label فقط، فیش مرتبط با owner معتبر، مقصد متفاوت و expected/paid متفاوت، چند فیش/پرداخت جزئی طبق سیاست مصوب.
- فایل spoof، خراب، خالی/بزرگ، decode و مجوز دانلود؛ rollback DB و پاک‌سازی فایل باقی‌مانده.
- idempotency ثبت، رأی تکراری، شرط دو شخص مشخص، رأی آخر هم‌زمان، approve/reject race، ارشد/رأی عادی هم‌زمان، stale revision و تغییر مجوز حین اقدام.
- قفل‌ها و unique constraints با PostgreSQL و `TransactionTestCase` یا pytest transactional و connectionهای جدا؛ SQLite اثبات رفتار lock PostgreSQL نیست.
- اصلاح نسخه رأی‌های قدیمی را معتبر نکند؛ رد و ارشد دلیل/actor/time ثبت کنند؛ سوابق قابل حذف عادی نباشند.
- صدور فقط یک outbox، رأی جزئی صفر SMS تأیید، نهایی شدن یک SMS، نبود template، unit/global override، tag غلط، provider failure/retry و کارکرد رویدادهای قدیمی.
- شمارنده شامل مستقل/جزئی، حذف تکرار join، فیلتر permission قبل از count، pagination پایدار و تعداد query محدود.
### مرورگر و تجربه کاربر
- ورود مشتری به خانه چهارسو و مسیر جدا تاریخچه؛ خانه بدون سوابق و سه خدمت در اندازه معمول ۳۲۰×۵۶۸، بدون overflow.
- صفحات مستقل شماره حساب‌ها/فیش، اطلاعات تکمیلی نمایان، کپی تکی/کامل و fallback Clipboard با اطلاعات مجاز کامل.
- فیش مرتبط اول، مستقل در دسترس، حفظ داده هنگام تغییر نوع، مقصد فقط نام، خطا و فوکوس مناسب.
- ورودی مبلغ: تایپ وسط متن، selection، Backspace/Delete کنار جداکننده، paste ارقام مختلف، مبلغ بزرگ و معادل دقیق تومان.
- تاریخ سه‌قسمتی، امروز به وقت ایران، تغییر ماه کبیسه، ارسال Gregorian و حفظ تاریخ در تغییر حالت؛ حالت نبود پشتیبانی Intl/JS مسیر قابل استفاده سرور داشته باشد.
- ثبت واقعی → صفحه پیگیری وضعیت؛ پیام موفقیت جعلی prototype حذف شود؛ reload و double click فیش تکراری نسازد.
- کارشناس صدور و مشاهده همه/محدود، فیش مستقل در میزکار، جست‌وجو/فیلتر/page، تأیید دو نفر و ارشد، timeline و badge.
- فونت/asset/SVG همگی از پروژه؛ intercept شبکه دارایی خارجی، PWA عدم cache مالی/فایل و نسخه‌های جدید را کنترل کند.
- desktop/mobile sidebar active state، shares badge و composer صفحات قدیمی رگرسیون نداشته باشند؛ keyboard، zoom، safe-area و متن بلند بررسی شوند.
تست‌های E2E جدید زیر `tests/e2e/` و Page Objectهای مالی زیر `tests/e2e/pages/`؛ fixture Chromium/BrowserContext function-scoped فعلی، marker e2e و live_server transactional. بدون sleep ثابت و assertions تنها روی نتیجه قابل مشاهده. provider پیامک mock شود؛ تست‌ها پیامک واقعی نفرستند. screenshot/trace شکست از fixture موجود استفاده کند.
فرمان‌های اجرای آینده از WSL و با venv مخزن:
```bash
.venv/Scripts/python.exe -m pytest tests -m "not e2e" -q
.venv/Scripts/python.exe -m pytest apps/phone_contacts_api/test_api.py -q
.venv/Scripts/python.exe -m pytest tests/e2e -m "e2e and not external_e2e" -q
node --test tests/js/chat-coach.test.mjs
```
تست‌های جدید قفل/هم‌زمانی نیز به suite افزوده و روی PostgreSQL اجرا شوند. تغییر frontend caching/PWA به‌طور خاص باید `tests/test_views.py` و `tests/e2e/test_pwa_versioning.py` را پاس کند. این فرمان‌ها در مرحله تحلیل اجرا نشده‌اند.
معیار تحویل: یک کارشناس اعلامیه اختصاصی صادر کند، پیامک از template DB صف شود، مشتری فقط مورد خودش را ببیند و فیش مستقل/مرتبط ثبت کند، اشخاص دقیقاً تعیین‌شده یا ارشد مجاز تأیید کنند، همه سوابق و نسخه‌ها ثبت و قابل بررسی باشند، SMS نهایی فقط بعد از پایان شرط ارسال شود، و جریان‌های فعلی تیکت/دسترسی/فایل/PWA سالم باقی بمانند.
+33
View File
@@ -0,0 +1,33 @@
# لینک موقت پرداخت
لینک `{payment_link}` در پیامک صدور اعلامیه و تأیید نهایی فیش، اکنون یک مجوز مستقل است؛ کاربر با آن وارد حساب نمی‌شود. مسیر ورودی `/chaharsu/link/<token>/` پس از بررسی، به آدرس بدون توکن منتقل می‌شود.
## محدوده و عمر دسترسی
- اعتبار پیش‌فرض ۷ روز است. `PAYMENT_LINK_TTL_DAYS` قابل تنظیم است و در نبود آن، از `MAGIC_LINK_TTL_DAYS` استفاده می‌شود.
- لینک اعلامیه فقط همان اعلامیه، فیش‌های آن و فیش‌های مستقلی را که از طریق همان لینک ثبت شده‌اند نمایش می‌دهد؛ دسترسی به سایر اعلامیه‌های مشتری یا حساب کامل او نمی‌دهد.
- مشاهده اطلاعات بانکی، کپی تک‌فیلد و کامل، ثبت فیش مرتبط و مستقل، مشاهده وضعیت، دریافت تصویر و اصلاح فیش ردشده بدون ورود ممکن است.
- فیلد «شناسه واریز که با آن پرداخت انجام داده‌اید» هنگام ارسال فیش اختیاری است؛ خالی‌بودن آن مانع ارسال نمی‌شود. هشدار ثبت شناسه در عملیات پرداخت بانکی همچنان نمایش داده می‌شود.
- انقضا، ابطال و غیرفعال‌شدن مشتری در هر درخواست دوباره بررسی می‌شود. CSRF و اعتبارسنجی تصویر، مبلغ و تاریخ همچنان برقرارند.
- تأیید و رد فقط برای کاربران دارای مجوز و تعیین‌شده در سیاست تأیید باقی می‌ماند. لینک پرداخت مجوز تأیید نمی‌دهد و لینک تیکت مجوز مالی نمی‌دهد.
- اگر حساب دیگری باز باشد، خروج تنها پس از تأیید صریح کاربر انجام می‌شود.
## پیامک و مدیریت
هنگام آماده‌سازی نخستین ارسال پیامک، لینک ساخته می‌شود تا تأخیر صف از زمان اعتبار کم نکند. تلاش‌های مجدد همان لینک و متن ثبت‌شده را استفاده می‌کنند. برای پیامک‌های آماده‌شده قدیمی که هنوز لینک موقت ندارند، فقط URL قبلی جایگزین می‌شود و متن سفارشی حفظ می‌شود؛ پیامکی که قبلاً به گوشی ارسال شده قابل تغییر نیست.
در جزئیات اعلامیه کارشناس، «ساخت لینک تازه پرداخت» لینک قابل کپی می‌سازد؛ این کار پیامک ارسال نمی‌کند. لینک‌های قبلی تا انقضا یا ابطال معتبر می‌مانند. در ادمین، مدل «لینک‌های موقت پرداخت» امکان بررسی زمان‌ها و ابطال لینک را دارد. ایجاد، بازکردن و ابطال در ممیزی مالی ثبت می‌شوند و توکن در ردیف ممیزی ثبت نمی‌شود.
مدل `SMSTemplate` تگ‌های مجاز را بر اساس رویداد تعیین و ذخیره می‌کند. رویدادهای مالی از `{payment_link}` و رویدادهای تیکت مشتری از `{magic_link}` پشتیبانی می‌کنند. صفحه مدیریت پیامک با تغییر رویداد، فهرست تگ‌های مناسب را نشان می‌دهد. مهاجرت داده فقط تگ‌ها را هماهنگ می‌کند و متن‌های سفارشی را تغییر نمی‌دهد.
شماره حساب، شبا و سایر اطلاعات از snapshot اعلامیه نمایش داده می‌شوند. شناسه واریز و تذکر آن قرمز است. امضای مسیر بدون ورود از `SiteBranding.developer_credit_text` خوانده می‌شود.
## تست دائمی
از WSL و در ریشه پروژه اجرا کنید:
```bash
.venv/Scripts/python.exe -m pytest tests/test_payment_links.py tests/e2e/test_payment_links_e2e.py -q
```
تست‌ها از دیتابیس جداگانه، سرور transactional و مرورگر مدیریت‌شده پروژه استفاده می‌کنند و پیامک واقعی ارسال نمی‌کنند. تست‌های مرورگر در عرض‌های ۳۲۰، ۳۹۰ و ۱۴۴۰ پیکسل اجرا می‌شوند. تست‌های سرور شامل لینک پیامک، تکرار ارسال، عدم ورود به حساب، محدوده مشتری، انقضا و ابطال، CSRF، ارسال فیش مرتبط و مستقل، اصلاح و کنترل تصویرند.
+121
View File
@@ -0,0 +1,121 @@
/* Chaharsu tokens follow design-prototypes/chaharsu/STYLE_GUIDE.md.
Page-scoped components: no global form or ticket composer overrides. */
.chaharsu{max-width:900px;margin:auto;color:#08376b;font-size:14px;line-height:1.7;overflow-wrap:anywhere}.chaharsu h1{font-size:24px;font-weight:900;line-height:1.5}.chaharsu h2{font-size:18px;font-weight:900}.chaharsu h3{font-size:14px;font-weight:800}.chaharsu p{margin-block:8px}.chaharsu-heading{margin-bottom:20px}.chaharsu-heading>a,.chaharsu-heading>span{color:#526c86;font-size:12px}.chaharsu-heading p{color:#526c86}.pay-card{background:#fff;border:1px solid #dce5ed;border-radius:20px;padding:16px;margin-block:12px}.pay-button,.pay-copy{display:inline-flex;align-items:center;justify-content:center;min-height:44px;padding:8px 16px;border:1px solid #08376b;border-radius:12px;background:#08376b;color:#fff;font:inherit;font-weight:800;text-decoration:none;cursor:pointer}.pay-button.secondary,.pay-copy{background:#fff;color:#08376b}.pay-actions,.service-actions{display:flex;gap:8px;flex-wrap:wrap;align-items:center}.pay-actions{margin-block:16px}.pay-button:focus-visible,.pay-copy:focus-visible,.chaharsu a:focus-visible,.chaharsu input:focus-visible,.chaharsu select:focus-visible,.chaharsu textarea:focus-visible,.payment-notice summary:focus-visible{outline:3px solid #1977ce;outline-offset:3px}.pay-button:disabled{opacity:.6;cursor:wait}.pay-form input:not([type=radio]):not([type=checkbox]),.pay-form select,.pay-form textarea,.pay-filters input,.pay-filters select,.pay-search input{width:100%;min-height:44px;border:1px solid #a1b4c7;border-radius:12px;padding:8px;font:inherit;font-size:16px;background:#fff;color:#08376b}.pay-form input[type=file]{padding:12px;border-style:dashed}.pay-form textarea{min-height:90px}.pay-field{margin-block:16px}.pay-field label{display:block;margin-bottom:6px;font-weight:700}.pay-field [type=radio],.pay-field [type=checkbox]{width:20px;height:20px;margin-inline:6px}.field-error,.pay-errors{color:#9f1f1f;font-size:14px;line-height:1.8}.pay-errors{border:1px solid #9f1f1f;background:#fff4f3;padding:12px;border-radius:12px;margin-block:12px}.pay-errors a{color:inherit;text-decoration:underline}.pay-form [aria-invalid=true]{border-color:#9f1f1f}.pay-today{font:inherit;color:#08376b;border:1px solid #dce5ed;border-radius:8px;min-height:44px;padding:4px 12px;background:#fff;margin-top:6px}.pay-supplement{background:#f6fbff;border-radius:12px;padding:12px;margin-block:12px}.payment-notice summary{cursor:pointer;min-height:44px;list-style-position:inside;display:flex;flex-wrap:wrap;gap:8px;align-items:center}.payment-notice summary strong{flex:1;min-width:120px}.payment-notice summary b{font-size:18px}.payment-notice summary small{width:100%;color:#526c86;font-size:12px}.payment-values>div{display:grid;grid-template-columns:1fr auto;gap:8px;padding-block:9px;border-bottom:1px solid #e7edf2}.payment-values dt{grid-column:1/-1;font-weight:700}.payment-values dd{font-size:16px;text-align:right;overflow-wrap:anywhere}.pay-search,.pay-filters{display:flex;gap:8px;flex-wrap:wrap}.pay-search input{flex:1;min-width:140px}.pay-filters>*{flex:1;min-width:130px}.pay-row{display:flex;flex-wrap:wrap;gap:12px;align-items:center;min-height:60px}.pay-row time{font-size:12px;color:#526c86}.pay-tabs,.pay-pagination{display:flex;gap:16px;margin-block:16px;align-items:center}.pay-tabs a,.pay-pagination a{min-height:44px;padding:8px}.receipt-preview{max-width:100%;max-height:600px;object-fit:contain;display:block;margin-block:12px}.service-grid{display:grid;grid-template-columns:1fr 1fr;gap:10px}.expert-service{grid-column:1/-1;border-radius:20px;background:#08376b;border-bottom:3px solid #fa9f00;padding:12px 14px;color:#fff}.expert-service p{color:#cadbea;font-size:14px;line-height:1.65}.expert-service h2{color:#fff}.service-actions>a{flex:1;padding-inline:8px;white-space:nowrap}.pay-button.light{color:#08376b;background:#fff;border-color:#fff}.pay-button.outline-light{background:transparent;border-color:#cadbea}.financial-service{display:block;padding:12px;background:#fff;border:1px solid #dce5ed;border-radius:20px;color:#08376b}.financial-service h2{font-size:14px;line-height:1.7;margin-block:6px}.financial-service p{font-size:14px;color:#526c86;line-height:1.65;margin:0}.financial-service svg{width:24px;height:24px;stroke:currentColor;stroke-width:1.7;fill:none;stroke-linecap:round;stroke-linejoin:round}.chaharsu-home .chaharsu-heading{margin-bottom:12px}.chaharsu-home h1{font-size:22px}.chaharsu-home .chaharsu-heading p{margin:0}.pay-copy-status{position:fixed;bottom:100px;right:16px;background:#08376b;color:#fff;padding:12px;border-radius:12px;z-index:100}.pay-manual-copy{max-width:520px;width:calc(100% - 24px);border:1px solid #dce5ed;border-radius:20px;padding:20px;color:#08376b}.pay-manual-copy textarea{width:100%;min-height:200px;font:inherit}.pay-manual-copy::backdrop{background:#0009}.pay-form [data-field=paid_day],.pay-form [data-field=paid_month],.pay-form [data-field=paid_year]{display:inline-block;width:calc(33.33% - 8px);vertical-align:top}.pay-form [data-field=paid_day] .field-error{miLine truncated
@media(min-width:640px){.chaharsu h1{font-size:28px}.chaharsu h2{font-size:20px}.pay-card{padding:22px}.payment-values>div{grid-template-columns:130px 1fr auto;align-items:center}.payment-values dt{grid-column:auto}.chaharsu-home{max-width:1120px}.financial-service{padding:24px}.financial-service h2{font-size:17px}.expert-service{padding:20px}.service-grid{gap:16px}.pay-form{max-width:700px;margin-inline:auto}}
@media(min-width:900px){.service-grid{grid-template-columns:repeat(4,1fr)}.expert-service{grid-column:span 2}}
@media(prefers-reduced-motion:reduce){.chaharsu *{scroll-behavior:auto}}
.chaharsu-dock{grid-template-columns:repeat(3,minmax(0,1fr))}.chaharsu-dock a{font-size:12px;gap:4px}.chaharsu-dock svg{flex-shrink:0}
/* Prototype-aligned customer shell. Loaded only by payment templates. */
.cs-icon{width:22px;height:22px;flex-shrink:0}
.cs-header{position:sticky;top:0;z-index:40;background:#eeeeeeed;border-bottom:1px solid #dce5ed;backdrop-filter:blur(14px)}
.cs-header-inner{max-width:1120px;margin:auto;min-height:76px;padding:0 24px;display:flex;align-items:center;justify-content:space-between;gap:20px}
.cs-brand{display:flex;align-items:center;gap:14px;color:#08376b;flex-shrink:0}
.cs-brand img{width:85px;height:auto;object-fit:contain}.cs-brand-divider{height:32px;width:1px;background:#cdd7e0}
.cs-brand b{display:block;font-size:20px;font-weight:900;line-height:1.4}.cs-brand small{display:block;margin-top:3px;font-size:11px;color:#526c86}
.cs-nav,.cs-header-actions{display:flex;align-items:center;gap:12px}.cs-nav a{min-height:44px;padding:10px 16px;border-radius:12px;color:#526c86;font-size:14px;font-weight:700}.cs-nav a[aria-current]{background:white;color:#08376b}
.cs-profile{display:flex;align-items:center;gap:8px;min-height:44px;padding:8px 12px;background:#fff;border:1px solid #dce5ed;border-radius:14px;color:#08376b;font-size:14px;font-weight:700}
.cs-header-actions form{margin:0}.cs-logout{min-height:44px;padding:8px;color:#526c86;background:transparent;border:0;box-shadow:none;font-size:14px}.cs-logout:hover{background:#fff;color:#08376b;transform:none;box-shadow:none}
.cs-header a:focus-visible,.cs-header button:focus-visible{outline:3px solid #1977ce;outline-offset:3px}
.customer-main:has(.chaharsu){max-width:1120px;padding:30px 24px 16px}.customer-main:has(.chaharsu-home){padding-top:28px}
.cs-footer{max-width:1120px;margin:12px auto 0;padding:0 24px 24px;text-align:center;font-size:12px;line-height:1.9;color:#526c86}
.chaharsu-home .chaharsu-heading{margin-bottom:20px}.chaharsu-home h1{font-size:28px}.chaharsu-home .chaharsu-heading p{margin-top:4px;font-size:14px}
.chaharsu-home .service-grid{align-items:stretch}.expert-service{display:flex;flex-direction:column;min-width:0;padding:24px 20px 20px;border-radius:22px}
.expert-service-head{display:grid;grid-template-columns:34px minmax(0,1fr);column-gap:12px;align-items:center}.expert-service-head h2{margin:0;font-size:20px}.expert-service-head p{grid-column:2;margin:6px 0 0;line-height:1.7}
.service-icon{display:grid;place-items:center;width:48px;height:48px;border-radius:14px;background:#e5f4ff;color:#1977ce}.expert-service-head .service-icon{width:34px;height:34px;background:#1977ce;color:#fff}
.service-actions{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:10px;margin-top:auto;padding-top:24px}
.service-actions .pay-button{gap:8px;font-size:14px;font-weight:800}.service-actions .pay-button.light{background:#1977ce;color:#fff;border-color:#1977ce}.service-actions .outline-light{background:#ffffff08;border-color:#ffffff40;color:white}
.service-actions .light:hover{background:#1566b2;border-color:#1566b2}.service-actions .outline-light:hover{background:#ffffff15;border-color:#ffffff70}
.financial-service{display:flex;flex-direction:column;min-width:0;padding:24px;border-radius:22px;transition:border-color .18s,box-shadow .18s,transform .18s}
.financial-service h2{margin:20px 0 6px;font-size:17px;font-weight:900}.financial-service p{line-height:1.8}.financial-service .cs-icon{width:23px;height:23px}
.service-link{display:flex;justify-content:space-between;align-items:center;gap:8px;margin-top:auto;padding-top:20px;color:#08376b;font-size:14px;font-weight:700}.service-link .cs-icon{width:18px;height:18px}
.financial-service:hover{border-color:#91bfdd;box-shadow:0 15px 35px -25px #08376b70;transform:translateY(-3px)}
.chaharsu .pay-button{gap:8px;transition:background .15s,border-color .15s}.chaharsu .pay-button:hover{background:#0b4c8c;color:#fff;box-shadow:none;transform:none}.chaharsu .pay-button.secondary:hover{background:#e5f4ff;color:#08376b}
.chaharsu .pay-button:disabled{cursor:not-allowed;opacity:.5}.chaharsu .pay-button:disabled:hover{background:#08376b}
.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-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}
.pay-head-actions{display:flex;gap:8px;flex-wrap:wrap}.pay-head-actions .pay-button{white-space:nowrap}
.pay-desk-tabs{display:flex;gap:4px;padding:5px;margin:0 0 16px;background:#e3e9ef;border:1px solid #d5dfe8;border-radius:16px;width:fit-content;max-width:100%}
.pay-desk-tabs a{display:flex;align-items:center;justify-content:center;padding:10px 18px;font-size:14px;font-weight:700;border-radius:12px;color:#526c86;text-align:center}.pay-desk-tabs a[aria-current]{background:#fff;color:#08376b;box-shadow:0 2px 6px #08376b08}.pay-desk-tabs a:hover{color:#08376b;background:#ffffff80}
.pay-filter-panel{margin:0 0 20px;padding:20px}.pay-filter-grid{display:grid;grid-template-columns:minmax(220px,2fr) repeat(2,minmax(140px,1fr)) auto;align-items:end;gap:12px}
.pay-bank-filters{grid-template-columns:minmax(220px,2fr) repeat(3,minmax(115px,1fr)) auto}
.pay-filter-grid label,.pay-extra-filters label,.pay-bulk-bar label{display:flex;flex-direction:column;gap:6px;font-size:14px;font-weight:700;min-width:0}
.pay-workspace input:not([type=checkbox]):not([type=hidden]),.pay-workspace select{width:100%;min-width:0;min-height:44px;border:1px solid #c6d3df;border-radius:12px;padding:9px 12px;background:#fff;color:#08376b;font:inherit;font-size:14px;box-shadow:none}
.pay-workspace input::placeholder{color:#526c86;opacity:1}.pay-workspace input:focus,.pay-workspace select:focus{border-color:#1977ce;box-shadow:0 0 0 3px #1977ce12}
.pay-search-input{display:flex;align-items:center;gap:8px;background:#f6f8fa;border:1px solid #c6d3df;border-radius:12px;padding-inline:12px;min-height:44px}.pay-search-input .cs-icon{color:#526c86;width:20px;height:20px}.pay-workspace .pay-search-input input[type=search]{border:0;background:transparent;padding-inline:0}.pay-workspace .pay-search-input input[type=search]:focus{box-shadow:none}.pay-search-input:focus-within{outline:3px solid #1977ce;outline-offset:2px}
.pay-filter-bottom{display:flex;justify-content:space-between;align-items:flex-start;gap:16px;margin-top:12px}.pay-more-filters{flex:1;min-width:0}.pay-more-filters summary{display:flex;align-items:center;gap:6px;min-height:44px;width:fit-content;cursor:pointer;font-weight:700;color:#08376b;list-style:none}.pay-more-filters summary::-webkit-details-marker{display:none}.pay-more-filters summary::after{content:'⌄';margin-inline-start:4px;transition:transform .15s}.pay-more-filters[open] summary::after{transform:rotate(180deg)}.pay-more-filters summary:focus-visible{outline:3px solid #1977ce;outline-offset:3px}
.pay-extra-filters{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:12px;padding-top:8px}.pay-extra-filters label small{display:contents;font-size:12px;font-weight:400}.pay-extra-filters input[type=date]{direction:ltr;text-align:right}.pay-reset{display:flex;align-items:center;min-height:44px;white-space:nowrap;color:#526c86;text-decoration:underline;text-underline-offset:4px}
.pay-records,.pay-bank-panel{border:1px solid #dce5ed;border-radius:20px;background:#fff;overflow:hidden}.pay-list-head{display:flex;align-items:center;justify-content:space-between;gap:16px;min-height:64px;padding:16px 20px;border-bottom:1px solid #e7edf2}.pay-list-head h2{font-size:16px;margin:0;font-weight:800}.pay-list-head>span{font-size:12px;color:#526c86;background:#f0f4f7;border-radius:20px;padding:4px 10px;white-space:nowrap}
.pay-record{display:grid;grid-template-columns:44px minmax(0,1fr) minmax(155px,auto) minmax(165px,auto);align-items:center;gap:16px;padding:20px;border-bottom:1px solid #e7edf2}.pay-record:last-child{border-bottom:0}.pay-record:hover{background:#fbfdff}
.pay-record-icon{display:grid;place-items:center;width:44px;height:44px;flex-shrink:0;border-radius:14px;background:#f0f5f9;color:#1977ce}.pay-record-main{display:flex;flex-direction:column;gap:4px;min-width:0}.pay-record-main h3{font-size:16px;margin:0;font-weight:800}.pay-record-main h3 a:hover{text-decoration:underline;text-underline-offset:4px}.pay-record-meta{font-size:12px;color:#526c86}.pay-record-customer{display:flex;align-items:center;gap:6px;font-size:14px;color:#526c86}.pay-record-customer .cs-icon{width:16px;height:16px}
.pay-record-amount{display:flex;flex-direction:column;gap:6px}.pay-record-amount strong{font-size:18px;line-height:1.7;font-weight:800;white-space:nowrap}.pay-record-amount small{font-size:12px;font-weight:400}.pay-record-amount time{font-size:12px;color:#526c86}.pay-record-side{display:flex;flex-direction:column;align-items:flex-end;gap:8px}
.pay-status{display:inline-flex;align-items:center;gap:6px;min-height:28px;max-width:100%;padding:4px 10px;border-radius:20px;font-size:12px;font-weight:700;background:#f0f3f6;color:#526c86;white-space:nowrap}.pay-status::before{content:'';width:5px;height:5px;flex-shrink:0;border-radius:50%;background:currentColor}.pay-status.pending{background:#fff3da;color:#805300}.pay-status.partial,.pay-status.info{background:#e5f4ff;color:#125587}.pay-status.approved{background:#eaf5ef;color:#216345}.pay-status.rejected{background:#fff0ee;color:#9f1f1f}
.pay-open{display:flex;align-items:center;justify-content:center;gap:8px;min-height:44px;color:#08376b;font-size:14px;font-weight:700}.pay-open .cs-icon{width:18px;height:18px}.pay-open:hover{color:#1977ce;text-decoration:underline;text-underline-offset:4px}
.pay-empty{text-align:center;padding:40px 20px;color:#526c86}.pay-empty-icon{display:grid;place-items:center;width:56px;height:56px;border-radius:18px;background:#f0f5f9;color:#526c86;margin:0 auto 12px}.pay-empty-icon .cs-icon{width:26px;height:26px}.pay-empty h3{font-size:18px;color:#08376b;margin:0}.pay-empty p{margin:6px 0 0}
.pay-bank-table-head,.pay-bank-row{display:grid;grid-template-columns:44px minmax(190px,1fr) repeat(3,100px) 90px;align-items:center;gap:12px}.pay-bank-table-head{padding:12px 20px;background:#f6f8fa;color:#526c86;font-size:12px;border-bottom:1px solid #e7edf2}.pay-select-all{display:flex;align-items:center;gap:8px}.pay-select-all span{display:none}.pay-bank-row{padding:20px;border-bottom:1px solid #e7edf2}.pay-bank-select{display:flex;align-items:center;min-height:44px;cursor:pointer}.pay-bank-panel input[type=checkbox]{width:20px;height:20px;accent-color:#08376b;cursor:pointer;margin:0;flex-shrink:0}
.pay-bank-identity{display:flex;gap:12px;align-items:center;min-width:0}.pay-bank-identity h3{font-size:16px;margin:0}.pay-bank-identity p{font-size:14px;color:#526c86;margin:3px 0}.pay-identifier-badge{display:inline-block;font-size:12px;color:#805300;background:#fff3da;border:1px solid #f3ddae;border-radius:8px;padding:2px 7px;margin-top:4px}
.pay-bank-edit{display:flex;align-items:center;justify-content:center;gap:6px;min-height:44px;border:1px solid #dce5ed;border-radius:12px;font-size:14px;font-weight:700;color:#08376b}.pay-bank-edit:hover{background:#e5f4ff;border-color:#91bfdd}.pay-bank-edit .cs-icon{width:17px;height:17px}.pay-mobile-label{display:none}
.pay-bulk-bar{display:grid;grid-template-columns:minmax(180px,1fr) minmax(155px,auto) minmax(100px,auto) auto;align-items:end;gap:12px;padding:20px;background:#f6f8fa}.pay-bulk-label{display:flex;flex-direction:column;gap:6px;align-self:center}.pay-bulk-label strong{font-size:14px}.pay-bulk-label span{font-size:12px;color:#526c86}.pay-bank-hint{padding:0 20px 14px;margin:0!important;background:#f6f8fa;color:#526c86;font-size:12px}
.pay-pagination{justify-content:center;flex-wrap:wrap;margin-top:20px}.pay-pagination a{display:flex;align-items:center;justify-content:center;gap:8px;min-width:90px;min-height:44px;background:#fff;border:1px solid #dce5ed;border-radius:12px;font-weight:700}.pay-pagination span{font-size:14px;color:#526c86}
.pay-staff-dock{grid-template-columns:repeat(2,minmax(0,1fr));gap:4px}.pay-staff-dock.has-banks{grid-template-columns:repeat(3,minmax(0,1fr))}.pay-staff-dock a{gap:5px;min-height:54px;font-size:12px;color:#cadbea}.pay-staff-dock a.active{background:#0b4c8c}.pay-staff-dock .cs-icon{width:20px;height:20px}
@media(min-width:900px){.chaharsu-home .service-grid>*{min-height:232px}}
@media(max-width:1199px){.pay-bank-filters{grid-template-columns:repeat(3,minmax(0,1fr))}.pay-bank-filters .pay-search-field{grid-column:span 2}.pay-bank-filters button{width:100%}.pay-page-head{gap:16px}.pay-bank-table-head,.pay-bank-row{grid-template-columns:32px minmax(160px,1fr) repeat(3,85px) 80px;gap:8px}.pay-bulk-bar{grid-template-columns:repeat(3,minmax(0,1fr))}.pay-bulk-label{grid-column:1/-1}}
@media(max-width:899px){.pay-filter-grid{grid-template-columns:repeat(2,minmax(0,1fr))}.pay-search-field{grid-column:1/-1}.pay-bank-filters .pay-search-field{grid-column:1/-1}.pay-record{grid-template-columns:44px minmax(0,1fr) auto}.pay-record-side{grid-column:2/-1;flex-direction:row;justify-content:space-between;align-items:center}.pay-head-actions{width:100%}.pay-page-title h1{font-size:22px}.cs-nav{gap:4px}.cs-nav a{padding-inline:10px}.cs-header-inner{gap:12px}.cs-profile span{display:none}}
@media(max-width:767px){.pay-bank-table-head{display:flex;padding:10px 16px}.pay-bank-table-head>span{display:none}.pay-select-all span{display:inline}.pay-bank-row{grid-template-columns:24px repeat(3,minmax(0,1fr));gap:12px;padding:16px}.pay-bank-select{grid-column:1;grid-row:1;align-self:start}.pay-bank-identity{grid-column:2/-1}.pay-bank-flag{display:flex;flex-direction:column;gap:6px;align-items:flex-start}.pay-bank-flag:nth-of-type(2){grid-column:2}.pay-mobile-label{display:block;font-size:12px;color:#526c86}.pay-bank-edit{grid-column:2/-1;justify-self:end;padding-inline:14px}.pay-bank-identity .pay-record-icon{display:none}.pay-bulk-bar{grid-template-columns:repeat(2,minmax(0,1fr));padding:16px}.pay-bulk-bar button{grid-column:1/-1}.pay-bank-hint{padding-inline:16px}}
@media(max-width:639px){
.cs-header-inner{min-height:64px;padding-inline:16px;gap:8px}.cs-brand{gap:10px}.cs-brand img{width:64px}.cs-brand-divider{height:28px}.cs-brand b{font-size:18px}.cs-brand small{font-size:11px}.cs-nav{display:none}.cs-profile{padding:0;width:40px;min-height:40px;border-radius:12px}.cs-profile .cs-icon{margin:auto;width:20px;height:20px}.cs-header-actions{gap:4px}.cs-logout{font-size:12px;padding-inline:6px}
.customer-main:has(.chaharsu){padding:18px 12px 12px}.customer-main:has(.chaharsu-home){padding:12px 16px 8px}.chaharsu-home .chaharsu-heading{margin-bottom:12px}.chaharsu-home h1{font-size:22px}.chaharsu-home .chaharsu-heading p{display:none}
.expert-service{padding:12px 14px;border-radius:20px}.expert-service-head{grid-template-columns:minmax(0,1fr) 30px;gap:0 8px}.expert-service-head h2{grid-column:1;grid-row:1;font-size:18px}.expert-service-head .service-icon{grid-column:2;grid-row:1;width:30px;height:30px;border-radius:10px}.expert-service-head .cs-icon{width:18px;height:18px}.expert-service-head p{grid-column:1/-1;margin-top:6px;line-height:1.65}.service-actions{padding-top:10px;gap:8px}.service-actions .pay-button{padding-inline:6px;gap:5px}.service-actions .cs-icon{width:18px;height:18px}
.financial-service{padding:11px 12px;border-radius:20px}.financial-service .service-icon{width:30px;height:30px;border-radius:10px}.financial-service .cs-icon{width:20px;height:20px}.financial-service h2{font-size:14px;margin:7px 0 4px}.financial-service p{font-size:14px;line-height:1.65}.service-link{display:none}.cs-footer{padding:0 16px calc(96px + env(safe-area-inset-bottom));margin-top:16px}
.pay-page-head{margin-bottom:16px;gap:12px}.pay-page-title{gap:10px;align-items:flex-start}.pay-page-icon{width:40px;height:40px;border-radius:12px}.pay-page-icon .cs-icon{width:22px;height:22px}.pay-page-title h1{font-size:20px}.pay-page-title p{line-height:1.7;margin-top:4px}.pay-head-actions .pay-button{flex:1;padding-inline:10px}.pay-page-head>.pay-button{width:100%}
.pay-desk-tabs{width:100%;gap:2px}.pay-desk-tabs a{flex:1;min-width:0;padding:8px 6px;line-height:1.6;font-size:14px}.pay-filter-panel{padding:14px;margin-bottom:16px}.pay-filter-grid{gap:10px}.pay-filter-grid button{width:100%;align-self:end}.pay-extra-filters{grid-template-columns:1fr}.pay-filter-bottom{gap:8px;flex-wrap:wrap}.pay-reset{font-size:12px}.pay-list-head{padding:14px 16px}.pay-list-head h2{font-size:14px}
.pay-record{grid-template-columns:minmax(0,1fr);gap:10px;padding:16px}.pay-record>.pay-record-icon{display:none}.pay-record-main h3{font-size:14px}.pay-record-amount{flex-direction:row;align-items:center;justify-content:space-between;flex-wrap:wrap;gap:6px}.pay-record-amount strong{font-size:16px}.pay-record-side{grid-column:auto;gap:8px;flex-wrap:wrap;border-top:1px solid #e7edf2;padding-top:8px}.pay-record-meta{font-size:12px}.pay-empty{padding:30px 16px}.pay-pagination{gap:8px}
}
@media(max-width:359px){.cs-header-inner{padding-inline:12px}.cs-brand img{width:54px}.cs-brand{gap:8px}.cs-brand small{font-size:10px}.customer-main:has(.chaharsu-home){padding-inline:12px}.service-actions .cs-icon{display:none}.pay-bank-row{gap:10px}.pay-bank-flag .pay-status{font-size:12px;padding-inline:7px}}
@media(max-width:639px) and (max-height:650px){.customer-main:has(.chaharsu-home)+.cs-footer{margin-top:96px}}
@media(prefers-reduced-motion:reduce){.financial-service,.chaharsu .pay-button{transition:none}.financial-service:hover{transform:none}}
/* Every staff payment form/detail uses the same local component family. */
.pay-form-workspace{max-width:1000px}.pay-detail-workspace{max-width:1200px}
.pay-back{display:inline-flex;align-items:center;gap:6px;min-height:44px;margin-bottom:8px;font-size:14px;font-weight:700;color:#526c86}.pay-back .cs-icon{width:18px;height:18px}.pay-back:hover{color:#08376b}
.pay-staff-form{max-width:none!important;margin:0;padding:24px}.pay-form-section{margin:0;padding:0 0 24px;border:0;min-width:0}.pay-form-section+.pay-form-section{border-top:1px solid #e7edf2;padding-top:24px}.pay-form-section legend{float:right;width:100%;font-size:18px;font-weight:800;color:#08376b;margin-bottom:12px}.pay-form-section::after{content:'';display:block;clear:both}
.pay-section-help,.pay-field-hint{font-size:14px;font-weight:400;color:#526c86;line-height:1.8}.pay-section-help{clear:both;margin:0 0 12px!important}.pay-field-hint{margin:6px 0 0!important;font-size:12px}.pay-form-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:20px 24px;clear:both}.pay-form-grid .pay-field{margin:0;min-width:0}.pay-field-wide{grid-column:1/-1}.pay-staff-form .pay-field label{display:flex;align-items:center;flex-wrap:wrap;gap:8px;font-size:14px;margin-bottom:8px}.pay-required{font-size:12px;font-weight:400;color:#526c86}.pay-optional{font-size:12px;font-weight:400;color:#526c86}
.pay-staff-form input:not([type=checkbox]):not([type=radio]):not([type=hidden]),.pay-staff-form select,.pay-staff-form textarea{min-height:48px;padding:10px 12px;border:1px solid #b8c9d8;border-radius:12px;font-size:16px;box-shadow:none}.pay-staff-form textarea{min-height:120px;resize:vertical}.pay-staff-form input[id=id_iban],.pay-staff-form input[id=id_card_number],.pay-staff-form input[id=id_account_number],.pay-staff-form input[id=id_amount],.pay-staff-form input[id=id_deposit_id]{direction:ltr;text-align:right}.pay-staff-form .has-error input,.pay-staff-form .has-error select,.pay-staff-form .has-error textarea{border-color:#9f1f1f}.chaharsu .errorlist{list-style:none;padding:0;margin:6px 0;color:#9f1f1f;font-size:14px;line-height:1.8}.pay-staff-form .pay-field .errorlist{margin:0}.pay-inline-note{clear:both;background:#f6fbff;border:1px solid #cde6f7;border-radius:12px;padding:12px 14px;color:#526c86;font-size:14px;line-height:1.8;margin:16px 0 0!important}
.pay-check-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:12px;clear:both}.pay-check-card{display:flex;align-items:flex-start;gap:12px;padding:16px;border:1px solid #dce5ed;border-radius:14px;background:#fafcfe;cursor:pointer;min-width:0}.pay-check-card:has(input:checked){border-color:#91bfdd;background:#f0f8fd}.pay-check-card input[type=checkbox]{width:20px;height:20px;margin:3px 0 0;flex-shrink:0;accent-color:#08376b}.pay-check-card strong{display:block;font-size:14px;font-weight:800}.pay-check-card small{display:block;font-size:12px;line-height:1.8;font-weight:400;color:#526c86;margin-top:5px}.pay-sort-field{max-width:300px;margin-top:20px}.pay-sort-field .pay-field{margin:0}
.pay-form-footer{display:flex;align-items:center;gap:12px;flex-wrap:wrap;border-top:1px solid #e7edf2;padding-top:20px;margin-top:4px}.pay-form-footer .pay-button{min-width:120px}.pay-delete-panel{border:1px solid #ecd4d2;background:#fff;border-radius:16px;margin-top:20px;color:#9f1f1f}.pay-delete-panel summary{padding:14px 20px;cursor:pointer;min-height:48px;font-size:14px;font-weight:700}.pay-delete-panel form{padding:0 20px 20px;color:#526c86;font-size:14px}.chaharsu .pay-danger,.chaharsu .pay-danger.secondary{background:#fff4f3;border-color:#d9a4a0;color:#9f1f1f}.chaharsu .pay-danger:hover{background:#9f1f1f;border-color:#9f1f1f;color:#fff}
.pay-receipt-grid{display:grid;grid-template-columns:repeat(6,minmax(0,1fr));gap:18px 24px;margin-top:20px}.pay-staff-form .pay-receipt-grid>.pay-field{grid-column:span 3;margin:0;min-width:0;display:block;width:auto}.pay-staff-form .pay-receipt-grid>[data-field=receipt_mode],.pay-staff-form .pay-receipt-grid>[data-field=notice_id],.pay-staff-form .pay-receipt-grid>[data-field=description],.pay-staff-form .pay-receipt-grid>[data-field=receipt_image]{grid-column:1/-1}.pay-staff-form .pay-receipt-grid>[data-field=paid_day],.pay-staff-form .pay-receipt-grid>[data-field=paid_month],.pay-staff-form .pay-receipt-grid>[data-field=paid_year]{grid-column:span 2}.pay-date-heading{grid-column:1/-1;display:flex;gap:8px;flex-wrap:wrap;margin-top:8px!important;border-top:1px solid #e7edf2;padding-top:20px}.pay-staff-form #id_receipt_mode{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:12px}.pay-staff-form #id_receipt_mode label{min-height:52px;padding:12px;margin:0;border:1px solid #dce5ed;border-radius:12px;cursor:pointer;background:#fafcfe;flex-wrap:nowrap;align-items:flex-start}.pay-staff-form #id_receipt_mode label:has(input:checked){background:#e5f4ff;border-color:#91bfdd}.pay-staff-form #id_receipt_mode input{margin:2px 0 0;flex-shrink:0;accent-color:#08376b}.pay-staff-form input[type=file]{padding:18px!important;background:#f6fbff;border:1px dashed #91bfdd!important}.pay-staff-form #amount-words{font-size:14px;color:#526c86}.pay-staff-form .pay-today{font-size:12px;min-height:36px;padding:4px 10px;margin-top:8px}.pay-staff-form #date-preview{font-size:12px;color:#526c86}
.pay-detail-grid{display:grid;grid-template-columns:minmax(0,1.6fr) minmax(300px,1fr);gap:24px;align-items:start}.pay-detail-grid>div,.pay-detail-aside{min-width:0}.pay-detail-grid .pay-card{margin:0 0 20px}.pay-detail-grid .pay-card h2{font-size:18px;margin-bottom:12px}.pay-detail-grid .pay-form{max-width:none}.pay-notice-main .payment-notice summary{padding-bottom:16px;border-bottom:1px solid #e7edf2;margin-bottom:16px}.pay-notice-main .payment-values>div{grid-template-columns:125px minmax(0,1fr) auto}.pay-notice-main .payment-values dd{font-size:16px}.pay-notice-main .pay-copy{padding-inline:12px;min-height:40px;font-size:14px}.pay-linked-record{display:flex;align-items:center;gap:8px;flex-wrap:wrap;border-top:1px solid #e7edf2;padding:12px 0;min-height:48px;font-size:14px;font-weight:700}.pay-linked-record>.cs-icon{margin-inline-start:auto;width:18px;height:18px}.pay-notification-row{display:flex;flex-direction:column;gap:4px;padding:12px 0;border-top:1px solid #e7edf2}.pay-notification-row span{font-size:12px;color:#526c86}
.pay-warning{background:#fff3da;border:1px solid #f3ddae;color:#805300;padding:14px 16px;border-radius:14px;font-size:14px;line-height:1.8;margin:0 0 16px!important}.pay-receipt-total{background:#f6fbff;border:1px solid #cde6f7;border-radius:14px;padding:16px;margin-bottom:16px}.pay-receipt-total>span{font-size:12px;color:#526c86}.pay-receipt-total h2{font-size:26px!important;margin:4px 0!important;line-height:1.6}.pay-receipt-total h2 small{font-size:14px;font-weight:400}.pay-receipt-total p{font-size:14px;color:#526c86;margin:0}.pay-facts>div{display:grid;grid-template-columns:150px minmax(0,1fr);gap:12px;padding:10px 0;border-bottom:1px solid #e7edf2}.pay-facts dt{color:#526c86;font-size:14px}.pay-facts dd{font-size:14px;font-weight:700;min-width:0;overflow-wrap:anywhere}.pay-facts dd[dir=ltr]{text-align:right}.pay-section-title{font-size:14px!important;margin:20px 0 8px;font-weight:800}.pay-card-title{display:flex;align-items:center;justify-content:space-between;gap:12px;flex-wrap:wrap;margin-bottom:16px}.pay-card-title h2{margin:0!important}.pay-image-card .receipt-preview{margin:0 auto 12px;max-height:650px;width:auto;border-radius:12px;border:1px solid #e7edf2;background:#f6f8fa}.pay-decision-form{border-top:3px solid #1977ce}.pay-decision-form label{font-size:14px}.pay-decision-form textarea{margin-top:8px;min-height:110px}.pay-decision-actions{display:flex;flex-direction:column;gap:10px;margin-top:16px}.pay-decision-actions .pay-button{width:100%}.pay-timeline-item{position:relative;padding:0 16px 20px 0;border-inline-start:2px solid #dce5ed;display:flex;flex-direction:column;gap:4px}.pay-timeline-item:before{content:'';position:absolute;inset-inline-start:-5px;top:5px;background:#1977ce;width:8px;height:8px;border-radius:50%}.pay-timeline-item strong{font-size:14px}.pay-timeline-item span,.pay-timeline-item time{font-size:12px;color:#526c86}.pay-timeline-item p{font-size:14px;margin:4px 0}.pay-version-row{display:flex;align-items:center;justify-content:space-between;gap:12px;flex-wrap:wrap;padding:10px 0;border-bottom:1px solid #e7edf2}.pay-version-row a{font-size:14px;font-weight:700}.pay-version-row time{font-size:12px;color:#526c86}.pay-reviewer{display:flex;align-items:center;gap:8px;flex-wrap:wrap;font-size:14px}.pay-reviewer>.cs-icon{width:18px;height:18px;color:#526c86}
@media(max-width:899px){.pay-detail-grid{grid-template-columns:minmax(0,1fr);gap:0}.pay-detail-grid .pay-card{margin-bottom:16px}}
@media(max-width:639px){.pay-staff-form{padding:16px}.pay-form-grid{grid-template-columns:minmax(0,1fr);gap:18px}.pay-form-section{padding-bottom:20px}.pay-form-section+.pay-form-section{padding-top:20px}.pay-form-section legend{font-size:16px}.pay-check-grid{grid-template-columns:minmax(0,1fr);gap:10px}.pay-check-card{padding:14px}.pay-sort-field{max-width:none}.pay-form-footer{gap:8px}.pay-form-footer .pay-button{flex:1;min-width:0}.pay-receipt-grid{gap:16px 8px}.pay-staff-form .pay-receipt-grid>.pay-field{grid-column:1/-1}.pay-staff-form .pay-receipt-grid>[data-field=paid_day],.pay-staff-form .pay-receipt-grid>[data-field=paid_month],.pay-staff-form .pay-receipt-grid>[data-field=paid_year]{grid-column:span 2}.pay-staff-form .pay-receipt-grid select{padding-inline:6px}.pay-staff-form #id_receipt_mode{grid-template-columns:minmax(0,1fr);gap:8px}.pay-staff-form [data-field=paid_day] .field-error{min-width:0}.pay-facts>div{grid-template-columns:1fr;gap:4px}.pay-receipt-total h2{font-size:24px!important}.pay-notice-main .payment-values>div{grid-template-columns:minmax(0,1fr) auto}.pay-notice-main .payment-values dt{grid-column:1/-1}.pay-detail-grid .pay-card h2{font-size:16px}.pay-delete-panel summary{padding-inline:16px}.pay-delete-panel form{padding-inline:16px}.pay-card-title .pay-button{min-height:40px;padding-inline:12px}}
/* Native radios must not inherit the workspace text-input width and height. */
.pay-staff-form #id_receipt_mode input[type=radio]{width:20px;height:20px;min-height:20px;padding:0;border:0;border-radius:50%;background:transparent;box-shadow:none;flex:0 0 20px}
.pay-staff-form textarea{height:128px;min-height:128px}.pay-date-status{grid-column:1/-1;margin:-6px 0 0!important}
.pay-customer-picker{min-width:0}.pay-customer-picker>label{display:flex;align-items:center;gap:8px;font-weight:700;font-size:14px;margin-bottom:8px}.pay-workspace .pay-customer-picker .pay-search-input input[type=search]{border:0;background:transparent;padding-inline:0}.pay-customer-results{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:12px;margin-top:16px;max-height:520px;overflow-y:auto;padding:4px}.pay-customer-result{padding:16px;border:1px solid #dce5ed;border-radius:14px;background:#fafcfe;min-width:0;display:flex;flex-direction:column;gap:10px}.pay-customer-identity{display:flex;flex-direction:column;gap:4px;min-width:0}.pay-customer-identity strong{font-size:16px;font-weight:800}.pay-customer-identity bdi{font-size:16px;font-weight:700;text-align:right}.pay-customer-identity span{font-size:14px}.pay-customer-identity small{font-size:12px;color:#526c86}.pay-customer-result>.pay-button{margin-top:auto}.pay-customer-history{border-top:1px solid #dce5ed;padding-top:6px}.pay-customer-history summary{font-size:14px;cursor:pointer;min-height:40px;display:list-item;list-style-position:inside}.pay-customer-history div{display:flex;flex-direction:column;gap:3px;padding:8px 0;border-top:1px solid #e7edf2}.pay-customer-history a{font-size:14px;color:#08376b;text-decoration:underline;text-underline-offset:3px}.pay-customer-history small,.pay-customer-history p{font-size:12px;color:#526c86}.pay-selected-customer{margin-top:16px;padding:16px 20px;border:1px solid #91bfdd;border-radius:16px;background:#f0f8fd;display:grid;grid-template-columns:minmax(0,1fr) auto;gap:8px 16px}.pay-selected-label{font-size:12px;color:#526c86;grid-column:1/-1}.pay-selected-customer .pay-customer-identity{grid-column:1;grid-row:2}.pay-selected-customer>.pay-button{grid-column:2;grid-row:2;align-self:start}.pay-selected-customer .pay-customer-history{grid-column:1/-1}.pay-customer-history summary:focus-visible{outline:3px solid #1977ce;outline-offset:2px}
@media(max-width:639px){.pay-customer-results{grid-template-columns:1fr;max-height:440px}.pay-selected-customer{padding:14px;gap:12px}.pay-selected-customer>.pay-button{grid-column:1/-1;grid-row:auto}.pay-customer-result{padding:14px}}
/* Customer financial pages follow the standalone prototypes and STYLE_GUIDE.
All rules are scoped: staff forms and the ticket composer keep their styles. */
.customer-finance .pay-breadcrumb{display:flex;gap:10px;align-items:center;flex-wrap:wrap;font-size:12px;color:#526c86;margin-bottom:16px}
.customer-finance-title{display:flex;align-items:center;gap:16px;margin-bottom:24px}.customer-finance-title h1{margin:0}.customer-finance-title p{color:#526c86;margin:6px 0 0}.customer-finance .pay-page-icon{width:56px;height:56px;display:grid;place-items:center;background:#e5f4ff;border:1px solid #cde6f7;border-radius:18px;flex-shrink:0}.customer-finance .pay-page-icon svg{width:28px;height:28px}
.customer-finance .finance-guidance{background:#f0f8fd;border:1px solid #cde6f7;border-radius:12px;padding:12px 14px;font-size:14px;line-height:1.8;color:#526c86;margin:12px 0 20px}.customer-finance .pay-text-link{display:inline-flex;align-items:center;gap:6px;min-height:44px;font-size:14px;font-weight:700;text-decoration:underline;text-underline-offset:4px}.customer-finance .pay-text-link svg{width:18px;height:18px}.customer-service-footer{display:flex;justify-content:space-between;gap:12px;flex-wrap:wrap;margin:20px 0}
.customer-receipt-form.pay-form{max-width:none;margin:0;padding:0;border:0;background:transparent;box-shadow:none}.customer-receipt-form .pay-card{margin:0 0 20px;padding:22px;border-radius:23px}.customer-receipt-form h2{margin:0 0 16px}.customer-receipt-form .pay-field{min-width:0}.customer-receipt-form .pay-field label{font-size:14px}.customer-receipt-form .pay-optional{margin-inline-start:8px}.customer-receipt-form input:not([type=hidden]):not([type=radio]):not([type=file]),.customer-receipt-form select,.customer-receipt-form textarea{padding:10px 12px;font-size:16px;min-height:48px;box-shadow:none;border-color:#b8c9d8}.customer-receipt-form textarea{height:112px;min-height:112px;resize:vertical}.customer-field-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:20px 24px;margin-top:20px}.customer-field-grid .pay-field{margin:0}.customer-receipt-form .pay-field-hint{font-size:12px;line-height:1.8}.customer-receipt-form #amount-words{font-size:14px;color:#526c86;line-height:1.8}
.customer-receipt-modes{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:12px}.customer-mode{border:1px solid #dce5ed;border-radius:14px;padding:12px 14px;background:#fafcfe}.customer-mode.recommended-mode{border-color:#91bfdd}.customer-mode:has(input:checked){background:#e5f4ff;border-color:#1977ce}.customer-receipt-form .customer-mode label{display:flex;align-items:center;gap:10px;cursor:pointer;min-height:36px;margin:0;padding:0}.customer-mode small{display:block;margin-inline-start:30px;font-size:12px;color:#526c86}.customer-receipt-form .customer-mode input[type=radio]{width:20px;height:20px;min-height:20px;margin:0;padding:0;flex:0 0 20px;accent-color:#08376b;box-shadow:none}.customer-receipt-form .receipt-mode-field{padding:0;border:0}.customer-receipt-form #selected-payment h3{font-size:16px;margin:0}.customer-receipt-form #selected-payment p{font-size:14px;color:#526c86}
.customer-date{padding:20px 0 0;margin-top:24px;border:0;border-top:1px solid #e7edf2;min-width:0}.customer-date legend{font-size:14px;font-weight:700;padding-inline:0 10px}.customer-date-selects{display:grid;grid-template-columns:.7fr 1.3fr 1fr;gap:12px}.customer-receipt-form .customer-date-selects>.pay-field{display:block;width:auto;margin:0;min-width:0}.customer-receipt-form [data-field=paid_day] .field-error{min-width:0}.customer-date-tools{display:flex;align-items:center;justify-content:space-between;gap:12px;margin-top:8px}.customer-date-tools .pay-today{margin:0;font-size:12px;min-height:44px;padding:4px 12px}.customer-receipt-form #date-preview{font-size:12px;color:#526c86;min-height:22px}
.customer-upload-zone{position:relative;border:1px dashed #91bfdd;background:#f6fbff;border-radius:16px;display:flex;flex-direction:column;align-items:center;padding:24px 16px;text-align:center;gap:12px}.customer-upload-zone>.cs-icon{width:32px;height:32px;color:#1977ce}.customer-receipt-form .customer-file-button{display:inline-flex;align-items:center;gap:8px;min-height:44px;padding:8px 16px;border-radius:12px;background:#08376b;color:#fff;cursor:pointer;margin:0}.customer-file-button span{font-size:12px;font-weight:400}.customer-upload-zone p{font-size:12px;color:#526c86;margin:0}.customer-receipt-form .customer-upload-zone input[type=file]{position:absolute;width:1px;height:1px;min-height:0;padding:0;margin:-1px;overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap;border:0}.customer-upload-zone:focus-within .customer-file-button{outline:3px solid #1977ce;outline-offset:3px}.customer-file-selection{display:flex;align-items:center;justify-content:space-between;gap:12px;font-size:12px;margin-top:12px}.customer-file-selection p{overflow-wrap:anywhere}.customer-file-selection button{min-height:44px;flex-shrink:0;background:#fff;border:1px solid #dce5ed;border-radius:10px;font:inherit;color:#9f1f1f;padding:8px 12px}.customer-receipt-form #receipt-file-preview{display:block;width:100%;max-height:240px;object-fit:contain;border:1px solid #dce5ed;border-radius:12px;background:#fafcfe;margin-top:12px}.customer-receipt-form [hidden]{display:none!important}.customer-receipt-actions{display:flex;align-items:center;gap:16px;flex-wrap:wrap;margin-top:20px}.customer-receipt-actions .pay-button{min-width:180px}
.customer-list-heading{display:flex;align-items:center;justify-content:space-between;gap:12px;margin:24px 0 12px}.customer-list-heading h2{margin:0}.customer-list-heading>span{font-size:12px;color:#526c86;background:#fff;border-radius:20px;padding:4px 12px}.customer-search-box{display:flex;align-items:center;gap:10px;background:#fff;border:1px solid #b8c9d8;border-radius:12px;padding-inline:12px;flex:1;min-width:0}.customer-notice-search{margin-bottom:20px;align-items:center}.customer-finance .customer-search-box input{border:0;background:transparent;min-width:0;padding-inline:0;box-shadow:none}.customer-search-box:focus-within{outline:3px solid #1977ce;outline-offset:2px}.customer-empty{text-align:center;padding:32px 20px}.customer-empty>.cs-icon{width:36px;height:36px;color:#526c86}.customer-empty p{color:#526c86}
.customer-accounts .payment-notice{padding:0;overflow:hidden;border-radius:23px;margin:16px 0}.customer-accounts .payment-notice summary{position:relative;display:grid;grid-template-columns:minmax(0,1fr) auto 22px;gap:8px 16px;padding:20px 22px;list-style:none}.payment-notice summary::-webkit-details-marker{display:none}.customer-accounts .payment-notice summary strong{min-width:0;grid-column:1;grid-row:1;font-size:16px}.customer-accounts .payment-notice summary b{grid-column:2;grid-row:1;font-size:20px;white-space:nowrap}.customer-accounts .payment-notice summary b span{font-size:12px;font-weight:400}.customer-accounts .payment-notice summary small{grid-column:1;grid-row:2;width:auto}.customer-accounts .notice-state{grid-column:2;grid-row:2;font-size:12px;color:#1977ce;justify-self:end}.customer-accounts .payment-notice summary>.cs-icon{grid-column:3;grid-row:1/3;width:20px;align-self:center}.customer-accounts .payment-notice[open] summary>.cs-icon{transform:rotate(180deg)}.customer-accounts .payment-notice[open] summary{border-bottom:1px solid #e7edf2}.customer-accounts .pay-details{padding:0 22px 6px}.customer-accounts .notice-bank{font-size:14px;color:#526c86;margin:16px 0}.customer-accounts .payment-values{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:12px;margin:16px 0}.customer-accounts .payment-values>div{display:grid;grid-template-columns:minmax(0,1fr) auto;gap:4px 12px;border:1px solid #dce5ed;border-radius:12px;padding:12px 14px;background:#fafcfe;align-items:center}.customer-accounts .payment-values dt{grid-column:1;grid-row:1;font-size:12px;color:#526c86;font-weight:400}.customer-accounts .payment-values dd{grid-column:1/-1;grid-row:2;font-size:16px;margin:0;line-height:1.8;letter-spacing:.3px}.customer-accounts .pay-copy{grid-column:2;grid-row:1;font-size:12px;padding:4px 8px;min-height:44px;border-color:#dce5ed;gap:6px}.customer-accounts .pay-copy svg{width:16px;height:16px}.customer-accounts .pay-details>p:not(.notice-bank){font-size:12px;color:#526c86}.customer-accounts .pay-supplement{font-size:14px;line-height:1.9}.customer-accounts .pay-supplement h3{margin:0 0 8px}.customer-accounts .pay-actions{gap:12px}
@media(max-width:639px){.customer-finance-title{gap:12px;margin-bottom:20px;align-items:flex-start}.customer-finance-title h1{font-size:24px}.customer-finance .pay-page-icon{width:48px;height:48px;border-radius:14px}.customer-receipt-form .pay-card{padding:16px;border-radius:20px;margin-bottom:16px}.customer-receipt-form h2{font-size:18px}.customer-field-grid{grid-template-columns:minmax(0,1fr);gap:18px}.customer-receipt-modes{grid-template-columns:minmax(0,1fr);gap:8px}.customer-date-selects{gap:8px}.customer-receipt-form .customer-date-selects select{padding-inline:6px}.customer-receipt-actions{gap:8px}.customer-receipt-actions .pay-button{width:100%}.customer-service-footer{flex-direction:column;gap:0}.customer-accounts .payment-notice{border-radius:20px}.customer-accounts .payment-notice summary{grid-template-columns:minmax(0,1fr) 20px;padding:16px;gap:6px 8px}.customer-accounts .payment-notice summary strong{grid-column:1;grid-row:1}.customer-accounts .payment-notice summary b{grid-column:1;grid-row:2;justify-self:start;font-size:20px}.customer-accounts .payment-notice summary small{grid-column:1;grid-row:3}.customer-accounts .notice-state{grid-column:1;grid-row:4;justify-self:start}.customer-accounts .payment-notice summary>.cs-icon{grid-column:2;grid-row:1/5}.customer-accounts .pay-details{padding:0 16px 4px}.customer-accounts .payment-values{grid-template-columns:minmax(0,1fr)}.customer-accounts .pay-actions>.pay-button{width:100%}}
/* Review identity and history: names identify actual decisions, not access lists. */
.pay-customer-summary{display:flex;flex-direction:column;gap:3px;margin-top:8px;min-width:0}.pay-customer-summary strong{font-size:14px;font-weight:800}.pay-customer-summary>span{font-size:14px;color:#526c86;line-height:1.8}.pay-record-review{display:flex;flex-direction:column;gap:3px;margin-top:6px;font-size:12px;color:#526c86;line-height:1.8}.pay-linked-receipts{border-top:1px solid #dce5ed;margin-top:20px;padding-top:16px}.pay-linked-receipts>h3{font-size:16px;margin:0 0 12px}.pay-linked-receipt{padding:14px 16px;background:#fafcfe;border:1px solid #dce5ed;border-radius:14px;margin:12px 0}.pay-linked-receipt-head{display:flex;align-items:center;justify-content:space-between;gap:8px;flex-wrap:wrap}.pay-linked-receipt-meta{font-size:14px;color:#526c86}.pay-linked-decision{display:flex;flex-direction:column;gap:4px;padding:10px 0;border-top:1px solid #e7edf2;font-size:14px}.pay-linked-decision time{font-size:12px;color:#526c86}.customer-receipt-detail .pay-page-head{gap:16px;margin-bottom:20px}.customer-receipt-detail .pay-card{padding:22px;border-radius:23px}.customer-receipt-detail .pay-detail-aside .pay-timeline-item{margin-top:12px}.customer-receipt-detail .pay-timeline-item p{overflow-wrap:anywhere}.customer-receipt-detail .pay-page-icon{width:48px;height:48px}.customer-receipt-detail .pay-version-row>div{text-align:end}.pay-version-row .pay-field-hint{margin:4px 0!important}
@media(max-width:639px){.customer-receipt-detail .pay-card{padding:16px;border-radius:20px}.customer-receipt-detail h1{font-size:24px}.pay-linked-receipt{padding:12px}.customer-receipt-detail .pay-version-row>div{text-align:start;width:100%}}
@media(max-width:359px){.customer-accounts .payment-values dd{font-size:14px;letter-spacing:0}}
.pay-notice-permalink{margin:0 12px 12px}.customer-notice-detail .payment-notice>summary{cursor:pointer}.staff-receipt-workspace .customer-finance-title{align-items:flex-start}.staff-receipt-workspace .pay-customer-summary{padding:10px 14px;margin-top:12px;background:#fff;border:1px solid #dce5ed;border-radius:12px}.staff-receipt-workspace .customer-receipt-form{max-width:900px;margin:auto}
.customer-accounts .pay-notice-entry{background:#fff;border:1px solid #dce5ed;border-radius:23px;margin:16px 0;overflow:hidden}.customer-accounts .pay-notice-entry .payment-notice{border:0;border-radius:0;margin:0}.customer-accounts .pay-notice-entry .pay-notice-permalink{display:flex;justify-content:space-between;border-top:1px solid #e7edf2;margin:0;padding:12px 20px;min-height:48px;text-decoration:none;background:#fafcfe}
.pay-identifier-control{display:flex;align-items:stretch;gap:12px}.pay-identifier-control>input{flex:1;min-width:0}.pay-identifier-control>.pay-button{gap:8px;flex-shrink:0;white-space:nowrap;font-size:14px}.pay-identifier-control>.pay-button:disabled{opacity:.6;cursor:not-allowed}.pay-staff-form #id_deposit_id{letter-spacing:.5px}.pay-staff-form #identifier-replacement p{font-size:14px}.pay-staff-form #identifier-replacement .pay-text-link{font:inherit;background:transparent;border:0;color:#526c86;min-height:44px;padding:8px 12px;cursor:pointer}.pay-staff-form #identifier-replacement[hidden]{display:none}
@media(max-width:639px){.pay-identifier-control{flex-direction:column;gap:8px}.pay-identifier-control>.pay-button{align-self:stretch}}
+24
View File
@@ -0,0 +1,24 @@
.magic-confirmation { color: var(--color-ink, #08376b); background: var(--color-cream, #eeeeee); }
.magic-confirmation-shell { width: min(100%, 36rem); margin-inline: auto; padding: clamp(1.5rem, 7vh, 4rem) 1rem max(1.5rem, env(safe-area-inset-bottom)); }
.magic-confirmation-brand { display: flex; justify-content: center; margin-bottom: 1.5rem; }
.magic-confirmation-brand .auth-brand { margin: 0; }
.magic-confirmation-brand img { width: 8.5rem; height: auto; object-fit: contain; }
.magic-confirmation-card { padding: 1.5rem; border: 1px solid #dce5ed; border-top: 3px solid var(--color-coral, #fa9f00); border-radius: 1.25rem; background: #fff; }
.magic-confirmation-icon { display: grid; place-items: center; width: 3rem; height: 3rem; margin-bottom: 1rem; border-radius: .875rem; background: var(--color-mint, #e5f4ff); }
.magic-confirmation-icon svg { width: 1.5rem; height: 1.5rem; }
.magic-confirmation-card h1 { margin: 0; font-size: 1.5rem; font-weight: 900; line-height: 1.6; letter-spacing: normal; }
.magic-confirmation-card p { margin: 1rem 0 0; font-size: 1rem; font-weight: 400; line-height: 1.9; color: #526c86; }
.magic-confirmation-actions { display: grid; gap: .75rem; margin-top: 1.5rem; }
.magic-confirmation-primary, .magic-confirmation-cancel { display: flex; align-items: center; justify-content: center; min-height: 3rem; min-width: 2.75rem; padding: .75rem 1rem; border: 1px solid transparent; border-radius: .75rem; font-size: .875rem; font-weight: 800; line-height: 1.7; text-align: center; white-space: normal; overflow-wrap: anywhere; text-decoration: none; box-shadow: none; transform: none; }
.magic-confirmation-primary { color: #fff; background: var(--color-ink, #08376b); }
.magic-confirmation-primary:hover { background: var(--color-ink-soft, #0b4c8c); box-shadow: none; transform: none; }
.magic-confirmation-cancel { color: var(--color-ink, #08376b); background: #fff; border-color: #dce5ed; }
.magic-confirmation-cancel:hover { background: var(--color-mint, #e5f4ff); border-color: #91bfdd; }
.magic-confirmation-primary:focus-visible, .magic-confirmation-cancel:focus-visible { outline: 3px solid var(--color-sea, #0693e3); outline-offset: 4px; }
.magic-confirmation-primary:disabled { background: var(--color-ink-soft, #0b4c8c); color: #fff; opacity: 1; cursor: wait; }
.magic-confirmation-status:empty { display: none; }
.magic-confirmation-status { color: #526c86; font-size: .875rem; line-height: 1.7; text-align: center; }
.magic-confirmation .auth-footer { margin-top: 1.5rem; color: #526c86; font-size: .75rem; line-height: 1.9; text-align: center; overflow-wrap: anywhere; }
@media (min-width: 640px) { .magic-confirmation-card { padding: 2rem; } .magic-confirmation-card h1 { font-size: 1.75rem; } }
@media (max-width: 359px) { .magic-confirmation-shell { padding-inline: .75rem; } .magic-confirmation-card { padding: 1.25rem; } }
@media (prefers-reduced-motion: reduce) { .magic-confirmation-actions > * { transition: none; } }
+18
View File
@@ -99,3 +99,21 @@ body>.voip-call-status{position:fixed!important;z-index:2147483001;right:auto!im
.contact-history__empty strong{color:#274b65;font-size:.82rem} .contact-history__empty strong{color:#274b65;font-size:.82rem}
.contact-history__empty p{margin:0;color:#78909f;font-size:.68rem} .contact-history__empty p{margin:0;color:#78909f;font-size:.68rem}
@media(max-width:640px){.contact-history__heading{flex-wrap:wrap;padding:1rem}.contact-history__heading p{line-height:1.7}.contact-history__total{margin-inline-start:0;margin-inline-end:auto}.contact-history__timeline{padding:1rem .9rem 1rem .7rem}.contact-history__event{padding-right:1.25rem}.contact-history__summary{gap:.55rem;padding:.7rem}.contact-history__change{grid-template-columns:1fr;gap:.35rem}.contact-history__changes{padding:.7rem}.contact-history__values{gap:.3rem}.contact-history__value{padding:.4rem .45rem}} @media(max-width:640px){.contact-history__heading{flex-wrap:wrap;padding:1rem}.contact-history__heading p{line-height:1.7}.contact-history__total{margin-inline-start:0;margin-inline-end:auto}.contact-history__timeline{padding:1rem .9rem 1rem .7rem}.contact-history__event{padding-right:1.25rem}.contact-history__summary{gap:.55rem;padding:.7rem}.contact-history__change{grid-template-columns:1fr;gap:.35rem}.contact-history__changes{padding:.7rem}.contact-history__values{gap:.3rem}.contact-history__value{padding:.4rem .45rem}}
/* Contextual financial action; local SVG, full customer identity passed by UUID. */
.payment-notice-shortcut{display:inline-flex;align-items:center;justify-content:center;gap:8px;min-height:44px;padding:9px 12px;border:1px solid #b8cfe3;border-radius:12px;background:#e5f4ff;color:#08376b;font-size:14px;font-weight:700;text-decoration:none}.payment-notice-shortcut svg{width:18px;height:18px;flex-shrink:0}.payment-notice-shortcut:hover{background:#d8ecfa;border-color:#91bfdd}.payment-notice-shortcut:focus-visible{outline:3px solid #1977ce;outline-offset:3px}.customer-side .payment-notice-shortcut{display:flex;width:100%;margin-top:16px}.contact-card footer .payment-notice-shortcut{width:100%;font-size:14px;min-height:44px}
.contact-card footer:has(.payment-notice-shortcut){flex-wrap:wrap;gap:8px}.contact-card footer .payment-notice-shortcut{flex-basis:100%;color:#08376b;font-weight:700}
.ticket-notices{margin-top:16px;border:1px solid #cbdce9;border-radius:14px;background:#f8fbfe;overflow:hidden}
.ticket-notices>summary{display:flex;align-items:center;justify-content:space-between;gap:8px;min-height:48px;padding:12px;color:#08376b;font-size:14px;font-weight:700;cursor:pointer;list-style:none;background:#e5f4ff}
.ticket-notices>summary::-webkit-details-marker{display:none}
.ticket-notices-title{display:flex;align-items:center;gap:8px}.ticket-notices-title svg{flex-shrink:0}
.ticket-notices-chevron{font-size:20px;line-height:1}.ticket-notices[open] .ticket-notices-chevron{transform:rotate(180deg)}
.ticket-notices>summary:hover{background:#d8ecfa}.ticket-notices>summary:focus-visible,.ticket-notice-item:focus-visible,.ticket-notices-more:focus-visible{outline:3px solid #1977ce;outline-offset:-3px}
.ticket-notices-content{padding:12px}.customer-side .ticket-notices .payment-notice-shortcut{margin-top:0;padding:8px;font-size:12px;gap:6px}.ticket-notices .payment-notice-shortcut span{white-space:nowrap}
.ticket-notices-caption{margin:14px 0 8px;font-size:12px;color:#536a80}
.ticket-notices-list{display:grid;gap:8px;margin:0;padding:0;list-style:none}
.ticket-notice-item{display:grid;gap:8px;padding:12px 10px;border:1px solid #dce6ee;border-radius:10px;background:#fff;text-decoration:none;color:#08376b}
.ticket-notice-item:hover{border-color:#91bfdd;background:#f0f8ff}.ticket-notice-item strong{font-size:13px;overflow-wrap:anywhere}
.ticket-notice-meta{display:flex;align-items:center;justify-content:space-between;flex-wrap:wrap;gap:6px;font-size:11px;color:#536a80}.ticket-notice-meta bdi{font-size:10px}
.ticket-notice-status{padding:3px 7px;border-radius:6px;background:#fff3d8;color:#805b13;font-weight:700}.ticket-notice-status.is-settled{background:#e4f5eb;color:#216544}.ticket-notice-status.is-cancelled{background:#f0f1f4;color:#657080}
.ticket-notices-empty{padding:12px 0;font-size:12px;color:#536a80;line-height:1.8}
.ticket-notices-more{display:flex;align-items:center;flex-wrap:wrap;gap:6px;min-height:44px;margin-top:8px;color:#1977ce;font-size:13px;font-weight:700;text-decoration:none}.ticket-notices-more small{flex:1;font-size:10px;font-weight:400}.ticket-notices-more:hover{text-decoration:underline}
+10
View File
@@ -0,0 +1,10 @@
/* Temporary payment access follows the Chaharsu style guide. */
.pay-share-url{overflow-wrap:anywhere;font-size:14px;line-height:1.8;padding:12px;border:1px solid #dce5ed;border-radius:12px;background:#f6fbff}.pay-detail-aside .pay-card:has(.pay-share-url) form{margin-top:12px}
.pay-link-status{display:flex;align-items:center;justify-content:space-between;flex-wrap:wrap;gap:8px 16px;margin-bottom:24px;padding:14px 18px;border:1px solid #cde6f7;border-radius:14px;background:#f6fbff;color:#08376b;line-height:1.8}
.pay-link-status>span{display:flex;align-items:center;gap:8px;font-size:14px;font-weight:700}.pay-link-status .cs-icon{width:20px;height:20px;flex-shrink:0}.pay-link-status small{color:#526c86;font-size:14px}.pay-link-session-label{font-size:14px;color:#526c86}
.payment-values .pay-deposit-required,.customer-accounts .payment-values .pay-deposit-required{background:#fff8f8;border-color:#edc5c5}
.payment-values .pay-deposit-required dt,.payment-values .pay-deposit-required dd,.customer-accounts .payment-values .pay-deposit-required dt,.customer-accounts .payment-values .pay-deposit-required dd{color:#9f1f1f;font-weight:700}
.pay-deposit-warning,.customer-accounts .pay-details>p.pay-deposit-warning{padding:12px 14px;border:1px solid #edc5c5;border-radius:12px;background:#fff8f8;color:#9f1f1f;font-size:14px;line-height:1.9}
.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%}}
+25
View File
@@ -0,0 +1,25 @@
/* Selection changes presentation only; permissions and updates remain server-side. */
(() => {
'use strict';
const form = document.querySelector('[data-bank-selection]');
if (!form) return;
const boxes = Array.from(form.querySelectorAll('input[name="accounts"]'));
const all = form.querySelector('[data-select-all]');
const count = form.querySelector('[data-selection-count]');
const submit = form.querySelector('[data-bulk-submit]');
if (!all || !count || !submit) return;
const update = () => {
const selected = boxes.filter(box => box.checked).length;
all.checked = selected > 0 && selected === boxes.length;
all.indeterminate = selected > 0 && selected < boxes.length;
count.textContent = selected ? `${selected.toLocaleString('fa-IR')} حساب انتخاب شده` : 'حساب‌های موردنظر را انتخاب کنید.';
submit.disabled = selected === 0;
};
all.addEventListener('change', () => {
boxes.forEach(box => { box.checked = all.checked; });
update();
});
boxes.forEach(box => box.addEventListener('change', update));
window.addEventListener('pageshow', update);
update();
})();
+94
View File
@@ -0,0 +1,94 @@
/* Local, server-filtered search. Explicit choice only; no customer data in storage. */
(() => {
'use strict';
const root = document.querySelector('[data-payment-customer-picker]');
if (!root) return;
const search = root.querySelector('input[type=search]');
const value = root.querySelector('#id_customer');
const results = root.querySelector('[data-customer-results]');
const selected = root.querySelector('[data-selected-customer]');
const status = root.querySelector('[role=status]');
let timer, controller, version = 0;
const node = (tag, text, className) => {
const element = document.createElement(tag);
if (text !== undefined) element.textContent = text;
if (className) element.className = className;
return element;
};
const history = customer => {
const details = node('details', undefined, 'pay-customer-history');
details.append(node('summary', `سوابق قابل مشاهده · ${customer.ticket_count.toLocaleString('fa-IR')} تیکت`));
if (!customer.history.length) details.append(node('p', 'سابقه تیکت قابل مشاهده‌ای وجود ندارد.'));
customer.history.forEach(ticket => {
const link = node('a', `${ticket.number} · ${ticket.subject}`);
link.href = ticket.url; link.target = '_blank'; link.rel = 'noopener';
const row = node('div'); row.append(link, node('small', ticket.status)); details.append(row);
});
return details;
};
const identity = customer => {
const container = node('div', undefined, 'pay-customer-identity');
container.append(node('strong', customer.name), node('bdi', customer.mobile));
if (customer.organization) container.append(node('span', customer.organization));
container.append(node('small', customer.location || 'موقعیت ثبت نشده'));
return container;
};
const choose = (customer, focus = true) => {
clearTimeout(timer); version++; controller?.abort();
value.value = customer.value; value.dispatchEvent(new Event('change', {bubbles:true}));
search.value = ''; results.hidden = true; results.replaceChildren();
selected.replaceChildren(node('span', 'مشتری انتخاب‌شده', 'pay-selected-label'), identity(customer), history(customer));
const change = node('button', 'تغییر مشتری', 'pay-button secondary'); change.type = 'button';
change.addEventListener('click', () => {
value.value = ''; value.dispatchEvent(new Event('change', {bubbles:true})); selected.hidden = true; search.focus();
status.textContent = 'مشتری جدید را جست‌وجو و انتخاب کنید.';
});
selected.append(change); selected.hidden = false;
status.textContent = 'قبل از صدور، نام و شماره تماس مشتری انتخاب‌شده را بررسی کنید.';
if (focus) change.focus({preventScroll:true});
};
const lookup = async () => {
const query = search.value.trim(), current = ++version;
controller?.abort(); controller = new AbortController();
if (query.length < 2) { results.hidden = true; status.textContent = 'حداقل دو حرف یا رقم وارد کنید.'; return; }
status.textContent = 'در حال جست‌وجو…';
try {
const response = await fetch(`${root.dataset.searchUrl}?q=${encodeURIComponent(query)}`, {
credentials: 'same-origin', cache: 'no-store', signal: controller.signal
});
if (!response.ok) throw new Error('lookup');
const data = await response.json();
if (current !== version) return;
results.replaceChildren();
data.results.forEach(customer => {
const card = node('article', undefined, 'pay-customer-result');
const button = node('button', 'انتخاب این مشتری', 'pay-button secondary'); button.type = 'button';
button.addEventListener('click', () => choose(customer));
card.append(identity(customer), history(customer), button); results.append(card);
});
results.hidden = !data.results.length;
status.textContent = data.results.length ? `${data.results.length.toLocaleString('fa-IR')} نتیجه${data.has_more ? '؛ برای نتایج دقیق‌تر نام یا شماره کامل‌تری وارد کنید.' : '؛ مشتری موردنظر را انتخاب کنید.'}` : 'مشتری فعالی پیدا نشد؛ نام یا شماره تماس را بررسی کنید.';
} catch (error) {
if (error.name === 'AbortError' || current !== version) return;
results.hidden = true;
status.textContent = 'جست‌وجو انجام نشد؛ اتصال را بررسی کنید و دوباره جست‌وجو کنید.';
}
};
search.addEventListener('input', () => {
clearTimeout(timer); version++; controller?.abort();
value.value = ''; value.dispatchEvent(new Event('change', {bubbles:true})); selected.hidden = true; results.hidden = true;
if (search.value.trim().length < 2) { status.textContent = 'حداقل دو حرف یا رقم وارد کنید.'; return; }
timer = setTimeout(lookup, 350);
});
search.addEventListener('keydown', event => {
if (event.key === 'Enter') { event.preventDefault(); clearTimeout(timer); lookup(); }
});
root.closest('form').addEventListener('submit', event => {
if (!value.value) {
event.preventDefault(); status.textContent = 'مشتری را از نتایج جست‌وجو انتخاب کنید.';
search.focus(); search.scrollIntoView({block:'center'});
}
});
const initial = JSON.parse(document.querySelector('#payment-selected-customer').textContent);
if (initial && String(initial.value) === value.value) choose(initial, false);
})();
+44
View File
@@ -0,0 +1,44 @@
/* Customer-only presentation. Financial and image validation remains in receipt-form.js.
Object URLs preview local files without uploading or persisting them. */
(() => {
const form = document.querySelector('.customer-receipt-form');
if (!form) return;
const input = form.elements.namedItem('receipt_image');
const preview = document.querySelector('#receipt-file-preview');
const selection = document.querySelector('#receipt-file-selection');
const status = document.querySelector('#receipt-file-status');
let previewUrl = null;
function releasePreview() {
if (previewUrl) URL.revokeObjectURL(previewUrl);
previewUrl = null;
preview.removeAttribute('src');
preview.hidden = true;
}
function updateFile() {
releasePreview();
const file = input.files[0];
selection.hidden = !file;
status.textContent = file ? file.name + ' · ' + new Intl.NumberFormat('fa-IR', {maximumFractionDigits: 1}).format(file.size / 1024) + ' کیلوبایت' : '';
if (file && /^image\/(jpeg|png|gif|webp)$/.test(file.type)) {
previewUrl = URL.createObjectURL(file);
preview.src = previewUrl;
preview.hidden = false;
}
}
input.addEventListener('change', updateFile);
document.querySelector('#receipt-clear-file').addEventListener('click', () => {
input.value = '';
input.dispatchEvent(new Event('change', {bubbles: true}));
input.focus();
});
window.addEventListener('pagehide', releasePreview);
const origin = form.elements.namedItem('origin_bank');
const other = form.elements.namedItem('origin_other');
function updateOrigin() {
const visible = origin.selectedOptions[0]?.textContent.trim() === 'سایر';
other.closest('[data-field]').hidden = !visible;
other.disabled = !visible;
}
origin.addEventListener('change', updateOrigin);
updateOrigin();
})();
+65
View File
@@ -0,0 +1,65 @@
/* Server calculates the identifier from the selected customer's stored mobile.
Manual values remain editable. Changing customer clears an unchanged auto value. */
(() => {
const button = document.querySelector('#generate-deposit-identifier');
if (!button) return;
const form = button.closest('form');
const customer = form.elements.namedItem('customer');
const input = form.elements.namedItem('deposit_id');
const status = document.querySelector('#identifier-generation-status');
const replacement = document.querySelector('#identifier-replacement');
const candidate = document.querySelector('#identifier-candidate');
const originalLabel = button.innerHTML;
let activeCustomer = customer.value, generated = '', pending = '', controller = null;
function customerChanged() {
if (activeCustomer !== customer.value) {
controller?.abort(); controller = null;
if (generated && input.value === generated) input.value = '';
generated = ''; pending = ''; replacement.hidden = true;
activeCustomer = customer.value;
}
button.disabled = !customer.value;
button.innerHTML = originalLabel;
status.textContent = customer.value ? 'شناسه بر اساس شماره موبایل مشتری انتخاب‌شده تولید می‌شود.' : 'ابتدا مشتری را انتخاب کنید؛ شناسه بر اساس شماره موبایل او تولید می‌شود.';
}
function applyIdentifier(value) {
input.value = value; generated = value; pending = '';
replacement.hidden = true;
input.dispatchEvent(new Event('input', {bubbles:true}));
input.dispatchEvent(new Event('change', {bubbles:true}));
input.focus();
status.textContent = 'شناسه تولید و در فیلد وارد شد؛ قبل از صدور اعلامیه آن را بررسی کنید.';
}
customer.addEventListener('change', customerChanged);
button.addEventListener('click', async () => {
if (!customer.value) return;
const selectedCustomer = customer.value;
const request = new AbortController(); controller = request;
button.disabled = true; button.textContent = 'در حال تولید…';
replacement.hidden = true;
status.textContent = 'در حال دریافت شناسه از سیستم…';
let failureMessage = 'شناسه دریافت نشد؛ اتصال را بررسی کنید و دوباره تلاش کنید.';
try {
const response = await fetch(button.dataset.url, {
method: 'POST', credentials: 'same-origin', cache: 'no-store', signal: request.signal,
headers: {'X-CSRFToken': form.elements.namedItem('csrfmiddlewaretoken').value},
body: new URLSearchParams({customer: selectedCustomer})
});
const data = await response.json();
if (request !== controller || customer.value !== selectedCustomer) return;
if (!response.ok) {failureMessage = data.error || 'شناسه تولید نشد؛ دوباره تلاش کنید.'; throw new Error('generation');}
if (data.customer !== selectedCustomer || !/^[0-9]{13}$/.test(data.identifier)) {failureMessage = 'پاسخ سیستم معتبر نیست؛ دوباره تلاش کنید.'; throw new Error('generation');}
if (input.value.trim() && input.value !== data.identifier) {
pending = data.identifier; candidate.textContent = pending; replacement.hidden = false;
status.textContent = 'شناسه آماده است؛ برای جایگزینی مقدار فعلی، دکمه جایگزینی را بزنید.';
} else applyIdentifier(data.identifier);
} catch (error) {
if (error.name !== 'AbortError' && request === controller) status.textContent = failureMessage;
} finally {
if (request === controller) {controller = null; button.disabled = !customer.value; button.innerHTML = originalLabel;}
}
});
document.querySelector('#apply-deposit-identifier').addEventListener('click', () => {if (pending) applyIdentifier(pending);});
document.querySelector('#cancel-deposit-identifier').addEventListener('click', () => {pending = ''; replacement.hidden = true; status.textContent = 'شناسه فعلی حفظ شد.'; button.focus();});
customerChanged();
})();
+20
View File
@@ -0,0 +1,20 @@
/* All assets and UI behavior are local. No CDN or financial data in persistent browser storage. */
(() => {
'use strict';
let toast;
function status(text){if(!toast){toast=document.createElement('div');toast.className='pay-copy-status';toast.setAttribute('role','status');document.body.append(toast);}toast.textContent=text;toast.hidden=false;setTimeout(()=>{toast.hidden=true;},3000);}
async function copy(text){
try{await navigator.clipboard.writeText(text);status('کپی شد');}
catch{
const dialog=document.createElement('dialog');dialog.className='pay-manual-copy';dialog.setAttribute('aria-label','کپی اطلاعات');
const title=document.createElement('h2');title.textContent='متن را انتخاب و کپی کنید';const area=document.createElement('textarea');area.readOnly=true;area.value=text;
const select=document.createElement('button');select.className='pay-button';select.textContent='انتخاب متن';select.onclick=()=>{area.focus();area.select();};
const close=document.createElement('button');close.className='pay-button secondary';close.textContent='بستن';close.onclick=()=>dialog.close();dialog.append(title,area,select,close);document.body.append(dialog);dialog.addEventListener('close',()=>dialog.remove());dialog.showModal();area.focus();area.select();
}
}
document.querySelectorAll('[data-copy]').forEach(button=>button.addEventListener('click',()=>copy(button.dataset.copy)));
const identifiers=document.querySelector('#identifier-accounts'),account=document.querySelector('#id_account'),deposit=document.querySelector('#id_deposit_id');
if(identifiers && account && deposit){const ids=JSON.parse(identifiers.textContent).map(String);function update(){deposit.required=ids.includes(account.value);document.querySelector('#identifier-help').textContent=deposit.required?'این حساب شناسه‌دار است؛ شناسه واریز مشتری الزامی است.':'شناسه واریز در صورت اعلام بانک وارد شود.';}account.addEventListener('change',update);update();}
const badge=document.querySelector('[data-payment-count]');
if(badge){const update=async()=>{if(document.hidden)return;try{const response=await fetch(badge.dataset.countUrl,{credentials:'same-origin',cache:'no-store'});if(!response.ok)return;const data=await response.json();document.querySelectorAll('[data-payment-count]').forEach(node=>{node.textContent=String(data.pending_receipts);});}catch{}};document.addEventListener('visibilitychange',update);setInterval(update,60000);}
})();
+78
View File
@@ -0,0 +1,78 @@
/* Local amount presentation: never convert rials to tomans for the server.
Exact BigInt arithmetic; preserve fractional toman (one rial = one tenth toman).
Shared with notice amounts; visible grouping does not replace server validation. */
(() => {
'use strict';
const input=document.querySelector('#id_paid_amount, #id_amount');if(!input)return;
input.inputMode='numeric';
const words=document.querySelector('#amount-words');
const small=['صفر','یک','دو','سه','چهار','پنج','شش','هفت','هشت','نه','ده','یازده','دوازده','سیزده','چهارده','پانزده','شانزده','هفده','هجده','نوزده'];
const tens=['','','بیست','سی','چهل','پنجاه','شصت','هفتاد','هشتاد','نود'];
const hundreds=['','صد','دویست','سیصد','چهارصد','پانصد','ششصد','هفتصد','هشتصد','نهصد'];
const scales=['','هزار','میلیون','میلیارد','تریلیون','کوادریلیون'];
const ascii=value=>value.replace(/[۰-۹]/g,digit=>String(digit.charCodeAt(0)-1776)).replace(/[٠-٩]/g,digit=>String(digit.charCodeAt(0)-1632));
const digitCount=value=>(ascii(value).match(/\d/g) || []).length;
const separators=/[,٬ \u00a0]/g;
const grouped=value=>value.replace(/\B(?=(\d{3})+(?!\d))/g,'٬').replace(/\d/g,digit=>'۰۱۲۳۴۵۶۷۸۹'[digit]);
function threeDigits(value){
const parts=[];
if(value>=100)parts.push(hundreds[Math.floor(value/100)]);
const rest=value%100;
if(rest && rest<20)parts.push(small[rest]);
if(rest>=20){parts.push(tens[Math.floor(rest/10)]);if(rest%10)parts.push(small[rest%10]);}
return parts.join(' و ');
}
function numberWords(value){
if(value===0n)return 'صفر';
const parts=[];let index=0;
while(value>0n){
const group=Number(value%1000n);
if(group){const text=group===1 && index===1?'هزار':threeDigits(group)+(scales[index]?' '+scales[index]:'');parts.unshift(text);}
value/=1000n;index++;
}
return parts.join(' و ');
}
function tomanWords(rials){
const whole=rials/10n,fraction=rials%10n;
if(!fraction)return numberWords(whole)+' تومان';
return (whole?numberWords(whole)+' و ':'')+numberWords(fraction)+' دهم تومان';
}
function caretAfterDigits(value,count){
if(!count)return 0;
for(let index=0;index<value.length;index++)if(/\d/.test(ascii(value[index])) && --count===0)return index+1;
return value.length;
}
function formatAmount(event){
if(event?.isComposing)return;
const raw=input.value,start=input.selectionStart ?? raw.length,end=input.selectionEnd ?? start;
const normalized=ascii(raw).trim();
const valid=/^\d+$/.test(normalized) || /^\d{1,3}(?:[,٬ \u00a0]\d{3})+$/.test(normalized);
// Re-group after genuine digit edits/deletion, but never sanitize letters, signs or decimal input.
const editing=event?.inputType?.startsWith('delete') || (event?.inputType==='insertText' && /^\d+$/.test(ascii(event.data || '')));
if(!valid && !(editing && /^[\d,٬ \u00a0]+$/.test(normalized))){if(words)words.textContent='';return;}
const digits=normalized.replace(separators,'').replace(/^0+(?=\d)/,'');
if(!digits || digits.length>16 || BigInt(digits)>BigInt(Number.MAX_SAFE_INTEGER)){if(words)words.textContent='';return;}
const leading=normalized.replace(separators,'').length-digits.length;
const left=Math.max(0,digitCount(raw.slice(0,start))-leading),right=Math.max(0,digitCount(raw.slice(0,end))-leading);
input.value=grouped(digits);
input.setSelectionRange(caretAfterDigits(input.value,left),caretAfterDigits(input.value,right));
if(words)words.textContent='معادل: '+tomanWords(BigInt(digits));
}
input.addEventListener('input',formatAmount);
input.addEventListener('compositionend',()=>formatAmount());
input.addEventListener('beforeinput',event=>{
const start=input.selectionStart,end=input.selectionEnd;
if(start!==end || event.isComposing)return;
const backward=event.inputType==='deleteContentBackward',forward=event.inputType==='deleteContentForward';
const adjacent=backward?start-1:start;
if((!backward && !forward) || !/[٬,]/.test(input.value[adjacent] || ''))return;
// Deleting a separator removes the adjacent digit, so grouping cannot trap the caret.
const target=backward?adjacent-1:adjacent+1;
const count=digitCount(input.value.slice(0,target));
const raw=input.value.slice(0,target)+input.value.slice(target+1);
event.preventDefault();input.value=ascii(raw).replace(separators,'');
input.setSelectionRange(count,count);input.dispatchEvent(new InputEvent('input',{bubbles:true,inputType:event.inputType}));
});
input.addEventListener('blur',()=>formatAmount());
formatAmount();
})();
+70
View File
@@ -0,0 +1,70 @@
/* Server remains authoritative. Display Jalali, submit an ASCII Gregorian date-only.
No dates, bank details or receipts are placed in localStorage/service-worker caches. */
(() => {
'use strict';
const form=document.querySelector('#receipt-form');if(!form)return;
const q=name=>form.elements.namedItem(name),button=document.querySelector('#submit-receipt');
const notices=JSON.parse(document.querySelector('#notice-data').textContent);
const day=q('paid_day'),month=q('paid_month'),year=q('paid_year');
const digits=value=>String(value).replace(/[۰-۹]/g,x=>String(x.charCodeAt(0)-1776)).replace(/[٠-٩]/g,x=>String(x.charCodeAt(0)-1632));
const formatter=new Intl.DateTimeFormat('en-US',{calendar:'persian',numberingSystem:'latn',timeZone:'Asia/Tehran',year:'numeric',month:'numeric',day:'numeric'});
const parts=date=>Object.fromEntries(formatter.formatToParts(date).map(p=>[p.type,p.value]));
function newYear(y){for(let d=18;d<=24;d++){const date=new Date(Date.UTC(y+621,2,d,12)),p=parts(date);if(Number(p.year)===y && Number(p.month)===1 && Number(p.day)===1)return date;}throw Error('تاریخ معتبر نیست.');}
function dateValue(){
const output=document.querySelector('#paid-date-iso');output.value='';
if(!day.value || !month.value || !year.value)return '';
try{const y=Number(year.value),m=Number(month.value),d=Number(day.value),offset=m<=6?(m-1)*31:186+(m-7)*30;const date=new Date(newYear(y).getTime()+(offset+d-1)*86400000),p=parts(date);if(Number(p.year)!==y || Number(p.month)!==m || Number(p.day)!==d)return '';output.value=date.toISOString().slice(0,10);return output.value;}catch{return '';}
}
function dateUpdate(){const iso=dateValue();document.querySelector('#date-preview').textContent=iso?'تاریخ انتخاب‌شده: '+day.options[day.selectedIndex].text+' '+month.options[month.selectedIndex].text+' '+year.value:'روز، ماه و سال پرداخت را کامل انتخاب کنید.';}
function monthUpdate(){
let length=31;
if(month.value && year.value){const m=Number(month.value),y=Number(year.value);length=m<=6?31:m<=11?30:(newYear(y+1)-newYear(y))/86400000===366?30:29;}
[...day.options].forEach(option=>{option.disabled=Number(option.value)>length;});if(Number(day.value)>length)day.value='';dateUpdate();
}
[month,year].forEach(control=>control.addEventListener('change',monthUpdate));day.addEventListener('change',dateUpdate);
document.querySelector('#date-today').addEventListener('click',()=>{const p=parts(new Date());year.value=p.year;month.value=p.month;monthUpdate();day.value=p.day;dateUpdate();day.focus();});monthUpdate();
function sync(){
const linked=q('receipt_mode').value==='linked';const select=q('notice_id');form.querySelector('[data-field=notice_id]').hidden=!linked;select.disabled=!linked;select.required=linked;
if(!linked)select.value='';
const notice=linked?notices.find(n=>n.id===select.value):null;const summary=document.querySelector('#selected-payment');summary.hidden=!notice;
const summaryText=notice?'مبلغ اعلام‌شده: '+new Intl.NumberFormat('fa-IR').format(BigInt(notice.amount))+' ریال · شناسه واریز: '+(notice.deposit || 'اعلام نشده')+(notice.invoice?' · فاکتور: '+notice.invoice:''):'';
const title=summary.querySelector('[data-selected-notice-title]');
if(title){title.textContent=notice?notice.subject:'';summary.querySelector('[data-selected-notice-summary]').textContent=summaryText;summary.querySelector('[data-selected-notice-link]').href=notice?notice.url:'#';}else summary.textContent=summaryText;
const finalSummary=document.querySelector('#receipt-summary');if(finalSummary)finalSummary.textContent=notice?'این فیش برای «'+notice.subject+'» ارسال می‌شود.':linked?'اعلامیه مرتبط را انتخاب کنید.':'این فیش به صورت واریز مستقل برای بررسی ارسال می‌شود.';
const depositWarning=summary.querySelector('[data-selected-deposit-warning]');
depositWarning.hidden=!notice?.deposit;
q('deposit_id').classList.toggle('pay-deposit-emphasis',Boolean(notice?.deposit));
q('deposit_id').required=false;
}
form.querySelectorAll('[name=receipt_mode]').forEach(control=>control.addEventListener('change',sync));q('notice_id').addEventListener('change',()=>{const notice=notices.find(n=>n.id===q('notice_id').value);if(notice){q('destination_account').value=String(notice.account);q('deposit_id').value=notice.deposit;}sync();});q('destination_account').addEventListener('change',sync);sync();
const fields=[...form.querySelectorAll('[data-field]')];
fields.forEach(field=>{const controls=field.querySelectorAll('input,select,textarea');controls.forEach(control=>{control.setAttribute('aria-describedby','error-'+field.dataset.field);if(field.querySelector('.field-error').textContent.trim())control.setAttribute('aria-invalid','true');});});
let busy=false;
function showErrors(errors){
fields.forEach(field=>{const name=field.dataset.field,message=errors.get(name)||'',error=field.querySelector('.field-error');error.textContent=message;error.hidden=!message;field.querySelectorAll('input,select,textarea').forEach(control=>control.setAttribute('aria-invalid',String(Boolean(message))));});
const summary=document.querySelector('#receipt-errors'),list=summary.querySelector('ul');list.replaceChildren();errors.forEach((message,name)=>{const item=document.createElement('li'),link=document.createElement('a');link.href='#id_'+name;link.textContent=message;link.onclick=event=>{event.preventDefault();q(name).focus();q(name).scrollIntoView({block:'center'});};item.append(link);list.append(item);});summary.hidden=!errors.size;
if(errors.size){const first=q(errors.keys().next().value);first.focus();first.scrollIntoView({block:'center'});}
}
async function validateImage(file){
if(!file)return 'تصویر فیش واریز را انتخاب کنید.';
if(!file.size || file.size>Number(form.dataset.maxBytes))return 'حجم تصویر بیش از حد مجاز است یا تصویر خالی است.';
const extension=file.name.split('.').pop().toLowerCase();if(!['jpg','jpeg','png','gif','webp'].includes(extension))return 'فقط تصویر JPG، PNG، GIF یا WEBP انتخاب کنید.';
const readable=await new Promise(resolve=>{const picture=new Image(),url=URL.createObjectURL(file);picture.onload=()=>{URL.revokeObjectURL(url);resolve(picture.naturalWidth*picture.naturalHeight<=25000000);};picture.onerror=()=>{URL.revokeObjectURL(url);resolve(false);};picture.src=url;});return readable?'':'تصویر باز نمی‌شود یا ابعاد آن بیش از حد مجاز است.';
}
let revision=0;form.addEventListener('input',()=>revision++);form.addEventListener('change',()=>revision++);
form.addEventListener('submit',async event=>{
event.preventDefault();if(busy)return;busy=true;button.disabled=true;const version=revision,errors=new Map();
try{
const value=digits(q('paid_amount').value).trim();if(!/^\d+$/.test(value) && !/^\d{1,3}(?:[,٬ ]\d{3})+$/.test(value))errors.set('paid_amount','مبلغ را به ریال و با عدد صحیح وارد کنید.');else{const amount=BigInt(value.replace(/[,٬ ]/g,''));if(amount<=0n || amount>9007199254740991n)errors.set('paid_amount','مبلغ باید مثبت و در محدوده مجاز باشد.');}
if(q('receipt_mode').value==='linked' && !q('notice_id').value)errors.set('notice_id','اعلامیه را انتخاب کنید یا واریز مستقل را بزنید.');
if(!q('destination_account').value)errors.set('destination_account','حساب مقصد را انتخاب کنید.');
const iso=dateValue();if(!iso)errors.set('paid_day','تاریخ را کامل و معتبر انتخاب کنید؛ روز، ماه و سال لازم‌اند.');
else{const now=Object.fromEntries(new Intl.DateTimeFormat('en-US',{timeZone:'Asia/Tehran',year:'numeric',month:'2-digit',day:'2-digit'}).formatToParts(new Date()).map(p=>[p.type,p.value]));if(iso>[now.year,now.month,now.day].join('-'))errors.set('paid_day','تاریخ واریز نمی‌تواند بعد از امروز باشد.');}
const imageError=await validateImage(q('receipt_image').files[0]);if(imageError)errors.set('receipt_image',imageError);
if(version!==revision){document.querySelector('#form-status').textContent='اطلاعات تغییر کرده است؛ دوباره ارسال را بزنید.';return;}
showErrors(errors);if(errors.size)return;
button.textContent='در حال ثبت…';HTMLFormElement.prototype.submit.call(form);return;
}finally{busy=false;if(button.textContent!=='در حال ثبت…')button.disabled=false;}
});
if(document.querySelector('#receipt-errors').hidden===false){const field=form.querySelector('[aria-invalid=true]');if(field){field.focus();field.scrollIntoView({block:'center'});}}
})();
+21
View File
@@ -0,0 +1,21 @@
const confirmationForm = document.querySelector('[data-magic-confirmation]');
if (confirmationForm) {
confirmationForm.addEventListener('submit', (event) => {
const button = confirmationForm.querySelector('button[type="submit"]');
if (button.disabled) {
event.preventDefault();
return;
}
button.disabled = true;
button.setAttribute('aria-busy', 'true');
button.textContent = 'در حال باز کردن تیکت…';
confirmationForm.querySelector('[role="status"]').textContent = button.textContent;
});
window.addEventListener('pageshow', () => {
const button = confirmationForm.querySelector('button[type="submit"]');
button.disabled = false;
button.removeAttribute('aria-busy');
button.textContent = 'خروج از حساب و مشاهده تیکت';
confirmationForm.querySelector('[role="status"]').textContent = '';
});
}
+16
View File
@@ -0,0 +1,16 @@
(() => {
const events = document.querySelector('#id_event_type');
const tags = document.querySelector('#sms-allowed-tags');
const data = document.querySelector('#sms-tag-map');
if (!events || !tags || !data) return;
const mapping = JSON.parse(data.textContent);
function showAllowedTags() {
tags.replaceChildren(...(mapping[events.value] || []).map(tag => {
const code = document.createElement('code');
code.textContent = '{' + tag + '}';
return code;
}));
}
events.addEventListener('change', showAllowedTags);
showAllowedTags();
})();
@@ -0,0 +1,30 @@
{% extends "base.html" %}
{% load static %}
{% block title %}این لینک مربوط به حساب دیگری است | {{ site_branding.site_title }}{% endblock %}
{% block extra_head %}
<meta name="referrer" content="same-origin">
<meta name="robots" content="noindex, nofollow">
<link rel="stylesheet" href="{% static 'css/magic-link-confirmation.css' %}?v={{ static_asset_version }}">
<script defer src="{% static 'js/magic-link-confirmation.js' %}?v={{ static_asset_version }}"></script>
{% endblock %}
{% block body %}
<main class="magic-confirmation magic-confirmation-shell">
<div class="magic-confirmation-brand">{% include 'includes/auth_brand.html' with hide_copy=True %}</div>
<section class="magic-confirmation-card" aria-labelledby="confirmation-title" aria-describedby="confirmation-description">
<div class="magic-confirmation-icon" aria-hidden="true">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><path d="M10 17l5-5-5-5M15 12H3M14 3h5a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2h-5"/></svg>
</div>
<h1 id="confirmation-title">این لینک مربوط به حساب دیگری است</h1>
<p id="confirmation-description">شما با حساب دیگری وارد سامانه شده‌اید. برای مشاهده تیکت مرتبط با این لینک، از حساب فعلی خارج شوید. پس از خروج، تیکت به‌صورت خودکار باز می‌شود و نیازی به ورود مجدد ندارید.</p>
<form method="post" action="{{ request.path }}" class="magic-confirmation-actions" data-magic-confirmation>
{% csrf_token %}
<input type="hidden" name="action" value="logout_and_open">
<input type="hidden" name="account_id" value="{{ user.pk }}">
<button type="submit" class="magic-confirmation-primary">خروج از حساب و مشاهده تیکت</button>
<a class="magic-confirmation-cancel" href="{% url 'accounts:home' %}">انصراف از مشاهده این تیکت/پیام</a>
<span class="magic-confirmation-status" role="status" aria-live="polite"></span>
</form>
</section>
{% include 'includes/auth_footer.html' %}
</main>
{% endblock %}
+11 -4
View File
@@ -13,8 +13,10 @@
<link rel="stylesheet" href="{% static 'css/operations.css' %}?v={{ static_asset_version }}"> <link rel="stylesheet" href="{% static 'css/operations.css' %}?v={{ static_asset_version }}">
<link rel="stylesheet" href="{% static 'css/dispatches.css' %}?v={{ static_asset_version }}"> <link rel="stylesheet" href="{% static 'css/dispatches.css' %}?v={{ static_asset_version }}">
<script defer src="{% static 'vendor/htmx-2.0.3.min.js' %}?v={{ static_asset_version }}"></script><script defer src="{% static 'vendor/alpine-3.14.8.min.js' %}?v={{ static_asset_version }}"></script> <script defer src="{% static 'vendor/htmx-2.0.3.min.js' %}?v={{ static_asset_version }}"></script><script defer src="{% static 'vendor/alpine-3.14.8.min.js' %}?v={{ static_asset_version }}"></script>
{% block extra_head %}{% endblock %}
</head> </head>
<body> <body>
{% block body %}
{% if user.is_authenticated and user.account_type == 'STAFF' %} {% if user.is_authenticated and user.account_type == 'STAFF' %}
<div class="staff-shell"> <div class="staff-shell">
<aside class="staff-sidebar"> <aside class="staff-sidebar">
@@ -33,6 +35,8 @@
{% if staff_operations_access.contacts %}<a class="{% if active_staff_section == 'contacts' %}active{% endif %}" href="{% url 'staff_tickets:contacts' %}" {% if active_staff_section == 'contacts' %}aria-current="page"{% endif %}><span>♧</span><b>مخاطبان</b></a>{% endif %} {% if staff_operations_access.contacts %}<a class="{% if active_staff_section == 'contacts' %}active{% endif %}" href="{% url 'staff_tickets:contacts' %}" {% if active_staff_section == 'contacts' %}aria-current="page"{% endif %}><span>♧</span><b>مخاطبان</b></a>{% endif %}
{% if staff_operations_access.dispatches %}<a class="{% if active_staff_section == 'dispatches' %}active{% endif %}" href="{% url 'staff_tickets:dispatch_create' %}" {% if active_staff_section == 'dispatches' %}aria-current="page"{% endif %}><span>⇶</span><b>ارسال گروهی</b></a>{% endif %} {% if staff_operations_access.dispatches %}<a class="{% if active_staff_section == 'dispatches' %}active{% endif %}" href="{% url 'staff_tickets:dispatch_create' %}" {% if active_staff_section == 'dispatches' %}aria-current="page"{% endif %}><span>⇶</span><b>ارسال گروهی</b></a>{% endif %}
{% if staff_operations_access.audit %}<a class="{% if active_staff_section == 'audit' %}active{% endif %}" href="{% url 'staff_tickets:magic_audit' %}" {% if active_staff_section == 'audit' %}aria-current="page"{% endif %}><span>⌁</span><b>ممیزی لینک‌ها</b></a>{% endif %} {% if staff_operations_access.audit %}<a class="{% if active_staff_section == 'audit' %}active{% endif %}" href="{% url 'staff_tickets:magic_audit' %}" {% if active_staff_section == 'audit' %}aria-current="page"{% endif %}><span>⌁</span><b>ممیزی لینک‌ها</b></a>{% endif %}
{% if payment_can_view %}<a class="{% if active_staff_section == 'payments' %}active{% endif %}" href="{% url 'staff_payments:desk' %}" {% if active_staff_section == 'payments' %}aria-current="page"{% endif %}><svg aria-hidden="true" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><path d="M6 3h12v18l-3-2-3 2-3-2-3 2V3Zm3 5h6m-6 4h6m-6 4h3"/></svg><b>اعلامیه واریز</b><em data-payment-count data-count-url="{% url 'staff_payments:count' %}" aria-label="فیش‌های در انتظار بررسی">{{ payment_pending_count }}</em></a>{% endif %}
{% if payment_can_manage_banks %}<a class="{% if active_staff_section == 'banks' %}active{% endif %}" href="{% url 'staff_payments:banks' %}" {% if active_staff_section == 'banks' %}aria-current="page"{% endif %}><svg aria-hidden="true" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><path d="m3 8 9-5 9 5H3Zm3 3v7m6-7v7m6-7v7M3 21h18"/></svg><b>بانک‌ها</b></a>{% endif %}
</nav> </nav>
</div> </div>
<div><div class="staff-profile"><span class="staff-profile-avatar">{{ user.staff_profile.display_name|default:user.username|slice:':1' }}</span><div><strong>{{ user.staff_profile.display_name|default:user.username }}</strong><span>{{ user.staff_profile.job_title|default:'کارشناس فروش' }}</span></div></div><div class="staff-developer-credit">{{ staff_sidebar_credit }}</div></div> <div><div class="staff-profile"><span class="staff-profile-avatar">{{ user.staff_profile.display_name|default:user.username|slice:':1' }}</span><div><strong>{{ user.staff_profile.display_name|default:user.username }}</strong><span>{{ user.staff_profile.job_title|default:'کارشناس فروش' }}</span></div></div><div class="staff-developer-credit">{{ staff_sidebar_credit }}</div></div>
@@ -40,7 +44,7 @@
<div class="staff-main"> <div class="staff-main">
<header class="staff-topbar"><div><small>{{ site_branding.staff_panel_label }}</small><h2>{% if active_tab_title %}{{ active_tab_title }}{% else %}{{ site_branding.staff_brand_subtitle }}{% endif %}</h2></div><div class="staff-topbar-actions"><span class="hidden text-xs text-ink/40 sm:block">{{ user.username }}</span><form method="post" action="{% url 'accounts:staff_logout' %}">{% csrf_token %}<button class="logout-button" type="submit">خروج</button></form></div></header> <header class="staff-topbar"><div><small>{{ site_branding.staff_panel_label }}</small><h2>{% if active_tab_title %}{{ active_tab_title }}{% else %}{{ site_branding.staff_brand_subtitle }}{% endif %}</h2></div><div class="staff-topbar-actions"><span class="hidden text-xs text-ink/40 sm:block">{{ user.username }}</span><form method="post" action="{% url 'accounts:staff_logout' %}">{% csrf_token %}<button class="logout-button" type="submit">خروج</button></form></div></header>
{% else %} {% else %}
<header class="site-header"><div class="header-inner"><a class="logo" href="{% if user.is_authenticated %}{% url 'tickets:list' %}{% else %}{% url 'accounts:login' %}{% endif %}">{% block header_logo %}<img class="header-company-logo" src="{{ company_logo_url }}?v={{ static_asset_version }}-{{ branding_version }}" alt="لوگوی شرکت" width="300" height="130">{% endblock %}<span class="logo-copy">{{ site_branding.header_title }}<small>{{ site_branding.header_subtitle }}</small></span></a>{% if user.is_authenticated %}<nav class="desktop-nav"><a href="{% url 'tickets:list' %}">درخواست‌های من</a><a href="{% url 'accounts:profile' %}">پروفایل</a><form method="post" action="{% url 'accounts:logout' %}">{% csrf_token %}<button class="logout-button" type="submit">خروج</button></form></nav>{% endif %}</div></header> {% block customer_header %}<header class="site-header"><div class="header-inner"><a class="logo" href="{% if user.is_authenticated %}{% url 'payments:home' %}{% else %}{% url 'accounts:login' %}{% endif %}">{% block header_logo %}<img class="header-company-logo" src="{{ company_logo_url }}?v={{ static_asset_version }}-{{ branding_version }}" alt="لوگوی شرکت" width="300" height="130">{% endblock %}<span class="logo-copy">{{ site_branding.header_title }}<small>{{ site_branding.header_subtitle }}</small></span></a>{% if user.is_authenticated %}<nav class="desktop-nav"><a href="{% url 'tickets:list' %}">درخواست‌های من</a><a href="{% url 'accounts:profile' %}">پروفایل</a><form method="post" action="{% url 'accounts:logout' %}">{% csrf_token %}<button class="logout-button" type="submit">خروج</button></form></nav>{% endif %}</div></header>{% endblock %}
{% endif %} {% endif %}
<main class="{% if user.is_authenticated and user.account_type == 'STAFF' %}staff-content{% else %}customer-main{% endif %}">{% for message in messages %}<div class="flash {{ message.tags }}" role="status">{{ message }}</div>{% endfor %}{% block content %}{% endblock %}</main> <main class="{% if user.is_authenticated and user.account_type == 'STAFF' %}staff-content{% else %}customer-main{% endif %}">{% for message in messages %}<div class="flash {{ message.tags }}" role="status">{{ message }}</div>{% endfor %}{% block content %}{% endblock %}</main>
@@ -48,7 +52,7 @@
{% if user.is_authenticated and user.account_type == 'STAFF' %} {% if user.is_authenticated and user.account_type == 'STAFF' %}
</div> </div>
<nav class="staff-mobile-dock" aria-label="دسترسی سریع کارشناسان"> {% block staff_mobile_navigation %}<nav class="staff-mobile-dock" aria-label="دسترسی سریع کارشناسان">
<a class="{% if request.resolver_match.url_name == 'list' and active_tab == '' %}active{% endif %}" href="{% url 'staff_tickets:list' %}" {% if request.resolver_match.url_name == 'list' and active_tab == '' %}aria-current="page"{% endif %}><span class="dock-icon">⌂</span>میز کار</a> <a class="{% if request.resolver_match.url_name == 'list' and active_tab == '' %}active{% endif %}" href="{% url 'staff_tickets:list' %}" {% if request.resolver_match.url_name == 'list' and active_tab == '' %}aria-current="page"{% endif %}><span class="dock-icon">⌂</span>میز کار</a>
<a class="{% if request.resolver_match.url_name == 'list' and active_tab == 'new' %}active{% endif %}" href="{% url 'staff_tickets:list' %}?tab=new" {% if request.resolver_match.url_name == 'list' and active_tab == 'new' %}aria-current="page"{% endif %}><span class="dock-icon">◌</span>صف جدید</a> <a class="{% if request.resolver_match.url_name == 'list' and active_tab == 'new' %}active{% endif %}" href="{% url 'staff_tickets:list' %}?tab=new" {% if request.resolver_match.url_name == 'list' and active_tab == 'new' %}aria-current="page"{% endif %}><span class="dock-icon">◌</span>صف جدید</a>
<a class="{% if request.resolver_match.url_name == 'list' and active_tab == 'waiting_staff' %}active{% endif %}" href="{% url 'staff_tickets:list' %}?tab=waiting_staff" {% if request.resolver_match.url_name == 'list' and active_tab == 'waiting_staff' %}aria-current="page"{% endif %}><span class="dock-icon">↺</span>پاسخ من</a> <a class="{% if request.resolver_match.url_name == 'list' and active_tab == 'waiting_staff' %}active{% endif %}" href="{% url 'staff_tickets:list' %}?tab=waiting_staff" {% if request.resolver_match.url_name == 'list' and active_tab == 'waiting_staff' %}aria-current="page"{% endif %}><span class="dock-icon">↺</span>پاسخ من</a>
@@ -56,10 +60,12 @@
<a class="{% if request.resolver_match.url_name == 'list' and active_tab == 'shared' %}active{% endif %}" href="{% url 'staff_tickets:list' %}?tab=shared" {% if request.resolver_match.url_name == 'list' and active_tab == 'shared' %}aria-current="page"{% endif %}><span class="dock-icon">◇</span>اشتراک‌ها<em data-share-unread-count data-count-url="{% url 'staff_tickets:shares_count' %}"{% if not staff_unread_shares %} hidden{% endif %}>{{ staff_unread_shares }}</em></a> <a class="{% if request.resolver_match.url_name == 'list' and active_tab == 'shared' %}active{% endif %}" href="{% url 'staff_tickets:list' %}?tab=shared" {% if request.resolver_match.url_name == 'list' and active_tab == 'shared' %}aria-current="page"{% endif %}><span class="dock-icon">◇</span>اشتراک‌ها<em data-share-unread-count data-count-url="{% url 'staff_tickets:shares_count' %}"{% if not staff_unread_shares %} hidden{% endif %}>{{ staff_unread_shares }}</em></a>
{% if staff_operations_access.contacts %}<a class="{% if active_staff_section == 'contacts' %}active{% endif %}" href="{% url 'staff_tickets:contacts' %}" {% if active_staff_section == 'contacts' %}aria-current="page"{% endif %}><span class="dock-icon">♧</span>مخاطبان</a>{% endif %} {% if staff_operations_access.contacts %}<a class="{% if active_staff_section == 'contacts' %}active{% endif %}" href="{% url 'staff_tickets:contacts' %}" {% if active_staff_section == 'contacts' %}aria-current="page"{% endif %}><span class="dock-icon">♧</span>مخاطبان</a>{% endif %}
{% if staff_operations_access.dispatches %}<a class="{% if active_staff_section == 'dispatches' %}active{% endif %}" href="{% url 'staff_tickets:dispatch_create' %}" {% if active_staff_section == 'dispatches' %}aria-current="page"{% endif %}><span class="dock-icon">⇶</span>گروهی</a>{% endif %} {% if staff_operations_access.dispatches %}<a class="{% if active_staff_section == 'dispatches' %}active{% endif %}" href="{% url 'staff_tickets:dispatch_create' %}" {% if active_staff_section == 'dispatches' %}aria-current="page"{% endif %}><span class="dock-icon">⇶</span>گروهی</a>{% endif %}
</nav> {% if payment_can_view %}<a class="{% if active_staff_section == 'payments' %}active{% endif %}" href="{% url 'staff_payments:desk' %}" {% if active_staff_section == 'payments' %}aria-current="page"{% endif %}><svg aria-hidden="true" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><path d="M6 3h12v18l-3-2-3 2-3-2-3 2V3Zm3 5h6m-6 4h6m-6 4h3"/></svg><b>اعلامیه واریز</b><em data-payment-count data-count-url="{% url 'staff_payments:count' %}" aria-label="فیش‌های در انتظار بررسی">{{ payment_pending_count }}</em></a>{% endif %}
{% if payment_can_manage_banks %}<a class="{% if active_staff_section == 'banks' %}active{% endif %}" href="{% url 'staff_payments:banks' %}" {% if active_staff_section == 'banks' %}aria-current="page"{% endif %}><svg aria-hidden="true" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><path d="m3 8 9-5 9 5H3Zm3 3v7m6-7v7m6-7v7M3 21h18"/></svg><b>بانک‌ها</b></a>{% endif %}
</nav>{% endblock %}
</div> </div>
{% elif user.is_authenticated %} {% elif user.is_authenticated %}
<nav class="mobile-dock customer-mobile-dock" aria-label="دسترسی سریع مشتری"><a class="{% if request.resolver_match.namespace == 'tickets' and request.resolver_match.url_name != 'new' %}active{% endif %}" href="{% url 'tickets:list' %}" {% if request.resolver_match.namespace == 'tickets' and request.resolver_match.url_name != 'new' %}aria-current="page"{% endif %}><span class="dock-icon">◎</span>درخواست‌ها</a><a class="{% if request.resolver_match.url_name == 'new' %}active{% endif %}" href="{% url 'tickets:new' %}" {% if request.resolver_match.url_name == 'new' %}aria-current="page"{% endif %}><span class="dock-icon">+</span>درخواست جدید</a><a class="{% if request.resolver_match.namespace == 'accounts' and request.resolver_match.url_name == 'profile' %}active{% endif %}" href="{% url 'accounts:profile' %}" {% if request.resolver_match.namespace == 'accounts' and request.resolver_match.url_name == 'profile' %}aria-current="page"{% endif %}><span class="dock-icon">○</span>پروفایل</a><form method="post" action="{% url 'accounts:logout' %}">{% csrf_token %}<button type="submit"><span class="dock-icon">←</span>خروج</button></form></nav> {% if financial_page %}<nav class="mobile-dock customer-mobile-dock chaharsu-dock" aria-label="دسترسی سریع مشتری"><a href="{% url 'payments:home' %}" {% if request.resolver_match.url_name == 'home' %}class="active" aria-current="page"{% endif %}><svg aria-hidden="true" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><path d="M3 3h7v7H3V3Zm11 0h7v7h-7V3ZM3 14h7v7H3v-7Zm11 0h7v7h-7v-7Z"/></svg>خدمات</a><a href="{% url 'tickets:list' %}"><svg aria-hidden="true" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><path d="M4 5h16v12H9l-5 4V5Zm4 4h8m-8 4h5"/></svg>درخواست‌ها</a><a href="{% url 'accounts:profile' %}"><svg aria-hidden="true" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><path d="M16 7a4 4 0 1 1-8 0 4 4 0 0 1 8 0ZM4 21v-2a8 8 0 0 1 16 0v2"/></svg>پروفایل</a></nav>{% else %} <nav class="mobile-dock customer-mobile-dock" aria-label="دسترسی سریع مشتری"><a class="{% if request.resolver_match.namespace == 'tickets' and request.resolver_match.url_name != 'new' %}active{% endif %}" href="{% url 'tickets:list' %}" {% if request.resolver_match.namespace == 'tickets' and request.resolver_match.url_name != 'new' %}aria-current="page"{% endif %}><span class="dock-icon">◎</span>درخواست‌ها</a><a class="{% if request.resolver_match.url_name == 'new' %}active{% endif %}" href="{% url 'tickets:new' %}" {% if request.resolver_match.url_name == 'new' %}aria-current="page"{% endif %}><span class="dock-icon">+</span>درخواست جدید</a><a class="{% if request.resolver_match.namespace == 'accounts' and request.resolver_match.url_name == 'profile' %}active{% endif %}" href="{% url 'accounts:profile' %}" {% if request.resolver_match.namespace == 'accounts' and request.resolver_match.url_name == 'profile' %}aria-current="page"{% endif %}><span class="dock-icon">○</span>پروفایل</a><form method="post" action="{% url 'accounts:logout' %}">{% csrf_token %}<button type="submit"><span class="dock-icon">←</span>خروج</button></form></nav>{% endif %}
{% endif %} {% endif %}
{% block pwa_install_prompt %}{% endblock %} {% block pwa_install_prompt %}{% endblock %}
<script src="{% static 'js/voice-recorder.js' %}?v={{ static_asset_version }}"></script><script src="{% static 'js/ui.js' %}?v={{ static_asset_version }}"></script><script src="{% static 'js/chat-coach.js' %}?v={{ static_asset_version }}"></script> <script src="{% static 'js/voice-recorder.js' %}?v={{ static_asset_version }}"></script><script src="{% static 'js/ui.js' %}?v={{ static_asset_version }}"></script><script src="{% static 'js/chat-coach.js' %}?v={{ static_asset_version }}"></script>
@@ -67,4 +73,5 @@
{% if user.is_authenticated and user.account_type == 'STAFF' %}<link rel="stylesheet" href="{% static 'css/staff-sharing.css' %}?v={{ static_asset_version }}"><script defer src="{% static 'js/staff-sharing.js' %}?v={{ static_asset_version }}"></script>{% endif %} {% if user.is_authenticated and user.account_type == 'STAFF' %}<link rel="stylesheet" href="{% static 'css/staff-sharing.css' %}?v={{ static_asset_version }}"><script defer src="{% static 'js/staff-sharing.js' %}?v={{ static_asset_version }}"></script>{% endif %}
{% block extra_scripts %}{% endblock %} {% block extra_scripts %}{% endblock %}
{% block otp_script %}{% endblock %} {% block otp_script %}{% endblock %}
{% endblock %}
</body></html> </body></html>
@@ -0,0 +1,4 @@
<div class="pay-customer-summary">
<strong>{% if customer.customer_profile.first_name or customer.customer_profile.last_name %}{{ customer.customer_profile.first_name }} {{ customer.customer_profile.last_name }}{% else %}{{ customer.get_full_name|default:'نام ثبت نشده' }}{% endif %}</strong>
<span><bdi>{{ customer.local_mobile }}</bdi>{% if customer.customer_profile.province %} · {{ customer.customer_profile.province }}{% endif %}{% if customer.customer_profile.city %} · {{ customer.customer_profile.city }}{% endif %}</span>
</div>
+12
View File
@@ -0,0 +1,12 @@
<section class="pay-customer-picker pay-field-wide" data-payment-customer-picker data-search-url="{% url 'staff_payments:customer_search' %}" aria-label="انتخاب مشتری">
{{ form.customer }}
<label for="payment-customer-search">جست‌وجو و انتخاب مشتری <span class="pay-required">الزامی</span></label>
<div class="pay-search-input">{% include 'payments/_icon.html' with name='search' %}<input id="payment-customer-search" type="search" autocomplete="off" placeholder="نام، موبایل، سازمان یا شماره و موضوع تیکت" aria-describedby="payment-customer-help payment-customer-status"></div>
<p id="payment-customer-help" class="pay-field-hint">نام و شماره تماس را تطبیق دهید؛ هیچ نتیجه‌ای خودکار انتخاب نمی‌شود.</p>
{% if form.customer.errors %}<div class="field-error" role="alert">{{ form.customer.errors }}</div>{% endif %}
<p id="payment-customer-status" class="pay-field-hint" role="status" aria-live="polite">برای جست‌وجو حداقل دو حرف یا رقم وارد کنید.</p>
<div class="pay-customer-results" data-customer-results aria-label="نتایج جست‌وجوی مشتری" hidden></div>
<div class="pay-selected-customer" data-selected-customer hidden></div>
{{ customer_selection|json_script:'payment-selected-customer' }}
<noscript>{% if customer_selection %}<p class="pay-inline-note">مشتری انتخاب‌شده: {{ customer_selection.name }} · <bdi>{{ customer_selection.mobile }}</bdi></p>{% else %}<p class="pay-inline-note">برای جست‌وجوی مشتری JavaScript مرورگر را فعال کنید. می‌توانید از صفحه مخاطبان یا تیکت مشتری، فرم اعلامیه را با مشتری انتخاب‌شده باز کنید.</p>{% endif %}</noscript>
</section>
@@ -0,0 +1,39 @@
<section class="chaharsu customer-finance customer-receipt {% if staff_form %}pay-workspace pay-form-workspace staff-receipt-workspace{% endif %}">
{% include 'payments/_link_status.html' %}
<nav class="pay-breadcrumb" aria-label="مسیر صفحه">{% if staff_form %}<a href="{% url 'staff_payments:desk' %}">اعلامیه‌ها و فیش‌ها</a>{% elif payment_link_access.notice_id %}<a href="{% url 'payments:notice_detail' payment_link_access.notice.public_id %}">اعلامیه واریز</a>{% elif not payment_link_access %}<a href="{% url 'payments:home' %}">خدمات چهارسو</a>{% endif %}<span aria-hidden="true">/</span><span aria-current="page">ارسال فیش واریزی</span></nav>
<header class="customer-finance-title"><span class="pay-page-icon">{% include 'payments/_icon.html' with name='receipt' %}</span><div><h1>{% if staff_form %}ثبت فیش برای مشتری{% elif revision_version %}اصلاح فیش واریزی{% else %}ارسال فیش واریزی{% endif %}</h1><p>فیش را مستقل یا برای یکی از واریزهای اعلام‌شده ارسال کنید.</p>{% if staff_form %}{% include 'payments/_customer_identity.html' with customer=customer %}{% endif %}</div></header>
<form id="receipt-form" class="customer-receipt-form pay-form" method="post" enctype="multipart/form-data" data-max-bytes="{{ max_bytes }}" novalidate>
{% csrf_token %}{{ form.submission_key }}{% if revision_version %}<input type="hidden" name="version" value="{{ revision_version }}">{% endif %}
<div id="receipt-errors" class="pay-errors" role="alert" {% if not form.errors %}hidden{% endif %}><strong>لطفاً این موارد را اصلاح کنید</strong><ul>{% for field in form %}{% for error in field.errors %}<li><a href="#{{ field.id_for_label }}">{{ error }}</a></li>{% endfor %}{% endfor %}{% for error in form.non_field_errors %}<li>{{ error }}</li>{% endfor %}</ul></div>
<section class="pay-card" aria-labelledby="association-title">
<h2 id="association-title">این فیش مربوط به کدام واریز است؟</h2>
<fieldset class="pay-field receipt-mode-field" data-field="receipt_mode"><legend class="sr-only">نوع واریز</legend>
<div class="customer-receipt-modes">{% for radio in form.receipt_mode %}<div class="customer-mode {% if radio.data.value == 'linked' %}recommended-mode{% endif %}"><label for="{{ radio.id_for_label }}">{{ radio.tag }}<strong>{{ radio.choice_label }}</strong></label><small>{% if radio.data.value == 'linked' %}با اعلامیه واریز کارشناس{% else %}بدون اعلامیه واریز{% endif %}</small></div>{% endfor %}</div>
<p class="field-error" id="error-receipt_mode" {% if not form.receipt_mode.errors %}hidden{% endif %}>{% for error in form.receipt_mode.errors %}{{ error }}{% endfor %}</p>
</fieldset>
<p class="finance-guidance">{% if staff_form %}اعلامیه مشتری را انتخاب کنید تا مبلغ و حساب مقصد قابل تطبیق باشد؛ ثبت واریز مستقل نیز امکان‌پذیر است.{% else %}برای ثبت دقیق‌تر، از کارشناس بخواهید اعلامیه واریز ایجاد کند؛ این کار توصیه می‌شود و اجباری نیست.{% endif %}</p>
{% if staff_form %}{% include 'payments/_receipt_field.html' with field=form.notice_id hint='موضوع، مبلغ و شماره فاکتور را با فیش مشتری تطبیق دهید.' %}{% elif payment_link_access %}{% include 'payments/_receipt_field.html' with field=form.notice_id hint='این لینک به اعلامیه مربوط به خودش دسترسی دارد؛ ارسال واریز مستقل نیز ممکن است.' %}{% else %}{% include 'payments/_receipt_field.html' with field=form.notice_id hint='۱۰ واریز اخیرِ تسویه‌نشده؛ برای واریزهای قدیمی‌تر، از صفحه شماره حساب‌ها وارد شوید.' %}{% endif %}
<div id="selected-payment" class="pay-supplement" hidden><h3 data-selected-notice-title></h3><p data-selected-notice-summary></p><p class="pay-deposit-warning" data-selected-deposit-warning hidden>هنگام واریز، حتماً شناسه واریز ارائه‌شده را در فرم بانکی، موبایل‌بانک یا اینترنت‌بانک وارد کنید؛ واریز بدون این شناسه معتبر نخواهد بود.</p><a class="pay-text-link" data-selected-notice-link>مشاهده اطلاعات و کپی شماره‌ها {% include 'payments/_icon.html' with name='arrow' %}</a></div>
</section>
<section class="pay-card" aria-labelledby="receipt-title">
<h2 id="receipt-title">اطلاعات فیش پرداخت</h2><p class="pay-section-help">مبلغ، حساب مقصد، تاریخ کامل و تصویر فیش الزامی‌اند.</p>
<div class="customer-field-grid">
{% include 'payments/_receipt_field.html' with field=form.paid_amount hint='مبلغی که واقعاً پرداخت کرده‌اید؛ ممکن است با مبلغ اعلام‌شده متفاوت باشد.' %}
{% include 'payments/_receipt_field.html' with field=form.tracking_number hint='شماره درج‌شده روی فیش بانکی' %}
{% include 'payments/_receipt_field.html' with field=form.origin_bank %}
{% include 'payments/_receipt_field.html' with field=form.destination_account hint='نام حسابی را که وجه به آن واریز کرده‌اید انتخاب کنید.' %}
{% include 'payments/_receipt_field.html' with field=form.origin_other %}
{% include 'payments/_receipt_field.html' with field=form.deposit_id hint='در صورت تمایل، شناسه‌ای را که هنگام پرداخت وارد کرده‌اید بنویسید؛ ارسال فیش بدون تکمیل این فیلد نیز امکان‌پذیر است.' %}
</div>
<fieldset class="customer-date"><legend>تاریخ واریز <span class="pay-optional">شمسی · الزامی</span></legend><div class="customer-date-selects">{% include 'payments/_receipt_field.html' with field=form.paid_day %}{% include 'payments/_receipt_field.html' with field=form.paid_month %}{% include 'payments/_receipt_field.html' with field=form.paid_year %}</div><div class="customer-date-tools"><p class="pay-field-hint">تاریخ درج‌شده روی فیش را کامل انتخاب کنید.</p><button id="date-today" type="button" class="pay-today">امروز</button></div><p id="date-preview" aria-live="polite"></p><input id="paid-date-iso" type="hidden" name="paid_date" value=""></fieldset>
<div class="pay-field customer-upload-field" data-field="receipt_image"><div class="customer-upload-zone">{% include 'payments/_icon.html' with name='upload' %}<label class="customer-file-button" for="{{ form.receipt_image.id_for_label }}">انتخاب تصویر فیش <span>الزامی</span></label>{{ form.receipt_image }}<p>تصویر JPG، PNG، GIF یا WEBP؛ حداکثر {{ max_bytes|filesizeformat }}.</p></div><p class="field-error" id="error-receipt_image" {% if not form.receipt_image.errors %}hidden{% endif %}>{% for error in form.receipt_image.errors %}{{ error }}{% endfor %}</p><div class="customer-file-selection" id="receipt-file-selection" hidden><p id="receipt-file-status" role="status" aria-live="polite"></p><button type="button" id="receipt-clear-file">حذف انتخاب</button></div><img id="receipt-file-preview" alt="پیش‌نمایش تصویر فیش" hidden></div>
{% include 'payments/_receipt_field.html' with field=form.description %}
<p id="receipt-summary" class="finance-guidance" role="status"></p><p role="status" id="form-status"></p>
<div class="customer-receipt-actions"><button class="pay-button" id="submit-receipt" type="submit">ثبت و ارسال فیش</button>{% if staff_form %}<a class="pay-text-link" href="{% url 'staff_payments:desk' %}">انصراف و بازگشت</a>{% elif payment_link_access.notice_id %}<a class="pay-text-link" href="{% url 'payments:notice_detail' payment_link_access.notice.public_id %}">مشاهده اعلامیه واریز</a>{% elif not payment_link_access %}<a class="pay-text-link" href="{% url 'payments:notices' %}">مشاهده واریزهای اعلام‌شده {% include 'payments/_icon.html' with name='arrow' %}</a>{% endif %}</div>
</section>
</form>
{% if not staff_form %}
<div class="customer-service-footer">{% if not payment_link_access %}<a class="pay-text-link" href="{% url 'payments:home' %}">بازگشت به خدمات چهارسو</a>{% endif %}<a class="pay-text-link" href="{% url 'payments:receipt_list' %}">فیش‌های من و وضعیت بررسی</a></div>
{% endif %}
{{ notice_data|json_script:'notice-data' }}{{ account_requirements|json_script:'account-requirements' }}
</section>
+7
View File
@@ -0,0 +1,7 @@
<div class="pay-field {% if field.errors %}has-error{% endif %} {% if wide %}pay-field-wide{% endif %}">
<label for="{{ field.id_for_label }}">{{ label|default:field.label }}{% if field.field.required %}<span class="pay-required">الزامی</span>{% else %}<span class="pay-optional">اختیاری</span>{% endif %}</label>
{{ field }}
{% if hint %}<p class="pay-field-hint">{{ hint }}</p>{% endif %}
{% if field.help_text %}<p class="pay-field-hint">{{ field.help_text }}</p>{% endif %}
{% if field.errors %}<div class="field-error" role="alert">{{ field.errors }}</div>{% endif %}
</div>
+17
View File
@@ -0,0 +1,17 @@
{# Local inline SVG paths: no icon font, CDN or external request. #}
<svg class="cs-icon" aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round">
{% if name == 'chat' %}<path d="M5 4h14a2 2 0 0 1 2 2v10a2 2 0 0 1-2 2H9l-6 3V6a2 2 0 0 1 2-2Z M7 9h10M7 13h6"/>
{% elif name == 'receipt' %}<path d="M5 3h14v18l-3-2-4 2-4-2-3 2V3ZM9 8h6M9 12h6"/>
{% elif name == 'bank' %}<path d="m3 7 9-4 9 4v3H3ZM5 10v7M10 10v7M14 10v7M19 10v7M3 17h18v4H3"/>
{% elif name == 'plus' %}<path d="M12 5v14M5 12h14"/>
{% elif name == 'upload' %}<path d="M12 16V3m-5 5 5-5 5 5M4 16v5h16v-5"/>
{% elif name == 'copy' %}<rect x="8" y="8" width="12" height="13" rx="2"/><path d="M16 8V3H3v13h5"/>
{% elif name == 'chevron-down' %}<path d="m6 9 6 6 6-6"/>
{% elif name == 'history' %}<path d="M3 11a9 9 0 1 1 2 7M3 5v6h6M12 7v5l3 2"/>
{% elif name == 'search' %}<circle cx="10" cy="10" r="6"/><path d="m15 15 6 6"/>
{% elif name == 'filter' %}<path d="M4 7h16M7 12h10M10 17h4"/>
{% elif name == 'user' %}<circle cx="12" cy="8" r="4"/><path d="M4 21v-2a8 8 0 0 1 16 0v2"/>
{% elif name == 'edit' %}<path d="m15 4 5 5M4 20l5-1L21 7l-4-4L5 15l-1 5Z"/>
{% elif name == 'chevron' %}<path d="m9 5 7 7-7 7"/>
{% else %}<path d="M20 12H4m6-6-6 6 6 6"/>{% endif %}
</svg>
+2
View File
@@ -0,0 +1,2 @@
{% load jalali %}
{% if payment_link_access %}<div class="pay-link-status" role="note"><span>{% include 'payments/_icon.html' with name='receipt' %}مشاهده و ارسال فیش بدون نیاز به ورود</span><small>اعتبار دسترسی تا <time datetime="{{ payment_link_access.expires_at|date:'c' }}">{{ payment_link_access.expires_at|jalali_datetime }}</time></small></div>{% endif %}
+8
View File
@@ -0,0 +1,8 @@
{% load jalali humanize %}
<section class="pay-linked-receipts" aria-label="فیش‌های این واریز"><h3>فیش‌های این واریز</h3>
{% for linked_receipt in notice.related_receipts %}<article class="pay-linked-receipt">
<div class="pay-linked-receipt-head"><a class="pay-text-link" href="{% url 'payments:receipt_detail' linked_receipt.public_id %}"><bdi>{{ linked_receipt.number }}</bdi>{% include 'payments/_icon.html' with name='arrow' %}</a><span class="pay-status {{ linked_receipt.status|lower }}">{{ linked_receipt.get_status_display }}</span></div>
<p class="pay-linked-receipt-meta">{{ linked_receipt.current_amount|intcomma:False }} ریال · واریز {{ linked_receipt.current_paid_date|jalali_date }}</p>
{% for vote in linked_receipt.review_decisions %}<p class="pay-linked-decision"><strong>{{ vote.get_action_display }} · {{ vote.actor_name }}</strong><time>{{ vote.created_at|jalali_datetime }}</time>{% if vote.reason %}<span>{{ vote.reason|truncatechars:160 }}</span>{% endif %}</p>{% empty %}<p class="pay-field-hint">فیش در انتظار بررسی است.</p>{% endfor %}
<a class="pay-text-link" href="{% url 'payments:receipt_detail' linked_receipt.public_id %}">جزئیات فیش و سوابق بررسی</a>
</article>{% empty %}<p class="pay-field-hint">هنوز فیشی برای این واریز ارسال نکرده‌اید.</p>{% endfor %}</section>
+19
View File
@@ -0,0 +1,19 @@
{% load jalali humanize %}
{% if not staff_notice and not notice_page %}<article class="pay-notice-entry">{% endif %}
<details class="pay-card payment-notice" {% if open_notice == notice.public_id|stringformat:'s' or staff_notice %}open{% endif %}>
<summary><strong>{{ notice.subject }}</strong><b>{{ notice.amount|intcomma:False }} <span>ریال</span></b><small>{{ notice.number }} · {{ notice.created_at|jalali_date }}</small><span class="notice-state">{{ notice.settlement_label }}{% if notice.cancelled_at %} · لغو شده{% endif %}</span>{% include 'payments/_icon.html' with name='chevron-down' %}</summary>
<div class="pay-details"><p class="notice-bank">{% if notice.account_snapshot.name %}{{ notice.account_snapshot.name }} · {% endif %}{{ notice.account_snapshot.bank }} · {{ notice.account_snapshot.holder }}</p>
<dl class="payment-values">
<div><dt>مبلغ (ریال)</dt><dd dir="ltr">{{ notice.amount|intcomma:False }}</dd><button type="button" data-copy="{{ notice.amount }}" class="pay-copy" aria-label="کپی مبلغ">{% include 'payments/_icon.html' with name='copy' %} کپی</button></div>
{% if notice.account_snapshot.account_number %}<div><dt>شماره حساب</dt><dd dir="ltr">{{ notice.account_snapshot.account_number }}</dd><button type="button" data-copy="{{ notice.account_snapshot.account_number }}" class="pay-copy" aria-label="کپی شماره حساب">{% include 'payments/_icon.html' with name='copy' %} کپی</button></div>{% endif %}
{% if notice.account_snapshot.iban %}<div><dt>شبا</dt><dd dir="ltr">{{ notice.account_snapshot.iban }}</dd><button type="button" data-copy="{{ notice.account_snapshot.iban }}" class="pay-copy" aria-label="کپی شبا">{% include 'payments/_icon.html' with name='copy' %} کپی</button></div>{% endif %}
{% if notice.account_snapshot.card_number %}<div><dt>شماره کارت</dt><dd dir="ltr">{{ notice.account_snapshot.card_number }}</dd><button type="button" data-copy="{{ notice.account_snapshot.card_number }}" class="pay-copy" aria-label="کپی کارت">{% include 'payments/_icon.html' with name='copy' %} کپی</button></div>{% endif %}
{% if notice.deposit_id %}<div class="pay-deposit-required"><dt>شناسه واریز · ضروری</dt><dd dir="ltr">{{ notice.deposit_id }}</dd><button type="button" data-copy="{{ notice.deposit_id }}" class="pay-copy" aria-label="کپی شناسه واریز">{% include 'payments/_icon.html' with name='copy' %} کپی</button></div>{% endif %}
</dl>
{% if notice.deposit_id %}<p class="pay-deposit-warning" role="note">هنگام واریز، حتماً شناسه واریز ارائه‌شده را در فرم بانکی، موبایل‌بانک یا اینترنت‌بانک وارد کنید؛ واریز بدون این شناسه معتبر نخواهد بود.</p>{% endif %}
<!-- Supplementary details stay visible within the expanded notice. Complete copy uses ALL snapshot fields. -->
<p>تأییدشده: {{ notice.approved_amount|intcomma:False }} ریال · باقی‌مانده: {{ notice.remaining_amount|intcomma:False }} ریال</p><section class="pay-supplement"><h3>اطلاعات تکمیلی و توضیحات</h3>{% if notice.invoice_number %}<p>فاکتور: {{ notice.invoice_number }}</p>{% endif %}<p>{{ notice.description|default:'توضیح دیگری اعلام نشده است.'|linebreaksbr }}</p></section>
<div class="pay-actions">{% if not notice.cancelled_at and not staff_notice and notice.remaining_amount > 0 %}<a class="pay-button" href="{% url 'payments:receipt_new' %}?notice={{ notice.public_id }}">ارسال فیش این واریز</a>{% endif %}<button type="button" class="pay-button secondary" data-copy="{{ notice.copy_text }}">کپی کامل اطلاعات</button></div>
{% if not staff_notice %}{% include 'payments/_linked_receipts.html' %}{% endif %}
</div></details>
{% if not staff_notice and not notice_page %}<a class="pay-notice-permalink pay-text-link" href="{% url 'payments:notice_detail' notice.public_id %}">جزئیات واریز و فیش‌ها {% include 'payments/_icon.html' with name='arrow' %}</a></article>{% endif %}
+1
View File
@@ -0,0 +1 @@
{% if payment_can_create and customer.is_active %}<a class="payment-notice-shortcut" href="{% url 'staff_payments:notice_new' %}?customer={{ customer.public_id }}"><svg aria-hidden="true" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><path d="M5 3h14v18l-3-2-4 2-4-2-3 2V3ZM9 8h6M9 12h6"/></svg><span>ایجاد اعلامیه واریز</span></a>{% endif %}
+1
View File
@@ -0,0 +1 @@
{% if page_obj.paginator.num_pages > 1 %}<nav class="pay-pagination" aria-label="صفحه‌بندی">{% if page_obj.has_previous %}<a href="?{{ query_string }}&page={{ page_obj.previous_page_number }}">قبلی</a>{% endif %}<span>{{ page_obj.number }} از {{ page_obj.paginator.num_pages }}</span>{% if page_obj.has_next %}<a href="?{{ query_string }}&page={{ page_obj.next_page_number }}">بعدی</a>{% endif %}</nav>{% endif %}
+6
View File
@@ -0,0 +1,6 @@
<div class="pay-field" data-field="{{ field.name }}">
<label for="{{ field.id_for_label }}">{{ field.label }}{% if field.field.required %}<span class="pay-optional">الزامی</span>{% endif %}</label>{{ field }}
{% if field.name == 'paid_amount' %}<p id="amount-words" aria-live="polite"></p>{% endif %}
{% if hint %}<p class="pay-field-hint">{{ hint }}</p>{% endif %}
<p class="field-error" id="error-{{ field.name }}" {% if not field.errors %}hidden{% endif %}>{% for error in field.errors %}{{ error }}{% endfor %}</p>
</div>
+20
View File
@@ -0,0 +1,20 @@
{% load jalali humanize %}
{% if payment_can_create or customer_notices %}
<details class="ticket-notices">
<summary><span class="ticket-notices-title"><svg aria-hidden="true" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><path d="M5 3h14v18l-3-2-4 2-4-2-3 2V3ZM9 8h6M9 12h6"/></svg>اعلامیه واریز</span><span class="ticket-notices-chevron" aria-hidden="true">⌄</span></summary>
<div class="ticket-notices-content">
{% include 'payments/_notice_shortcut.html' %}
<p class="ticket-notices-caption">آخرین اعلامیه‌های این مشتری</p>
<ul class="ticket-notices-list" aria-label="اعلامیه‌های اخیر مشتری">
{% for notice in customer_notices %}
<li><a class="ticket-notice-item" href="{% url 'staff_payments:notice_detail' notice.public_id %}">
<strong>{{ notice.subject }}</strong>
<span class="ticket-notice-meta"><bdi>{{ notice.number }}</bdi><time datetime="{{ notice.created_at|date:'c' }}">{{ notice.created_at|jalali_date }}</time></span>
<span class="ticket-notice-meta"><span>{{ notice.amount|intcomma }} ریال</span><span class="ticket-notice-status {% if notice.cancelled_at %}is-cancelled{% elif notice.approved_total >= notice.amount %}is-settled{% endif %}">{% if notice.cancelled_at %}لغو شده{% elif notice.approved_total >= notice.amount %}تسویه شده{% elif notice.approved_total %}پرداخت جزئی{% else %}در انتظار واریز{% endif %}</span></span>
</a></li>
{% empty %}<li class="ticket-notices-empty">اعلامیه واریزی برای نمایش وجود ندارد.</li>{% endfor %}
</ul>
<a class="ticket-notices-more" href="{% url 'staff_payments:desk' %}?tab=notices&amp;customer={{ customer.pk }}"><span>بیشتر</span><small>همه اعلامیه‌های این مشتری</small><span aria-hidden="true">←</span></a>
</div>
</details>
{% endif %}
+45
View File
@@ -0,0 +1,45 @@
{% extends 'payments/base.html' %}
{% load static %}
{% block title %}بانک‌ها و حساب‌های شرکت{% endblock %}
{% block content %}
<section class="chaharsu pay-workspace">
<header class="pay-page-head">
<div class="pay-page-title"><span class="pay-page-icon">{% include 'payments/_icon.html' with name='bank' %}</span><div><h1>بانک‌ها و حساب‌های شرکت</h1><p>حساب‌های قابل انتخاب برای کارشناسان و مشتریان را مدیریت کنید.</p></div></div>
<a class="pay-button" href="{% url 'staff_payments:bank_new' %}">{% include 'payments/_icon.html' with name='plus' %}ایجاد حساب</a>
</header>
<form class="pay-card pay-filter-panel" method="get">
<div class="pay-filter-grid pay-bank-filters">
<label class="pay-search-field">جست‌وجوی حساب<span class="pay-search-input">{% include 'payments/_icon.html' with name='search' %}<input type="search" name="q" value="{{ request.GET.q }}" placeholder="نام حساب، بانک یا صاحب حساب"></span></label>
{% for flag in bank_flags %}<label>{{ flag.label }}<select name="{{ flag.key }}"><option value="">همه</option><option value="1" {% if flag.value == '1' %}selected{% endif %}>فعال</option><option value="0" {% if flag.value == '0' %}selected{% endif %}>غیرفعال</option></select></label>{% endfor %}
<button class="pay-button" type="submit">جست‌وجو</button>
</div>
{% if request.GET.q or request.GET.is_active or request.GET.available_to_staff or request.GET.available_to_customers %}<div class="pay-filter-bottom"><a class="pay-reset" href="{% url 'staff_payments:banks' %}">پاک کردن فیلترها</a></div>{% endif %}
</form>
<form class="pay-bank-panel" method="post" action="{% url 'staff_payments:bank_bulk' %}" data-bank-selection>
{% csrf_token %}
<div class="pay-list-head"><h2>حساب‌های شرکت</h2><span>{{ page_obj.paginator.count }} حساب</span></div>
{% if page_obj %}
<div class="pay-bank-table-head"><label class="pay-select-all"><input type="checkbox" data-select-all aria-label="انتخاب همه حساب‌های این صفحه"><span>انتخاب همه</span></label><span>حساب و بانک</span><span>وضعیت کلی</span><span>کارشناسان</span><span>مشتریان</span><span>مدیریت</span></div>
{% for account in page_obj %}
<article class="pay-bank-row">
<label class="pay-bank-select"><input type="checkbox" name="accounts" value="{{ account.pk }}" aria-label="انتخاب {{ account.name }}"></label>
<div class="pay-bank-identity"><span class="pay-record-icon">{% include 'payments/_icon.html' with name='bank' %}</span><div><h3><a href="{% url 'staff_payments:bank_edit' account.public_id %}">{{ account.name }}</a></h3><p>{{ account.bank.name }} · {{ account.holder }}</p>{% if account.requires_deposit_id %}<span class="pay-identifier-badge">شناسه واریز الزامی</span>{% endif %}</div></div>
<div class="pay-bank-flag"><span class="pay-mobile-label">وضعیت کلی</span><span class="pay-status {% if account.is_active %}approved{% else %}neutral{% endif %}">{{ account.is_active|yesno:'فعال,غیرفعال' }}</span></div>
<div class="pay-bank-flag"><span class="pay-mobile-label">کارشناسان</span><span class="pay-status {% if account.is_active and account.available_to_staff %}approved{% else %}neutral{% endif %}">{% if not account.is_active %}غیرفعال{% else %}{{ account.available_to_staff|yesno:'فعال,غیرفعال' }}{% endif %}</span></div>
<div class="pay-bank-flag"><span class="pay-mobile-label">مشتریان</span><span class="pay-status {% if account.is_active and account.available_to_customers %}approved{% else %}neutral{% endif %}">{% if not account.is_active %}غیرفعال{% else %}{{ account.available_to_customers|yesno:'فعال,غیرفعال' }}{% endif %}</span></div>
<a class="pay-bank-edit" href="{% url 'staff_payments:bank_edit' account.public_id %}">{% include 'payments/_icon.html' with name='edit' %}ویرایش</a>
</article>
{% endfor %}
<div class="pay-bulk-bar">
<div class="pay-bulk-label"><strong>تغییر گروهی وضعیت</strong><span data-selection-count role="status" aria-live="polite">حساب‌های موردنظر را انتخاب کنید.</span></div>
<label>بخش موردنظر<select name="field"><option value="is_active">وضعیت کلی حساب</option><option value="available_to_staff">پنل کارشناسان</option><option value="available_to_customers">پنل مشتریان</option></select></label>
<label>وضعیت جدید<select name="value"><option value="1">فعال</option><option value="0">غیرفعال</option></select></label>
<button class="pay-button" type="submit" data-bulk-submit>اعمال بر انتخاب‌شده‌ها</button>
</div>
<p class="pay-bank-hint">حساب غیرفعال کلی در هیچ پنلی قابل انتخاب نیست؛ تنظیم هر پنل جداگانه حفظ می‌شود.</p>
{% else %}<div class="pay-empty"><span class="pay-empty-icon">{% include 'payments/_icon.html' with name='bank' %}</span><h3>{% if request.GET %}حسابی با این فیلترها پیدا نشد{% else %}هنوز حسابی تعریف نشده است{% endif %}</h3><p>{% if request.GET %}فیلترها را تغییر دهید یا پاک کنید.{% else %}برای شروع، اولین حساب شرکت را ایجاد کنید.{% endif %}</p></div>{% endif %}
</form>
{% include 'payments/_pagination.html' %}
</section>
{% endblock %}
{% block extra_scripts %}{{ block.super }}<script defer src="{% static 'js/chaharsu/bank-selection.js' %}?v={{ static_asset_version }}"></script>{% endblock %}
+36
View File
@@ -0,0 +1,36 @@
{% extends 'base.html' %}{% load static %}
{% block extra_head %}<link rel="stylesheet" href="{% static 'css/chaharsu.css' %}?v={{ static_asset_version }}"><link rel="stylesheet" href="{% static 'css/payment-link.css' %}?v={{ static_asset_version }}">{% if payment_link_access %}<meta name="robots" content="noindex, nofollow"><meta name="referrer" content="same-origin">{% endif %}{% endblock %}
{% block customer_header %}
<header class="cs-header">
<div class="cs-header-inner">
<a class="cs-brand" href="{% if payment_link_access.notice_id %}{% url 'payments:notice_detail' payment_link_access.notice.public_id %}{% elif payment_link_access.receipt_id %}{% url 'payments:receipt_detail' payment_link_access.receipt.public_id %}{% else %}{% url 'payments:home' %}{% endif %}" aria-label="چهارسو، خدمات پرداخت">
<img src="{{ company_logo_url }}?v={{ static_asset_version }}-{{ branding_version }}" alt="لوگوی شرکت" width="300" height="130">
<span class="cs-brand-divider" aria-hidden="true"></span>
<span><b>چهارسو</b><small>مرکز خدمات مشتریان</small></span>
</a>
<nav class="cs-nav" aria-label="منوی اصلی">
{% if payment_link_access %}
<a href="{% url 'payments:receipt_new' %}">ارسال فیش واریزی</a><a href="{% url 'payments:receipt_list' %}">فیش‌ها و وضعیت بررسی</a>
{% else %}
<a href="{% url 'payments:home' %}" {% if request.resolver_match.url_name == 'home' %}aria-current="page"{% endif %}>خدمات چهارسو</a>
<a href="{% url 'tickets:list' %}">درخواست‌های من</a>
{% endif %}
</nav>
<div class="cs-header-actions">
{% if payment_link_access %}<span class="pay-link-session-label">دسترسی موقت پرداخت</span>{% else %}
<a class="cs-profile" href="{% url 'accounts:profile' %}" aria-label="پروفایل من">{% include 'payments/_icon.html' with name='user' %}<span>پروفایل من</span></a>
<form method="post" action="{% url 'accounts:logout' %}">{% csrf_token %}<button class="cs-logout" type="submit">خروج</button></form>
{% endif %}
</div>
</div>
</header>
{% endblock %}
{% block staff_mobile_navigation %}
<nav class="staff-mobile-dock pay-staff-dock {% if payment_can_manage_banks %}has-banks{% endif %}" aria-label="دسترسی سریع پرداخت‌ها">
<a href="{% url 'staff_tickets:list' %}">{% include 'payments/_icon.html' with name='chat' %}میز کار</a>
<a href="{% url 'staff_payments:desk' %}" {% if active_staff_section == 'payments' %}class="active" aria-current="page"{% endif %}>{% include 'payments/_icon.html' with name='receipt' %}واریزها</a>
{% if payment_can_manage_banks %}<a href="{% url 'staff_payments:banks' %}" {% if active_staff_section == 'banks' %}class="active" aria-current="page"{% endif %}>{% include 'payments/_icon.html' with name='bank' %}بانک‌ها</a>{% endif %}
</nav>
{% endblock %}
{% block footer %}<footer class="cs-footer">{% if payment_link_access %}{{ site_branding.developer_credit_text }}{% else %}{{ developer_credit }}{% endif %}</footer>{% endblock %}
{% block extra_scripts %}<script defer src="{% static 'js/chaharsu/payments.js' %}?v={{ static_asset_version }}"></script>{% endblock %}
@@ -0,0 +1,9 @@
{% extends 'payments/base.html' %}
{% block title %}جزئیات واریز {{ notice.number }}{% endblock %}
{% block content %}<section class="chaharsu customer-finance customer-accounts customer-notice-detail">
{% include 'payments/_link_status.html' %}
{% if not payment_link_access %}<nav class="pay-breadcrumb" aria-label="مسیر صفحه"><a href="{% url 'payments:notices' %}">شماره حساب‌ها</a><span aria-hidden="true">/</span><span aria-current="page">جزئیات واریز</span></nav>{% endif %}
<header class="customer-finance-title"><span class="pay-page-icon">{% include 'payments/_icon.html' with name='bank' %}</span><div><h1>جزئیات واریز</h1><p>اطلاعات حساب، فیش‌های ارسالی و سوابق بررسی این واریز</p></div></header>
{% include 'payments/_notice.html' with notice_page=True %}
<div class="customer-service-footer">{% if payment_link_access %}<a class="pay-text-link" href="{% url 'payments:receipt_new' %}?mode=independent">ارسال فیش واریز مستقل</a>{% else %}<a class="pay-text-link" href="{% url 'payments:notices' %}">بازگشت به واریزهای شما</a>{% endif %}<a class="pay-text-link" href="{% url 'payments:receipt_list' %}">فیش‌های من و وضعیت بررسی</a></div>
</section>{% endblock %}
+59
View File
@@ -0,0 +1,59 @@
{% extends 'payments/base.html' %}
{% load jalali humanize %}
{% block title %}اعلامیه‌ها و فیش‌های واریزی{% endblock %}
{% block content %}
<section class="chaharsu pay-workspace">
<header class="pay-page-head">
<div class="pay-page-title"><span class="pay-page-icon">{% include 'payments/_icon.html' with name='receipt' %}</span><div><h1>اعلامیه‌ها و فیش‌های واریزی</h1><p>اعلام اطلاعات واریز به مشتریان و بررسی فیش‌های پرداخت</p></div></div>
{% if can_create %}<div class="pay-head-actions"><a class="pay-button" href="{% url 'staff_payments:notice_new' %}">{% include 'payments/_icon.html' with name='plus' %}ایجاد اعلامیه واریز</a><a class="pay-button secondary" href="{% url 'staff_payments:receipt_customer' %}">ثبت فیش برای مشتری</a></div>{% endif %}
</header>
<nav class="pay-tabs pay-desk-tabs" aria-label="نمای پرداخت‌ها">
<a href="?tab=receipts" {% if tab != 'notices' and not request.GET.mine %}aria-current="page"{% endif %}>فیش‌های واریزی</a>
<a href="?tab=notices" {% if tab == 'notices' %}aria-current="page"{% endif %}>اعلامیه‌های واریز</a>
<a href="?tab=receipts&mine=1" {% if tab != 'notices' and request.GET.mine %}aria-current="page"{% endif %}>نیازمند اقدام من</a>
</nav>
<form method="get" class="pay-card pay-filter-panel">
<input type="hidden" name="tab" value="{{ tab }}">
{% if request.GET.mine %}<input type="hidden" name="mine" value="1">{% endif %}
<div class="pay-filter-grid">
<label class="pay-search-field">جست‌وجو<span class="pay-search-input">{% include 'payments/_icon.html' with name='search' %}<input type="search" name="q" value="{{ request.GET.q }}" placeholder="نام، موبایل، استان، شهر یا فاکتور"></span></label>
<label>حساب مقصد<select name="account"><option value="">همه حساب‌ها</option>{% for bank in banks %}<option value="{{ bank.pk }}" {% if request.GET.account == bank.pk|stringformat:'s' %}selected{% endif %}>{{ bank.name }}</option>{% endfor %}</select></label>
{% if tab != 'notices' %}<label>وضعیت فیش<select name="status"><option value="">همه وضعیت‌ها</option>{% for value,label in statuses %}<option value="{{ value }}" {% if request.GET.status == value %}selected{% endif %}>{{ label }}</option>{% endfor %}</select></label>{% else %}<label>وضعیت واریز<select name="notice_state"><option value="">همه واریزها</option><option value="pending" {% if request.GET.notice_state == 'pending' %}selected{% endif %}>تسویه‌نشده</option><option value="settled" {% if request.GET.notice_state == 'settled' %}selected{% endif %}>تسویه شده</option><option value="cancelled" {% if request.GET.notice_state == 'cancelled' %}selected{% endif %}>لغو شده</option></select></label>{% endif %}
<button class="pay-button" type="submit">جست‌وجو</button>
</div>
<div class="pay-filter-bottom">
<details class="pay-more-filters" {% if request.GET.kind or request.GET.from or request.GET.to %}open{% endif %}>
<summary>{% include 'payments/_icon.html' with name='filter' %}فیلترهای بیشتر</summary>
<div class="pay-extra-filters">
{% if tab != 'notices' %}<label>نوع واریز<select name="kind"><option value="">همه فیش‌ها</option><option value="independent" {% if request.GET.kind == 'independent' %}selected{% endif %}>واریز مستقل</option><option value="linked" {% if request.GET.kind == 'linked' %}selected{% endif %}>واریز مرتبط</option></select></label>{% endif %}
<label>از تاریخ <small>(میلادی)</small><input type="date" name="from" value="{{ request.GET.from }}"></label>
<label>تا تاریخ <small>(میلادی)</small><input type="date" name="to" value="{{ request.GET.to }}"></label>
</div>
</details>
<a class="pay-reset" href="?tab={{ tab }}{% if request.GET.mine %}&mine=1{% endif %}">پاک کردن فیلترها</a>
</div>
</form>
<section class="pay-records" aria-labelledby="pay-results-title">
<div class="pay-list-head"><h2 id="pay-results-title">{% if tab == 'notices' %}اعلامیه‌های واریز{% elif request.GET.mine %}فیش‌های نیازمند اقدام شما{% else %}فیش‌های واریزی{% endif %}</h2><span>{{ page_obj.paginator.count }} مورد</span></div>
{% for item in page_obj %}
<article class="pay-record">
<span class="pay-record-icon">{% include 'payments/_icon.html' with name='receipt' %}</span>
<div class="pay-record-main">
{% if tab == 'notices' %}<h3><a href="{% url 'staff_payments:notice_detail' item.public_id %}">{{ item.subject }}</a></h3><span class="pay-record-meta"><bdi>{{ item.number }}</bdi>{% if item.invoice_number %} · فاکتور {{ item.invoice_number }}{% endif %}</span>
{% else %}<h3><a href="{% url 'staff_payments:receipt_detail' item.public_id %}"><bdi>{{ item.number }}</bdi></a></h3><span class="pay-record-meta">{% if item.notice %}مرتبط با {{ item.notice.subject }}{% else %}واریز مستقل{% endif %}</span>{% endif %}
{% include 'payments/_customer_identity.html' with customer=item.customer %}
<div class="pay-record-review">{% if tab == 'notices' %}<span>ایجادکننده: {{ item.created_by.staff_profile.display_name|default:item.created_by.get_full_name|default:item.created_by.username }}</span>{% for linked in item.related_receipts %}{% if linked.status == 'APPROVED' %}{% for vote in linked.review_decisions %}<span>تأیید فیش {{ linked.number }}: {{ vote.actor_name }}</span>{% endfor %}{% endif %}{% endfor %}{% else %}{% if item.notice %}<span>ایجادکننده اعلامیه: {{ item.notice.created_by.staff_profile.display_name|default:item.notice.created_by.get_full_name|default:item.notice.created_by.username }}</span>{% endif %}{% if item.status == 'APPROVED' %}{% for vote in item.review_decisions %}<span>تأییدکننده: {{ vote.actor_name }}</span>{% endfor %}{% endif %}{% endif %}</div>
</div>
<div class="pay-record-amount"><strong>{% if tab == 'notices' %}{{ item.amount|intcomma:False }}{% else %}{{ item.current_amount|intcomma:False }}{% endif %} <small>ریال</small></strong><time>{% if tab == 'notices' %}{{ item.created_at|jalali_datetime }}{% else %}واریز {{ item.current_paid_date|jalali_date }}{% endif %}</time></div>
<div class="pay-record-side">
{% if tab == 'notices' %}<span class="pay-status {% if item.cancelled_at %}neutral{% elif item.approved_amount >= item.amount %}approved{% else %}info{% endif %}">{% if item.cancelled_at %}لغو شده{% else %}{{ item.settlement_label }}{% endif %}</span><a class="pay-open" href="{% url 'staff_payments:notice_detail' item.public_id %}">مشاهده اعلامیه{% include 'payments/_icon.html' with name='arrow' %}</a>
{% else %}<span class="pay-status {{ item.status|lower }}">{{ item.get_status_display }}</span><a class="pay-open" href="{% url 'staff_payments:receipt_detail' item.public_id %}">مشاهده و بررسی{% include 'payments/_icon.html' with name='arrow' %}</a>{% endif %}
</div>
</article>
{% empty %}
<div class="pay-empty"><span class="pay-empty-icon">{% include 'payments/_icon.html' with name='search' %}</span><h3>موردی پیدا نشد</h3><p>{% if request.GET.q or request.GET.status or request.GET.notice_state or request.GET.account or request.GET.kind or request.GET.from or request.GET.to %}فیلترها را تغییر دهید یا پاک کنید.{% elif request.GET.mine %}در حال حاضر فیشی برای اقدام شما وجود ندارد.{% elif tab == 'notices' %}اعلامیه‌های صادرشده برای مشتریان اینجا نمایش داده می‌شوند.{% else %}فیش‌های ارسالی مشتریان برای بررسی اینجا نمایش داده می‌شوند.{% endif %}</p></div>
{% endfor %}
</section>
{% include 'payments/_pagination.html' %}
</section>
{% endblock %}
Loaded 100 of 127 files, more files were not shown because too many files have changed in this diff. Show more