fix payment
This commit is contained in:
1 parent
4f7486152c
commit
bb3ddd1610
47 files changed
+2137
-51
No files matched your search
+353
-15
@@ -1,14 +1,15 @@
|
||||
Project Root: customerTicket
|
||||
Generated At: 2026-10-05T09:09:24+00:00
|
||||
Python Files: 223
|
||||
Django Files: 42
|
||||
JavaScript/TypeScript Files: 28
|
||||
HTML Files: 45
|
||||
Total Files: 951
|
||||
Total Directories: 106
|
||||
Generated At: 2026-10-07T07:30:58+00:00
|
||||
Python Files: 287
|
||||
Django Files: 49
|
||||
JavaScript/TypeScript Files: 38
|
||||
HTML Files: 72
|
||||
Total Files: 1264
|
||||
Total Directories: 131
|
||||
|
||||
customerTicket
|
||||
├── .agents
|
||||
├── .aws
|
||||
├── .codex
|
||||
├── apps
|
||||
│ ├── accounts
|
||||
@@ -73,7 +74,8 @@ customerTicket
|
||||
│ │ ├── middleware.py
|
||||
│ │ ├── models.py
|
||||
│ │ ├── persian_text.py
|
||||
│ │ └── production_seed.py
|
||||
│ │ ├── production_seed.py
|
||||
│ │ └── session_access.py
|
||||
│ ├── customers
|
||||
│ │ ├── migrations
|
||||
│ │ │ ├── 0001_initial.py
|
||||
@@ -113,6 +115,7 @@ customerTicket
|
||||
│ │ ├── admin.py
|
||||
│ │ ├── apps.py
|
||||
│ │ ├── models.py
|
||||
│ │ ├── read_receipts.py
|
||||
│ │ ├── services.py
|
||||
│ │ ├── urls.py
|
||||
│ │ └── views.py
|
||||
@@ -139,6 +142,8 @@ customerTicket
|
||||
│ │ │ ├── 0014_backfill_safe_0098_delivery_retries.py
|
||||
│ │ │ ├── 0015_announcementjob.py
|
||||
│ │ │ ├── 0016_announcementjob_requested_by_announcementjob_source_and_more.py
|
||||
│ │ │ ├── 0017_alter_smstemplate_event_type.py
|
||||
│ │ │ ├── 0018_alter_smstemplate_body.py
|
||||
│ │ │ └── __init__.py
|
||||
│ │ ├── __init__.py
|
||||
│ │ ├── admin.py
|
||||
@@ -146,6 +151,33 @@ customerTicket
|
||||
│ │ ├── apps.py
|
||||
│ │ ├── models.py
|
||||
│ │ └── services.py
|
||||
│ ├── payments
|
||||
│ │ ├── migrations
|
||||
│ │ │ ├── 0001_initial.py
|
||||
│ │ │ ├── 0002_initial_configuration.py
|
||||
│ │ │ ├── 0003_paymentnotice_issuance_key.py
|
||||
│ │ │ ├── 0004_paymentaccesslink_paymentnotification_access_link_and_more.py
|
||||
│ │ │ ├── 0005_paymentnotice_deleted_at.py
|
||||
│ │ │ └── __init__.py
|
||||
│ │ ├── __init__.py
|
||||
│ │ ├── access.py
|
||||
│ │ ├── admin.py
|
||||
│ │ ├── apps.py
|
||||
│ │ ├── context_processors.py
|
||||
│ │ ├── customer_picker.py
|
||||
│ │ ├── deposit_identifier.py
|
||||
│ │ ├── forms.py
|
||||
│ │ ├── link_views.py
|
||||
│ │ ├── middleware.py
|
||||
│ │ ├── models.py
|
||||
│ │ ├── notifications.py
|
||||
│ │ ├── policies.py
|
||||
│ │ ├── selectors.py
|
||||
│ │ ├── services.py
|
||||
│ │ ├── staff_urls.py
|
||||
│ │ ├── urls.py
|
||||
│ │ ├── validators.py
|
||||
│ │ └── views.py
|
||||
│ ├── phone_contacts_api
|
||||
│ │ ├── migrations
|
||||
│ │ │ ├── 0001_initial.py
|
||||
@@ -263,9 +295,14 @@ customerTicket
|
||||
│ ├── entrypoint.sh
|
||||
│ └── nginx.conf
|
||||
├── docs
|
||||
│ ├── CHAHARSU_PAYMENTS_IMPLEMENTATION.md
|
||||
│ ├── CHAHARSU_PAYMENTS_IMPLEMENTATION_PLAN.md
|
||||
│ ├── DEPLOYMENT_COOLIFY.md
|
||||
│ ├── MESSAGE_READ_RECEIPTS.md
|
||||
│ ├── ORGANIZATIONAL_TICKETING.md
|
||||
│ ├── ORGANIZATIONAL_TICKETING_GAP_ANALYSIS.md
|
||||
│ ├── PAYMENT_LINKS.md
|
||||
│ ├── PAYMENT_NOTICE_DELETION.md
|
||||
│ ├── PHONE_CONTACTS_API.md
|
||||
│ ├── PRODUCTION_SEED.md
|
||||
│ ├── QA_PERMISSION_MATRIX.md
|
||||
@@ -332,6 +369,13 @@ customerTicket
|
||||
│ │ ├── graph.json
|
||||
│ │ ├── GRAPH_REPORT.md
|
||||
│ │ └── manifest.json
|
||||
│ ├── 2026-10-05
|
||||
│ │ ├── .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
|
||||
@@ -353,16 +397,20 @@ customerTicket
|
||||
│ │ │ ├── 04b84d19d132764512441a31b31294e4f19dc963ed0a6e3abdd3eca7719e8bca.json
|
||||
│ │ │ ├── 057ccb6901a83922bee4265993977ceb0d0ac425c900cb2d2f3181998df71796.json
|
||||
│ │ │ ├── 058b4e36240cd25d158647c300472baad5e7101ebde0319883abb0edc8c9c74d.json
|
||||
│ │ │ ├── 06ad1912a277e46fe5cecaa8a59b370c18101329c055c26fa6d603f37f63c91a.json
|
||||
│ │ │ ├── 07e174cf218887aa0f8747199874faf6bc8af9347e43ff9b5a2073c089637f6f.json
|
||||
│ │ │ ├── 08ff4daae812563107028890c2ae7257bb5f9a376a978244c814b7a4c1d10a98.json
|
||||
│ │ │ ├── 091194a653ec9ff59e50d6d6121d12c0b0238b3f5cbb0467133a224b7eda59c8.json
|
||||
│ │ │ ├── 09f9369eaae9b6144d8967b80f1a3edf0b3a8a3c44ef7e7f453b48b0adaa3fac.json
|
||||
│ │ │ ├── 0aa59f78fa092e41298775a4db40136ea63654d45e3ae835903c4e3dbd0578d4.json
|
||||
│ │ │ ├── 0aafac1c4b289aeef9a6251e3a12fbf1710ae576dbd647d155aa3981008d50b7.json
|
||||
│ │ │ ├── 0c2c38fa53a0fe1da084703272dff1c8130732eb327a788d26ec0443c72d6b96.json
|
||||
│ │ │ ├── 0d276b031b1e68ce07a7e0620645994c4d80c88dbdeb4b49deba5e053ef188cb.json
|
||||
│ │ │ ├── 0d5f84af41009fdecae2a46d4ad5fab38a79c1a5dde262e9c7f8d35707a35092.json
|
||||
│ │ │ ├── 0ef31d053655ee64599a0246f26c8eb61f27654ae1ffbd41b87500438fc9b06a.json
|
||||
│ │ │ ├── 0f0c4146a70cadafffd025d6954e9202626be74ff97539e984c8b27cda5ed662.json
|
||||
│ │ │ ├── 0f28b01b2484ce84b5251ff848065a2c6f7f574665305b5ecf8cb31a755a7982.json
|
||||
│ │ │ ├── 0feebff2f8ac6ee66557b8692e3a537c0834b6bc4c28a6b9da6f3edf619283c1.json
|
||||
│ │ │ ├── 11d2b71feb1480622fb18ee3795cf070421b146e4273b5016fad81e7cc8e2c7e.json
|
||||
│ │ │ ├── 11ff4da3afbce9cae19102862deefdf725fa363130fad753fa189fa4f6c8c4de.json
|
||||
│ │ │ ├── 12a877e28c12d0651d6487751c6b746afbe8361f9ba7e1b672f09bd596fbcf82.json
|
||||
@@ -375,27 +423,37 @@ customerTicket
|
||||
│ │ │ ├── 18ba25713068baf53992eb4ea94f95212ecaeb931fdcf6769970fc2fd19b2f93.json
|
||||
│ │ │ ├── 19ed1276256eb4d44b9cea2dddac4de3801d67a08cf381a55466e82ee6f2bbc6.json
|
||||
│ │ │ ├── 1bfc839bf502b97d0b70b0bacc9781cae7511d27d078ed6ac6978fd438833dd1.json
|
||||
│ │ │ ├── 1c74c48acbde13e6bc5c2f63b204a1c051f22a8f48cd7b40ac54fc95e8809018.json
|
||||
│ │ │ ├── 1c9f81b7412f571aa252ed10230220684b6657e1115f75a8c3a64b792517d41c.json
|
||||
│ │ │ ├── 1d0ab0c5a2f15cd1c5a78495225dc535c02b58e9da10f882f48182e39b3f8b7b.json
|
||||
│ │ │ ├── 1d6b17be6b2c0e7d25b4622b4a8c09ab705cecf9db09e2f86c35a4a564485791.json
|
||||
│ │ │ ├── 1db075565e2fe1027675b7ee03d055150e6b741f3e9865bcd46d9a6113c538a8.json
|
||||
│ │ │ ├── 1e3bef21b169a2020643dc56fc33c665acc164b4b8f07639a9b1f58c37bb4caf.json
|
||||
│ │ │ ├── 1f440edc6feb5bf8c2a462b1b4a0b44bd22b4a763286fb2b340c797c282b5bcd.json
|
||||
│ │ │ ├── 1f6cfd55d13bc48aa6c68558090ceeaf24a54c7bec690d51d95243c904daf0a6.json
|
||||
│ │ │ ├── 1fabd63a56e826ff351d3edca222f5f958004c01f0bd16aed30080e8b13da33f.json
|
||||
│ │ │ ├── 21f38c2f6a379ab402b4d3bd1b0809129c8554ebb01202a160c42a1e9b49b717.json
|
||||
│ │ │ ├── 22534d744aadf9c9b61b908eccea0e9b3eaa30bc381bace54b3221ee0861a0ec.json
|
||||
│ │ │ ├── 22df765bbeaca09caaf2fbcddcdd3ef1d4c0800f0c545f963bd91e85c33fe7ec.json
|
||||
│ │ │ ├── 2363fafda0db2a268722cdc649cfb520f1d2e7f888debc09528ea8dc4aab0b60.json
|
||||
│ │ │ ├── 2396802d9ca82a055f431939b4cd95eea6c29556956075d1d1ff35e4bca3b7c6.json
|
||||
│ │ │ ├── 254ff59f2f43fd16c2028b338ec47a892294021f1a354238db59cb2ae6cbdf4a.json
|
||||
│ │ │ ├── 2609e627752bd20df923f6c69e090173a368757b3dbd2ce418e3cc9f67c697ba.json
|
||||
│ │ │ ├── 26a41d5fda805c545065f2da12215d350587a61aff951937f54d64bb713f0fab.json
|
||||
│ │ │ ├── 26c6832fa65fe38fa62f98bbfd8d544cf6a470cce547579e1d650390e8b9fded.json
|
||||
│ │ │ ├── 26e3b3874f2dc2c3709945e2f6cf050e5c525933de9f09a8c91b244e3bfa8ca7.json
|
||||
│ │ │ ├── 28cefeff27c9cb5760aead796c37afdb1311bff0a5ff759136f0473ffa0152ff.json
|
||||
│ │ │ ├── 298888276da87757f59b0e0280ea46039c0ee05945961c3e00b287abcbc6a2f0.json
|
||||
│ │ │ ├── 29aca949b15e953360acae5ba86aac6c34ba8efddc55636fa05c28bd259ec66e.json
|
||||
│ │ │ ├── 2b7cc5d0dec7234082c1797ab637b916ac7df39277f8fbf109ae90190fc1e900.json
|
||||
│ │ │ ├── 2c76016c5b2c45bddcb0aec92b2365ea389feafb0667b2367350c756c7c51d87.json
|
||||
│ │ │ ├── 2c8d91fbfca457646cf60edb03fd3a89a2494a6b6612087854edeac5a119739a.json
|
||||
│ │ │ ├── 2df1edd1fd32c467f237babe4a94f2ed73b67270ede6fa8298b32eab7c3eb80b.json
|
||||
│ │ │ ├── 2e7903ddca9e29c53f1706f2755303e56d60436e494d066d011fdba93e701903.json
|
||||
│ │ │ ├── 2fd5953e7926f320bdeac2d559967758aef4632bf7dfa26a5ee50ad386fc9b67.json
|
||||
│ │ │ ├── 30a1c433d0992b236232f8d17b7e17bbcfac1fd7385c607d1d09e716ba7da9db.json
|
||||
│ │ │ ├── 3142f9d4517f137483c716dd61985811bbf006b85506791e9a2ee37d58b2f53e.json
|
||||
│ │ │ ├── 314eb1aeeb47d9039980ee994d0682472193ed4e187b497797cd3c51a51b18a4.json
|
||||
│ │ │ ├── 32a4505d16f4fd1efc5bc0c2ea7c8900419856b21a41b06d988cc424258ea1c9.json
|
||||
│ │ │ ├── 32c40d17e90ccf2511e124b4fcc5b3674a7200dd5215ee8742d319f8090b04ba.json
|
||||
│ │ │ ├── 336d5e1042b05e1f144a030b9f84836283398076b9f90d8cd4dc37622ab00caf.json
|
||||
@@ -412,16 +470,19 @@ customerTicket
|
||||
│ │ │ ├── 3948717314f47afa835e760073df4764ac427f4858f5ec0ee6a2f298ac393d49.json
|
||||
│ │ │ ├── 3b9252049dbf8ef67e64a756bb41325a6cf50190004d4442119d438620bacc72.json
|
||||
│ │ │ ├── 3c4a42b0b79b95acf81967479efdc4cd0a369e3b53b771fe87478d6327ddf0eb.json
|
||||
│ │ │ ├── 3ca58a3cda78344757f73fb9dc390d4bb3b45f4a711a1a058c049801282ec1f8.json
|
||||
│ │ │ ├── 3cec8055583c7d94e03ced535376a8bd9d3d335cd6c7e328f38dc2a9e6b1e397.json
|
||||
│ │ │ ├── 3d66d87daf2039a3fcd22d5d844e30c00443bed258dc048fbf939d97258e08d2.json
|
||||
│ │ │ ├── 3f0cad4a4a6eb440a2b133bf72f2e72d6e7ef3668f4c8dbc2c759dbd1a41eb6f.json
|
||||
│ │ │ ├── 401b3df2aa1a5c618d917ae2422e552c59dfbc5220b312fe7977d751168f6987.json
|
||||
│ │ │ ├── 408f6eb568e45e950fdc170776dabf9087829df0db630e16643fba8995318d26.json
|
||||
│ │ │ ├── 4177b5c9bcdd159b716f3e956852b337fc54e7e597b5631d0f0e9a4039011901.json
|
||||
│ │ │ ├── 41b1ac2b8ad040a3aa7173910cd19c2074adfdc23c4a1df31e445b61427a330b.json
|
||||
│ │ │ ├── 42a296078b0eb6bea6dd15ebc5799c252901ba9e001290df5d3a9dfae0e4e4d5.json
|
||||
│ │ │ ├── 42ffbc76dd6afcd90fde30281453fcc765ed07bc0e729fae4eee28d86f840578.json
|
||||
│ │ │ ├── 433946952194be24247d30e4d5ba4d67b64ee2cda27a9212f16b4160a6f3fb71.json
|
||||
│ │ │ ├── 434bb9df51f8929ca10167ce360dbaa9d93fdbfbd27053bdf11f3b171189edb8.json
|
||||
│ │ │ ├── 44c4bd6b78660abe3642da6dfe25e665ac7bed04e759528556f437423fbbfe36.json
|
||||
│ │ │ ├── 478a054f8292cd197743a61b5e3e04487f67f8db76ae658a28b04e4aef670ab2.json
|
||||
│ │ │ ├── 479992e96c0c5fac0a00bd4d9e0822dd52273158f5364c26884649b017e5b874.json
|
||||
│ │ │ ├── 47fb67d01a9006c7f835ad87b75b95bb1365c27b342144f081d858e07fd309b3.json
|
||||
@@ -429,10 +490,15 @@ customerTicket
|
||||
│ │ │ ├── 48f2dd332e3e37756440716095b3b0ba781ebd5abae50130c6b8bfee5a0468cd.json
|
||||
│ │ │ ├── 4993b53d428cbbb1e334ea396aae6397586a1295a35f39da038cfe72f0b89fd4.json
|
||||
│ │ │ ├── 49dbf05487834af2e8fb3c1c4048f93565e6ca40251d04486a137b8f960adcac.json
|
||||
│ │ │ ├── 4a114c79b6523fc965e3aabcaf45df383f34f3fa942efed1aab21819d5a11cee.json
|
||||
│ │ │ ├── 4ae084687e4372e69f349e8bbc5cf603768a032d3d9ee4d2504e6709ed8ee440.json
|
||||
│ │ │ ├── 4ae419f04a3a3b1221faef43b8526910f1958336162fbce432bab0e596ef2aa1.json
|
||||
│ │ │ ├── 4ae7b7ad5faee3292f9987cf047c1b9f484bb316d9e8c177dd85345bdfc861f3.json
|
||||
│ │ │ ├── 4bf46c6c0f27e14312f7c1311bca1bf5e1bfee29ea5cb35e6f94142141985d23.json
|
||||
│ │ │ ├── 4c924746bf7373f2f4b09200257622c6f97c2ba7fb3edc7f6595709e3c7ce9de.json
|
||||
│ │ │ ├── 4cf64fe0b79aec18c49355f7d82ab04ae66067042a6c364e89842109427ffb5d.json
|
||||
│ │ │ ├── 4d244ac4c7ac7dddfbb2d8ad6e70b2f9ef0d5226d2660b2ef38ef109f19eacc4.json
|
||||
│ │ │ ├── 4e56ef4c75a0ef1d175a72a6980ba047bb4aa51d49b8a17471d8f342a0b779c0.json
|
||||
│ │ │ ├── 4f4799a971a5d6701087428e8c709396c0d947f7ce5bcf7a048cd542d5551c57.json
|
||||
│ │ │ ├── 4fd59b7f74524c4eb0b9293df963b8aa007039c53e88fef4ca2bd9b61197a114.json
|
||||
│ │ │ ├── 502d00f961089b9a706153667a4767dbf3463eabb1d9c636ab92197cfebaaf20.json
|
||||
@@ -440,11 +506,13 @@ customerTicket
|
||||
│ │ │ ├── 52cd74ad5803b940489587043429b741434ae931fa4d244f5b0dc53582cee050.json
|
||||
│ │ │ ├── 5323b09e3c325ba8c161f5e2cab233b25cfa1b96ff042c6ae79ae1b029e2b1bc.json
|
||||
│ │ │ ├── 54d567d298ddc3c274327011a29bd4f2f2cb31efaae5ecb3a7a94164a3eb3c1f.json
|
||||
│ │ │ ├── 55322e8bcc443a33b31b8e43e27a92b0c89335d5669d7046d34f58d33edef673.json
|
||||
│ │ │ ├── 57bc96866536f0d67300ed29e56bd0f45fa9119c787fac805f228adcab18dc6f.json
|
||||
│ │ │ ├── 582c91a8ddabbea5521fc65d724e4fb3672c070f9213fae7b58595fcf62f3c85.json
|
||||
│ │ │ ├── 584447cf308657a696ef8465181b12d2c06f9dc5ff69752bea66bd0b9631feec.json
|
||||
│ │ │ ├── 596a13e9b322f8c3332a3ae214ca7bf368ba6158ff3adcc7db4582f102ea51dc.json
|
||||
│ │ │ ├── 5a34ded8b2dd4c10c4251d287580918b88690c7058dd2299d2d670170104241d.json
|
||||
│ │ │ ├── 5ab2de631b0d480df2665ade93dae4732a4d6a1f44b809b2051ccff4696515af.json
|
||||
│ │ │ ├── 5d43f2c9afc3451745d2a1b5f1ed17bc138c9b19caff61beadbc40a4e970b525.json
|
||||
│ │ │ ├── 5f759ce7c1bcc67b3972da0ff15616dedf748eb2a85e029802d6f0e0feb053ee.json
|
||||
│ │ │ ├── 5fcd0fdc47a301cbd07130dfa7545017b5975c29b9d7458ec65938ed503506a6.json
|
||||
@@ -456,6 +524,7 @@ customerTicket
|
||||
│ │ │ ├── 63a85c116feeb64b68cb48c92e34e48a89fc4ed443f40594b62751711b6e13e0.json
|
||||
│ │ │ ├── 63d01f1af1f4f0c9ad42a8c4d310a5b9df5547d5526c49284255fc6ff15ace9b.json
|
||||
│ │ │ ├── 6506009802a6a268be82b680ace7580613f18dc702ed27aaff929398a85760f3.json
|
||||
│ │ │ ├── 656466a59cda75489e13531815b0b6075b27edf7d4261322324e5c621edbe8a1.json
|
||||
│ │ │ ├── 659df1da012e33aa3c92c4d36817034e6ec1a4d5d7ae94e6c2328f9aaa607b6b.json
|
||||
│ │ │ ├── 65c9b0921f2ad9b0228f5685c40d5b341da9604f8cec59339fded4cf0bfa40f4.json
|
||||
│ │ │ ├── 6802a4e7d920a2a5ce48a61989565f6fa769c314a01b9179cb9d44653b9467ae.json
|
||||
@@ -465,11 +534,14 @@ customerTicket
|
||||
│ │ │ ├── 6c69494ba124d4bc564bcd3a3df1f33ae0ee0769001a2dfc46825ef8b44dc3e2.json
|
||||
│ │ │ ├── 6d3b094dc56f9b26ce7374ba28e460b85248711223cf516f8e732c5d8f4d9d75.json
|
||||
│ │ │ ├── 6d5685e1562faf37f6c30c614bcc7628edd19cd8181d5791aff416daf9fd5a6c.json
|
||||
│ │ │ ├── 6d824f70de5ab20115bad3abf431778360f87aace8e16f7056e6706f65346978.json
|
||||
│ │ │ ├── 6dc742b24111f73be6bd5c6d49bd013066a7bf4d299d145391fb0de1f4ed6fae.json
|
||||
│ │ │ ├── 6ee9154979fb497a8ec229a7177660af53a487e1aa17c7b555cdc67797615162.json
|
||||
│ │ │ ├── 7093cbd0a855750457814388b6e527fd3b612163f7a2b898ed6ce470dae74843.json
|
||||
│ │ │ ├── 72ae8a21c9106c639725190cc02f4e07efe7c645896cc4410923ad36751fbb2e.json
|
||||
│ │ │ ├── 72de23f55766289c980e6ccf604ca5d162e0989a222271b7496775ba4d749e3a.json
|
||||
│ │ │ ├── 7379dad88b1741f6f4d999fcea3eb0ac4ffe56f72c07dc11881e94615457bd52.json
|
||||
│ │ │ ├── 745e730da3d08083a3c15a3a8e2d5e603c81a33d371c6e2de7f554a70c7e2e29.json
|
||||
│ │ │ ├── 74c2a0c0f83087673b100b7555e506c71d2a6a941677e388a1de59371b6d9afe.json
|
||||
│ │ │ ├── 75fad6124616f6d9e50ce9a475d4e989955c5fae7c5566b99992d986ef9a1f93.json
|
||||
│ │ │ ├── 77916c3b035a577b24da1a1dbcf47db7307af4e0774fecc549f7c95b91994a20.json
|
||||
@@ -479,6 +551,7 @@ customerTicket
|
||||
│ │ │ ├── 7a886aec3fe4d432060a7ad6c88a9e0fd810a122ad1d244456af089a828af8c1.json
|
||||
│ │ │ ├── 7b25ccdfeab6d57509c1b8a9745fb601ab7e8fcae0e73a22932ceb725fd38c9a.json
|
||||
│ │ │ ├── 7b6916f499ae0ceae3eb3d509b51f42a7eaa98e43e161fe87c44b9a5bb01287b.json
|
||||
│ │ │ ├── 7bc90a6d89ccfaea18fd0aa05f2ab506d0639360f33413bfa9385ddf49e6d2fe.json
|
||||
│ │ │ ├── 7cd278b7ea82af8777e4da2f84fd1773dae3dd371744f66bbe12c0b925fd6486.json
|
||||
│ │ │ ├── 7d276789b80c8d9d1677556b3fb33b2da05988e97f6bf28903a044bb9fd64894.json
|
||||
│ │ │ ├── 7e136779a95d8e8e2ac6cad2192890254fd0d54d46b4f5514debba0f22618d75.json
|
||||
@@ -497,14 +570,19 @@ customerTicket
|
||||
│ │ │ ├── 84dedd4165eed360c4051690b2aa84f6ecc08f3f053f4129ca6f3e33c334ba35.json
|
||||
│ │ │ ├── 853fba8af82971f4fa56d957282d45d335d2e794f8e11001977161265af2ec79.json
|
||||
│ │ │ ├── 8595a5d566d43428818ee229e64f6b56453cc9ca61fd68be0b1c3c783d326315.json
|
||||
│ │ │ ├── 885114f15b5e41946539500e8df7e848021aa7f62c5a5bf097176f7b1cc741df.json
|
||||
│ │ │ ├── 88ade181c6611ec33d9bcf5fe739dc809d81875b6571b9f60f87dafb4f5941b2.json
|
||||
│ │ │ ├── 89273d53a77489be59de43475ae2b053374f33b01b5400a963cffe0804427f7a.json
|
||||
│ │ │ ├── 899b16856382d32c4e5b39d02cb251bdedaf35c5a81bede5c19bfaff092208e9.json
|
||||
│ │ │ ├── 8ab55a4f4f776ea75d01aaafdff3502c07b5b86e359789b22c590c7f988bf5f4.json
|
||||
│ │ │ ├── 8af38f9544549f250eff9b83a6c2a0e51f86d6ac51de0db24b48b7c654723a39.json
|
||||
│ │ │ ├── 8e4cb8d07f4d43e171550ff2fc9423efd032b911d7ffee51259e437d4dc32d4f.json
|
||||
│ │ │ ├── 91c2dc58207c4311391f22d524f461032f7ef3c9443d8f584edb2128966821bb.json
|
||||
│ │ │ ├── 91d83c62f39f219ccb6125502bf8e0a71a587dfc0a66d977a9bb93c8f2dc5dc6.json
|
||||
│ │ │ ├── 91f4a9afed43fafc3d99d7fe5eaa945cd5c89e29d4d6f6448b630c3e58db05e8.json
|
||||
│ │ │ ├── 9301321aa7fb1a7beff5518eb54fbf1bf7145e0aa76d3ed29ae4cb38f1057da9.json
|
||||
│ │ │ ├── 9326d87e36b2dc24e6173aa3346f46e7df9fc3353fdb409304af4363c286a41e.json
|
||||
│ │ │ ├── 94ea5a4bac836b2d19b08e446cf90eaea49b9eddc2591d261c384b3513e36f26.json
|
||||
│ │ │ ├── 9697c37cf3a88d2b4eb5419b2381951a9aca966172316db79e7b4c32557d5e87.json
|
||||
│ │ │ ├── 97506546de142970349e57e8a14064280e66a4fcdbced5dac55fc263630a2247.json
|
||||
│ │ │ ├── 97d8ebb860cbcd63be9ef80c6e2094e4a7ccba668b291d3c4815133d5fb8f37e.json
|
||||
@@ -518,37 +596,48 @@ customerTicket
|
||||
│ │ │ ├── 9cac12079d625e44ef3f62c6e4c9c7848f1cdd4d086c9251b3c81101cf4c114e.json
|
||||
│ │ │ ├── 9ce16f69c6b04f703adb24ec7498a2d2ecbf6f7524c459deb25111247e550698.json
|
||||
│ │ │ ├── 9d0e2a1fb14529aabd31c82c8d8f5543c0068366932934038ba93e234f291e22.json
|
||||
│ │ │ ├── 9d3a68c9991464131299b924086faf1e0c474c156019d681ba41e64d8100a7b1.json
|
||||
│ │ │ ├── 9ed5e291859abba38b07725bc6cf0275a1548e937888a5ba177674afaa48b96f.json
|
||||
│ │ │ ├── 9ef0e904ba6d1b295c75a1d5741851f571339a8e22c7de96bf5e2508f764ee43.json
|
||||
│ │ │ ├── a04a8ed5ca0ac9661dda4f6f369248937cf67e61cc982a1f17b24495263d53d6.json
|
||||
│ │ │ ├── a081526ffd8f52690e0834cc71e9d2bf987f949c2d0de68a6eb024063d97e64b.json
|
||||
│ │ │ ├── a130f47959c058751c2afdefa119ce88d7c59fa65883f7e0a69099a022033d4d.json
|
||||
│ │ │ ├── a1bd5a32741129c967a18413ae1be6bf3e20754251066d342d652a668ef95025.json
|
||||
│ │ │ ├── a2fea28bcbfaa240ed1524c5f422481c263eb6f010e0e06ddf8e45c7d1d88e6d.json
|
||||
│ │ │ ├── a3c770875ffabc18c595532501255df82b1a09246ee54b831e7c2221932d4f88.json
|
||||
│ │ │ ├── a3f339fe1ccb99a051c1a3bff70e7de0e2ee6b952db0aa2d6739e97331c29cd9.json
|
||||
│ │ │ ├── a4a27e7b3842713c361895d6d9c5fc9e4341cce71c5842f014748e1c1c23a197.json
|
||||
│ │ │ ├── a4a568f58efea076f16698bb9bb6279a92ab44167a6e97d424fff44900ff973e.json
|
||||
│ │ │ ├── a5231d68fe35e6b02ff4ff70e02a3229c08d9b6c2bae774a1b3227823b95e15a.json
|
||||
│ │ │ ├── a614593a975a3af1612ede797f69fd29220ee9251ab016d34d5e612a04696ce9.json
|
||||
│ │ │ ├── a698382a7159e40294490d6f668a46485f1a08fafdfbc52bfc616f362dc084a9.json
|
||||
│ │ │ ├── a7729f0b6bf20ded62ff7b6ee678fdaae9516dcd80c2a8f9e9f3d9299f6e22a0.json
|
||||
│ │ │ ├── a7b036e5bd51876694ebd883bd1b0a527c5f8409b48d4716612311e954a8cc0f.json
|
||||
│ │ │ ├── a8274003d8d4ef62b23c66e3baecf14eea260f99a4509411d58506f560c2b474.json
|
||||
│ │ │ ├── a836a06de0905683ff2aee057760fe6e4a3f9161d0cb60544fb7afc9ce782671.json
|
||||
│ │ │ ├── a93b84a0def4d2e0e97ff35032871b0ce64786ff468912aed21fd958b9fe782b.json
|
||||
│ │ │ ├── ab1648cc3b4d4bba5754e19eeb80ea160fd20207d5530ac371647168f9ed211a.json
|
||||
│ │ │ ├── abaaf1c3e88b939bf1833294f5233fa8e7c94091077495c424a0779298cc885d.json
|
||||
│ │ │ ├── ac454f6bfed5913148eb8c9caaa0e30f99bac381936b44612bdee8274bb27b29.json
|
||||
│ │ │ ├── ad1175b041ab42a2615856a41fbab394ae63ef9dd991901c0658c3fb0846ad3f.json
|
||||
│ │ │ ├── ae4d845b7155a0e8a73ab943ab7c4524cd6e73c3f90be008edb478c968d4fd9c.json
|
||||
│ │ │ ├── aeb59c5e1447023f9c49003f6bf4eb26a7b9a34ca5658f7ba2cde1b99e25ec76.json
|
||||
│ │ │ ├── aee78d4e87210ef46180d47e7015345bd66a7763cd140d84cd86539e720e8e67.json
|
||||
│ │ │ ├── afa89caafcdde5a4d7d85aad1db8c0abaddcebf884d84e8d23c50556b136b1a9.json
|
||||
│ │ │ ├── b0f297dddb05ea13b6bade486a2e88b6746c3c727b9ddfbd640942776b2e2cfb.json
|
||||
│ │ │ ├── b0f3c37fe7f5327da6c37161e405561df7eb920611513b0f7ec544e30763a312.json
|
||||
│ │ │ ├── b137ac0b92c8567809fb0323cd320fbf60c45ae6fad1bbdfb10d6efb22989f0b.json
|
||||
│ │ │ ├── b390f7bae9f80c48e703a6664d9de6ec9c141cdf8ac2f012d67ac2b4d8a25b4e.json
|
||||
│ │ │ ├── b396b497b11e135cfc19ccbee84e3e67df0e401a412f51c0336c89cb3609b5f5.json
|
||||
│ │ │ ├── b4446ca11a1f4f6ea45256e755ed56d6ba7c330278e15ead8cae2de28d5e18d8.json
|
||||
│ │ │ ├── b6e731246dc10854a0fa0455c3c5816e29887b1a8c436d518926e1c2a5e36239.json
|
||||
│ │ │ ├── b7014ccf0ecd3cdccb86d9ac37bd58c5ca00b59d2de58a194eb66ac139e14a5c.json
|
||||
│ │ │ ├── b7ddd6ec7284c45f0925a557b161afd7c9d01171b06c3f723ac4da599ecf47d2.json
|
||||
│ │ │ ├── b922fd1a31f3cf602f197c872f3bac6e69c79255e502ddcbf1626f846ec3baee.json
|
||||
│ │ │ ├── b96845015841d34aa0edcc02a6a51a4ec5f77bd6827d5e9bf8ca6f620c8de3dd.json
|
||||
│ │ │ ├── bb3d09cc5d1bc380e841e2eb19cb497391117101c73c358e6db03db4d6ae51e7.json
|
||||
│ │ │ ├── bb78c0a0d3cbee2cd3238fcc9a1c14cb8bd9ce97a78de60af3aa49f132f966a8.json
|
||||
│ │ │ ├── bf759ce9656625911599b351f64028b9bf0bcdcf4adc084e96e9cccc80bbe50c.json
|
||||
│ │ │ ├── c1060dca2a3f437f386324975be659215fe9f7e689137dc85e823879b462d11f.json
|
||||
│ │ │ ├── c10793a36312ddf2bd8ed9edbae5d67a19f54b78ac3cae01682d38c34f2bdf11.json
|
||||
│ │ │ ├── c199fcf03e3df0de15c0e7fa5f89fbd9649f2be4a257c8de9a7ebc69ce0d629a.json
|
||||
@@ -562,6 +651,7 @@ customerTicket
|
||||
│ │ │ ├── caac8d56f4ba7b3987d86c31357c6ffd2c4125d16e4637cb5a4568ac6eee29de.json
|
||||
│ │ │ ├── cb87cd70f59a8e0950529ae42a2229a259ff8b305e4319422fb1a9dbc41e63e2.json
|
||||
│ │ │ ├── ccd15ad60b63cd29e2bd2762133a74e2047a2894fd67206dff7f7c7fd082b399.json
|
||||
│ │ │ ├── cd137e2201aa02abbd8d57e28535e25842bbbe227f8e859dd45752d1e4217b0a.json
|
||||
│ │ │ ├── cd4804092002a20d5ed88c32b577b0868a0ef39e518c749dbadec455775917fb.json
|
||||
│ │ │ ├── ce52c1ffbf8db0d0b4ba85e0a07055258a8eb179db097f1df874e5be81aae49c.json
|
||||
│ │ │ ├── d083dc75811629ffa77ea30c1965e67ef3bc2bf65e5b20a1a35cb0f80a46949c.json
|
||||
@@ -578,10 +668,12 @@ customerTicket
|
||||
│ │ │ ├── d9516c3232e0f78225764bf4f9586c88932df925c45de71f4eb1c883a9ed5b5d.json
|
||||
│ │ │ ├── d9c81db9cf9f2fee8a4985367d33b1c0ab1d7e6901e85473070c3bfd6bfe32c6.json
|
||||
│ │ │ ├── daa187dac6877c746c02269141a7e7db178ac32a0b3a4bfeaaa8ed046b6dcffb.json
|
||||
│ │ │ ├── db0c84080aa8bbc15b1ebf23e820aa372f70076c2771e24fd7e6b1e8c663e881.json
|
||||
│ │ │ ├── dbaef2f4942a9f7ecde0afc10e4eebfe3a2809af1d8b76cb036bd417f19274b1.json
|
||||
│ │ │ ├── dc90ac5e8d6766f8001bc2a40d21c203717d7a9f469956c2f6d9bedf3ae51aea.json
|
||||
│ │ │ ├── dd795078450fe69f61a445d3cf1275420be57381b005597cb5003859b2025800.json
|
||||
│ │ │ ├── de1655ae1d42e56f0cdb8788c0f6ba1c7c6e6725e6ef5e80969b92ec3e68ac78.json
|
||||
│ │ │ ├── dea57fa4a89ef99a815bd9b1bb71b8475727a3d7d88161a9a696ebf636f29ed0.json
|
||||
│ │ │ ├── df2d803fc69084e91df819bda27a7e9f7303a40ff7a5d30d93fbc16daf5e2d45.json
|
||||
│ │ │ ├── dfc9279b0d9023afc1dbe55536fc1cd7a556a6a437775128b9e41615431b4733.json
|
||||
│ │ │ ├── e2f4f5c0bcb971ba3cb08a4ba14ed5d1ba6b5899fd36db1f5dcdbd2313cd318e.json
|
||||
@@ -613,26 +705,36 @@ customerTicket
|
||||
│ │ │ ├── f41de3fc65a6805cd39a47d713e1d224ba3c4eff476f0aeaf95b5e9b44cbc7ed.json
|
||||
│ │ │ ├── f4436ad45c3f2a27b4461333ae2ed21af157a3945e152fca7f43d640dc39f63b.json
|
||||
│ │ │ ├── f4780f9612e45a5bf32eb07fd006b874586bf10ee1528219fcad4f91304a6640.json
|
||||
│ │ │ ├── f48a1f4157bafecbddf10702c6bfd0ee33addb5752a4eddcdaac7b7e0cfa06e9.json
|
||||
│ │ │ ├── f4bd364e7dd2fde53b658ae0635bf5143f1f4cc5eaa22b1ad9449abcf98a9bd1.json
|
||||
│ │ │ ├── f542bc3aa4ea54e09e2fb68ecc937eed71e4307283d9cf7c2d94755c6937b7ad.json
|
||||
│ │ │ ├── f576af4a0cecb6cbaaf8afe5097913be0b7071e200fc924c918ea93446f00c91.json
|
||||
│ │ │ ├── f7e200f94ac2080e0885bf4a03e8afdb5cea2684a31a7c09e67dbf2f4fee6f51.json
|
||||
│ │ │ ├── f8769e3eeb23aebc01d72869a2bc6e635d7dc82440bc2b0b0e11e6b6b17f5109.json
|
||||
│ │ │ ├── f8fc55a028176d1ea9a1b580a8653d4c45ae7466445f37e6c23867f12821c44c.json
|
||||
│ │ │ ├── fa27401c5036759a68a650cf8097dd25e845aa3c385c1425fb2603455e6e9220.json
|
||||
│ │ │ ├── fadb933ee40737a7c1a11722b8c07663038f72148bd0e2c435ef319958670d9b.json
|
||||
│ │ │ ├── fb9736a4ef3aca74573f131c7cbd9f015d88dd755ffba3edd324aec95149175f.json
|
||||
│ │ │ ├── fbe224671138a9d843def175a438533c3d6cc144b2851f8a723e505918f2c22b.json
|
||||
│ │ │ ├── fca6b7e4c9f92410b2590539af80d6b499509255af4ef88282912c6c79a09330.json
|
||||
│ │ │ ├── fdd30ae7a839a46eb2f57681444d93258df5cbbdc40d2c24aae4caa8adef0936.json
|
||||
│ │ │ ├── fde577f1231f9b033560bfe075896efc6bf8d6079a030a159035584c7d39843b.json
|
||||
│ │ │ ├── fe032050843e4e1c28f5712205df74fa1698390169658c4ddc4555b6bcce8872.json
|
||||
│ │ │ ├── fe0c3eed2ff5c4b5d2116aba4494d599ae3c27004136e99f5d455f351281fa78.json
|
||||
│ │ │ ├── fe2f630d6d6dda85620260656f31e78db2ffcd0a8145f28e7b1713d851bd6baa.json
|
||||
│ │ │ ├── fe77fbd662d91d04f2efc69788d30e9859584c8d87d1fabd60ca66661568091e.json
|
||||
│ │ │ ├── fe93d0fb278c013183276f7bf5c5e2d0e62fe9c75f6695390f5994ec0b9c8dec.json
|
||||
│ │ │ └── ff809a93fdef0b7b8896c56bc9048369340da05981fc0435be179cfe930e7e4c.json
|
||||
│ │ ├── semantic
|
||||
│ │ │ └── pa567fc138e3a
|
||||
│ │ │ ├── 00825b46909baea25186d0f8351fc2c3fae63520319b5e7bede4ed3e9f53e54d.json
|
||||
│ │ │ ├── 04501034a062c75a4259223058c373b19c0b87c5ed44734076511726a012bd98.json
|
||||
│ │ │ ├── 067b09f64665879ffd6e39e47f23d7d969c007079c810536f77d816af6713957.json
|
||||
│ │ │ ├── 09f927cff0791d0e91365f50b4dba1a6c8cc0496ef70847d2ba9f1726092d00c.json
|
||||
│ │ │ ├── 0acd6559c9a499b9d542b239c9137b8f873cd77273cb7b388c0dc57d03f47d06.json
|
||||
│ │ │ ├── 0c8c80793f32ac4d0097cfee25dc9fdf0774d8ff334a3b2c4595e73bec49c4d5.json
|
||||
│ │ │ ├── 1285fb34ce6f72c9ab1cb3259622f3914330beab20c8e0404dbe1f46b9fd374a.json
|
||||
│ │ │ ├── 144fab3d71b01514d348460e1a00c80f30268fd6f9ac3ae9eb7ae70b4c5ce44c.json
|
||||
│ │ │ ├── 1516a3814f7846c4b73dd288d7a594bbdc6c4b72a9c177d3a169036715c0d8b0.json
|
||||
│ │ │ ├── 1753a19b402251a9e18e9c4113a5f1750a4168e9b0bedb06b4d6f35f2aa5980c.json
|
||||
│ │ │ ├── 195617b9b33d83896a4ffa16edb1d849e0da5cb7a1aa2b64d82d1bafed070f80.json
|
||||
@@ -644,26 +746,39 @@ customerTicket
|
||||
│ │ │ ├── 2233df96365dd9e332d4744056d02289ae3a003a6fb8f3342a205860e03f8665.json
|
||||
│ │ │ ├── 22569dac279084bd62c922dbc391f9a5a7cc2e1051ef043bde5cfc72df0ad864.json
|
||||
│ │ │ ├── 23f032018f0612c58670d22539577856b5479f6107878d558e5e062bac9da30b.json
|
||||
│ │ │ ├── 2715c71f1fa5ff6094b1b2044ce39de3608cc500c04fb154e62006d9a3e9d948.json
|
||||
│ │ │ ├── 28f2ce9d1005a70b7f624b04888ce9c511251d8da243d6c1c57a630eb14c1220.json
|
||||
│ │ │ ├── 292cc5e382e1a39fb922a669823ccc8be3955d41ebd377327d297ecdf1098a9d.json
|
||||
│ │ │ ├── 29a4f053d88deb1406e9e7fd20dac4fc1a3c518382af3adcb25fc0d6415e6c31.json
|
||||
│ │ │ ├── 29aca949b15e953360acae5ba86aac6c34ba8efddc55636fa05c28bd259ec66e.json
|
||||
│ │ │ ├── 2aca62132d5013370c679b9953bd8152e07acadbfc431f8e2b3d4257e02c8c73.json
|
||||
│ │ │ ├── 2b0ca95b4d2205396255ef9ad6880126d69de168f4349406796caf7cee711791.json
|
||||
│ │ │ ├── 2db4afd2f04cb1be70a822a9dfde1cbc197dea9ecc8d39cabf5eab10b778f10d.json
|
||||
│ │ │ ├── 3134c8aa643b2f1de403eb947f71f0c404194bf0c036212aed1bbf18972de75f.json
|
||||
│ │ │ ├── 3451c7089bb943a2571e2fd451467aa5e18fc642a64edaee34380e7b59f54baa.json
|
||||
│ │ │ ├── 362893de8fa037c65968c8d7d571986ee37da19609c9043fcae115812310fa0f.json
|
||||
│ │ │ ├── 379031c1044f245cdf1ded7504608f5861a11d5110845436678be4e121251062.json
|
||||
│ │ │ ├── 3a67afa45adca2d481cf2d40ef5330cd1302d052c83174c592ab5ea603989d6c.json
|
||||
│ │ │ ├── 3a706195f94277c85075fa51f990e934092ba6d36d28e46516bad78f18ea6140.json
|
||||
│ │ │ ├── 40fde7ee08169065b66efc00abbdc63c51cb968f8ad48fb21a32d44351878fc4.json
|
||||
│ │ │ ├── 4466b520ab1a4eb44abdd915e8a1cba43d72c8d08bf93ebea81d8e8feadef93e.json
|
||||
│ │ │ ├── 481e0ef213e9eb7ac3679e2fdf54cfe7bafde152b3de57b0c9306416529ab904.json
|
||||
│ │ │ ├── 485a9d1cf66322f5cb2886aac1f388a2846aba6fd444b87b251edb03c51ff00a.json
|
||||
│ │ │ ├── 4a72da5bbf738460d04c3c799474a4a7093821d69fff2b2efcca1dd78f947652.json
|
||||
│ │ │ ├── 4bf46c6c0f27e14312f7c1311bca1bf5e1bfee29ea5cb35e6f94142141985d23.json
|
||||
│ │ │ ├── 4e56ef4c75a0ef1d175a72a6980ba047bb4aa51d49b8a17471d8f342a0b779c0.json
|
||||
│ │ │ ├── 527fef297bee36f729186cbfe4bee9b0a41ad7bc98896815e2058c5348b76706.json
|
||||
│ │ │ ├── 5533327da089db48f0050a2d48af495de46f562ea2e29fcfdf3a527ece013770.json
|
||||
│ │ │ ├── 568acfd2839a06fe34d1c68f634629e1671bbaf64f45c2e1a628827249a03e1e.json
|
||||
│ │ │ ├── 57240e5b1ee2390b9f26b44f11253b97b1c6b279affdedc34cbeeadc74181504.json
|
||||
│ │ │ ├── 5931c8253737ddb54edc00f7f0edb0dfcf52d0f4b34d1c94e05e918a991505ec.json
|
||||
│ │ │ ├── 5aa8fb6425a50cdd0fdd3fe862960efbb179a8098df347292940f564737404ef.json
|
||||
│ │ │ ├── 5b5bd6e41d3308af6e19908a392f853875cd8a1f1a10f6e195d21aeab1a6a586.json
|
||||
│ │ │ ├── 647cd99ee01ccb860fc776807d8116a3849db7316920814fd004970cf61c38f1.json
|
||||
│ │ │ ├── 660dafe22e562cecf6aaf4afdf658996dd024a1436c57e9815c82caa0c3e1782.json
|
||||
│ │ │ ├── 68ad05b476d72c3361a7900c669d221d4b6c629e8697c360837d4faa857030e5.json
|
||||
│ │ │ ├── 697c16d4c7fadca75d73c67dcfea3aefc8c3b91394718b1d09188e7e41d8811a.json
|
||||
│ │ │ ├── 69ffb09e8b5b90bd7b99ae5182e4f20c9e89fa9498f5288b69931b8fdb9f4de0.json
|
||||
│ │ │ ├── 6d5ac5656791048028b6bec9a94588cede8731bd7ca2151f13f7819e2819f2e7.json
|
||||
│ │ │ ├── 7322f205d129029de2b4bb0be04fc6de2798fa17122738ae5d5f300e66ee5409.json
|
||||
│ │ │ ├── 74b74470177ae910424d02aaef051f094b6bd2d41b2f68dca025f2c11c0cfe2c.json
|
||||
@@ -679,6 +794,7 @@ customerTicket
|
||||
│ │ │ ├── 8a7c3cffb298a6af2f6f497e70513330244117b87649ee61c964461dbcaa05e8.json
|
||||
│ │ │ ├── 8d6f6490e7bc2e9660f5e0cc8ce68371ec092276a3eca9876d198989ed1d3ffb.json
|
||||
│ │ │ ├── 8e62122fceb99e49a7dd46a86cc46cbe3218cd86773d310fe15135a6ad4d6998.json
|
||||
│ │ │ ├── 8f1b537daa4cfb03cac6156351594ddc5310b32b66d3e1758f21621a7d35c491.json
|
||||
│ │ │ ├── 902240b9e3adc33b9d30ac42dfffbe44368e357a3658728c8d535e47fa5650a6.json
|
||||
│ │ │ ├── 908e0129cefe129d0b562243460fe20793c84876244cef2e99ca1becf8cf6a1f.json
|
||||
│ │ │ ├── 9281775c35930ece9d1382446b2e7d54cb3ec777fbc045ce46cdce5287f589d9.json
|
||||
@@ -689,8 +805,14 @@ customerTicket
|
||||
│ │ │ ├── a7b56fb6165dabba4d71f0757463855dfca3d85741975d62820b05abee14890f.json
|
||||
│ │ │ ├── a8274003d8d4ef62b23c66e3baecf14eea260f99a4509411d58506f560c2b474.json
|
||||
│ │ │ ├── a9e40d5d4e9e6a566950d6e8fad456d9703444e6e6a3aaac4022462fd2a6c45d.json
|
||||
│ │ │ ├── aa93e30f61d5ebc3d96127c430b5179e380e1af4aa9b620d472b3272fa8b39e0.json
|
||||
│ │ │ ├── ab73eedab77367896980e569b7dc44bfc0cd32b02e172584ff2a649948a24a80.json
|
||||
│ │ │ ├── aba1d8f31b0657f74255ef0de96fd7ec4b17c71de9f7d8141980dfeb103d89f3.json
|
||||
│ │ │ ├── af1fde0ad9ac2361e390c1f26e8b0962bbbf815a6be52bfa1c284feb3fea89f0.json
|
||||
│ │ │ ├── afebb8ab557f5b8220fa23344ed71d088e3b694f2bd38c4dc9d6102f3ed6bb56.json
|
||||
│ │ │ ├── b22c98e94a1e8c2b87a433d7ed250dd2fecd29126618e1846df822e951fd9a95.json
|
||||
│ │ │ ├── b40ac508988387f4983259dd227b1d883f2979f7cfa77ca18b250d18c0b25f8c.json
|
||||
│ │ │ ├── b5120a5f0b8d6df0debf9ade8b271f625fc152ed18896d3941246aa727b4e0b8.json
|
||||
│ │ │ ├── b631ef1705c4bbcc263ee039213e6929538d85401b648d0a675849fbeb60db11.json
|
||||
│ │ │ ├── bc16443a4c12a2ca6a8a74c6378c2f66b27425fd52be8ca5c938c2141c886a53.json
|
||||
│ │ │ ├── bd8de3be72dbe84774acd42c873075c4ca1cf14924cc0e20baa5b04a5853065d.json
|
||||
@@ -699,6 +821,8 @@ customerTicket
|
||||
│ │ │ ├── ca98ecfba0b2ad600c63c4f743e41528e2add0a2b48774b065aa0f60dcd905e3.json
|
||||
│ │ │ ├── cf5a494e78799d9b7557e6021056f1f896a5e8d8378b85d5a4879eeaceb8bec7.json
|
||||
│ │ │ ├── d2a670f53c70d2f82ff69615cfec3ab85a8b106dbd812f47cd3f0351c63ad69a.json
|
||||
│ │ │ ├── d2e91825bbf307d7ea4572efa370ce35846db137b4399684cdb0e21bef9f3996.json
|
||||
│ │ │ ├── d6edb416c765d3de28e41c59b685ce73df501e0ba7a0c0767b449c2d276c0395.json
|
||||
│ │ │ ├── ddf9f939efdbd2f15fe815fd608b3048eb2c439343c9700f446739f72582a91b.json
|
||||
│ │ │ ├── e00a1732e6e7df91a7aabb9bd609e2a5985d5cf7215635cde4a7495a0112efec.json
|
||||
│ │ │ ├── e10d4f4e825fa6fd025ab71d55be4304798b707016c9b0b0f6cc0da6589a8435.json
|
||||
@@ -707,7 +831,9 @@ customerTicket
|
||||
│ │ │ ├── e5f6e9aa1524a7e8c45c4ba6043bbf5798a8f8bec760ed991d69359cd416a08c.json
|
||||
│ │ │ ├── ea56445d1314587e18b1992d9599da03e1419ea7bc4929854b9039d6947de33b.json
|
||||
│ │ │ ├── eb0b530666894887e1b06f70f6dc329de81cf71265ae51d4a8ec138a41d67d86.json
|
||||
│ │ │ ├── eb9c8d1a9cff4e5c104daa3311dd2d2a9d410657194c46986e3e5614549d59e5.json
|
||||
│ │ │ ├── ec9174249dd1b030999fd9c188e1713659650afd92dbe8fcd7a1f29666023a81.json
|
||||
│ │ │ ├── ee37ba38f532af1bbe5bc8e1e9085c3be2eb5e152634b13765ee5557921a7ce5.json
|
||||
│ │ │ ├── f60e55eafdca5a62a95829310f4cc0a2a33e91d90b93f95c5b88fe3d6deb13c5.json
|
||||
│ │ │ └── ffc1f756026bddae9a6ad7a82aca9345a23c2762d06107b344bf35f35092653d.json
|
||||
│ │ ├── last_query_stamp
|
||||
@@ -770,7 +896,8 @@ customerTicket
|
||||
│ │ ├── query_20260916_111348_12184b5e_دنبال_کدهای_خطا_سامانه_0098sms_بگرد_در_source_کد_ا.md
|
||||
│ │ ├── query_20260917_060706_47ef388c_اصلاح_رابط_موبایل_صفحه_جزئیات_تیکت_کارشناس_و_magic.md
|
||||
│ │ ├── query_20260917_063554_1824eb56_پس_از_باز_شدن_کیبورد_موبایل__کادر_نوشتن_پاسخ_تیکت.md
|
||||
│ │ └── query_20260923_131543_e5730a0b_how_do_staff_ticket_sms_status__failed_delivery__a.md
|
||||
│ │ ├── query_20260923_131543_e5730a0b_how_do_staff_ticket_sms_status__failed_delivery__a.md
|
||||
│ │ └── query_20261006_111656_eab3ac96_how_do_payment_approval_policy_and_receipt_approva.md
|
||||
│ ├── reflections
|
||||
│ │ └── LESSONS.md
|
||||
│ ├── .graphify_analysis.json
|
||||
@@ -785,13 +912,72 @@ customerTicket
|
||||
│ ├── .graphify_semantic.json
|
||||
│ ├── .html_semantic_extract.json
|
||||
│ ├── .vocab.txt
|
||||
│ ├── chaharsu-home-mobile.png
|
||||
│ ├── chaharsu-prototype_server.py
|
||||
│ ├── chaharsu-ui-bank-new-desktop.png
|
||||
│ ├── chaharsu-ui-bank-new-mobile.png
|
||||
│ ├── chaharsu-ui-home-desktop.png
|
||||
│ ├── chaharsu-ui-home-mobile.png
|
||||
│ ├── chaharsu-ui-notice-new-desktop.png
|
||||
│ ├── chaharsu-ui-notice-new-mobile.png
|
||||
│ ├── chaharsu-ui-receipt-customer-desktop.png
|
||||
│ ├── chaharsu-ui-receipt-customer-mobile.png
|
||||
│ ├── chaharsu-ui-staff-banks-desktop.png
|
||||
│ ├── chaharsu-ui-staff-banks-mobile.png
|
||||
│ ├── chaharsu-ui-staff-payments-desktop.png
|
||||
│ ├── chaharsu-ui-staff-payments-mobile.png
|
||||
│ ├── chaharsu-ui-staff-receipt-desktop.png
|
||||
│ ├── chaharsu-ui-staff-receipt-mobile.png
|
||||
│ ├── chaharsu_merge_audit.json
|
||||
│ ├── chaharsu_semantic.json
|
||||
│ ├── CHAHARSU_SEMANTIC_REPORT.md
|
||||
│ ├── cost.json
|
||||
│ ├── customer-bank-accounts-1440.png
|
||||
│ ├── customer-bank-accounts-320.png
|
||||
│ ├── customer-bank-accounts-375.png
|
||||
│ ├── customer-payment-receipt-1440.png
|
||||
│ ├── customer-payment-receipt-320.png
|
||||
│ ├── customer-payment-receipt-375.png
|
||||
│ ├── db-before-announcement-fix-20261004-115635.sqlite3
|
||||
│ ├── db-before-payment-links-20261006.sqlite3
|
||||
│ ├── db-before-staff-sharing.sqlite3
|
||||
│ ├── db-pre-chaharsu.sqlite3
|
||||
│ ├── graph.html
|
||||
│ ├── graph.json
|
||||
│ ├── GRAPH_REPORT.md
|
||||
│ ├── identifier-generation-1440.png
|
||||
│ ├── identifier-generation-375.png
|
||||
│ ├── manifest.json
|
||||
│ ├── notice-page-customer-0-1440.png
|
||||
│ ├── notice-page-customer-0-320.png
|
||||
│ ├── notice-page-customer-1-1440.png
|
||||
│ ├── notice-page-customer-1-320.png
|
||||
│ ├── notice-page-customer-2-1440.png
|
||||
│ ├── notice-page-customer-2-320.png
|
||||
│ ├── notice-page-staff-0-1440.png
|
||||
│ ├── notice-page-staff-0-320.png
|
||||
│ ├── notice-page-staff-1-1440.png
|
||||
│ ├── notice-page-staff-1-320.png
|
||||
│ ├── notice-page-staff-2-1440.png
|
||||
│ ├── notice-page-staff-2-320.png
|
||||
│ ├── payment-link-form-1440.png
|
||||
│ ├── payment-link-form-320.png
|
||||
│ ├── payment-link-form-390.png
|
||||
│ ├── payment-link-notice-1440.png
|
||||
│ ├── payment-link-notice-320.png
|
||||
│ ├── payment-link-notice-390.png
|
||||
│ ├── payment-review-customer-0-1440.png
|
||||
│ ├── payment-review-customer-0-320.png
|
||||
│ ├── payment-review-customer-1-1440.png
|
||||
│ ├── payment-review-customer-1-320.png
|
||||
│ ├── payment-review-customer-2-1440.png
|
||||
│ ├── payment-review-customer-2-320.png
|
||||
│ ├── payment-review-staff-0-1440.png
|
||||
│ ├── payment-review-staff-0-320.png
|
||||
│ ├── payment-review-staff-1-1440.png
|
||||
│ ├── payment-review-staff-1-320.png
|
||||
│ ├── payment-review-staff-2-1440.png
|
||||
│ ├── payment-review-staff-2-320.png
|
||||
│ ├── shared-ticket-metadata-1280.png
|
||||
│ ├── shared-ticket-metadata-390.png
|
||||
│ ├── staff-inline-desktop-closed.png
|
||||
@@ -806,10 +992,21 @@ customerTicket
|
||||
│ ├── staff-sharing-mobile-audit.png
|
||||
│ ├── staff-sharing-mobile-message.png
|
||||
│ ├── staff-sharing-mobile-ticket.png
|
||||
│ ├── ticket-notices-1440.png
|
||||
│ ├── ticket-notices-390.png
|
||||
│ ├── unified-shares-message-1280.png
|
||||
│ ├── unified-shares-message-390.png
|
||||
│ ├── unified-shares-queue-1280.png
|
||||
│ └── unified-shares-queue-390.png
|
||||
│ ├── unified-shares-queue-390.png
|
||||
│ ├── verify_chaharsu_forms.py
|
||||
│ ├── verify_chaharsu_live.py
|
||||
│ ├── verify_chaharsu_ui.py
|
||||
│ ├── verify_customer_finance.py
|
||||
│ ├── verify_identifier_ui.py
|
||||
│ ├── verify_notice_pages.py
|
||||
│ ├── verify_payment_links.py
|
||||
│ ├── verify_payment_review.py
|
||||
│ └── verify_ticket_notices.py
|
||||
├── production-seed
|
||||
│ ├── data.json.gz
|
||||
│ ├── manifest.json
|
||||
@@ -817,10 +1014,16 @@ customerTicket
|
||||
├── static
|
||||
│ ├── css
|
||||
│ │ ├── app.css
|
||||
│ │ ├── chaharsu.css
|
||||
│ │ ├── customer-dock.css
|
||||
│ │ ├── customer-profile.css
|
||||
│ │ ├── customer-ticket-wizard.css
|
||||
│ │ ├── customer-tickets.css
|
||||
│ │ ├── dispatches.css
|
||||
│ │ ├── enhancements.css
|
||||
│ │ ├── magic-link-confirmation.css
|
||||
│ │ ├── operations.css
|
||||
│ │ ├── payment-link.css
|
||||
│ │ ├── staff-sharing.css
|
||||
│ │ ├── tailwind.css
|
||||
│ │ ├── ticket-composer-v2.css
|
||||
@@ -836,14 +1039,25 @@ customerTicket
|
||||
│ │ ├── favicon.ico
|
||||
│ │ └── pwa-icon.png
|
||||
│ ├── js
|
||||
│ │ ├── chaharsu
|
||||
│ │ │ ├── bank-selection.js
|
||||
│ │ │ ├── customer-picker.js
|
||||
│ │ │ ├── customer-receipt.js
|
||||
│ │ │ ├── deposit-identifier.js
|
||||
│ │ │ ├── payments.js
|
||||
│ │ │ ├── receipt-amount.js
|
||||
│ │ │ └── receipt-form.js
|
||||
│ │ ├── attachment-uploader.js
|
||||
│ │ ├── chat-coach.js
|
||||
│ │ ├── contact-group-selector.js
|
||||
│ │ ├── conversation-read.js
|
||||
│ │ ├── conversation.js
|
||||
│ │ ├── customer-ticket-wizard.js
|
||||
│ │ ├── image-viewer.js
|
||||
│ │ ├── location-picker.js
|
||||
│ │ ├── magic-link-confirmation.js
|
||||
│ │ ├── pwa.js
|
||||
│ │ ├── sms-template-tags.js
|
||||
│ │ ├── staff-customer-picker.js
|
||||
│ │ ├── staff-sharing.js
|
||||
│ │ ├── ticket-intent-search.js
|
||||
@@ -863,6 +1077,7 @@ customerTicket
|
||||
│ ├── accounts
|
||||
│ │ ├── customer_login.html
|
||||
│ │ ├── customer_password_login.html
|
||||
│ │ ├── magic_link_confirmation.html
|
||||
│ │ ├── otp.html
|
||||
│ │ ├── profile.html
|
||||
│ │ └── staff_login.html
|
||||
@@ -871,10 +1086,37 @@ customerTicket
|
||||
│ │ └── auth_footer.html
|
||||
│ ├── messaging
|
||||
│ │ └── revisions.html
|
||||
│ ├── payments
|
||||
│ │ ├── _customer_identity.html
|
||||
│ │ ├── _customer_picker.html
|
||||
│ │ ├── _customer_receipt_form.html
|
||||
│ │ ├── _field.html
|
||||
│ │ ├── _icon.html
|
||||
│ │ ├── _link_status.html
|
||||
│ │ ├── _linked_receipts.html
|
||||
│ │ ├── _notice.html
|
||||
│ │ ├── _notice_shortcut.html
|
||||
│ │ ├── _pagination.html
|
||||
│ │ ├── _receipt_field.html
|
||||
│ │ ├── _ticket_notices.html
|
||||
│ │ ├── banks.html
|
||||
│ │ ├── base.html
|
||||
│ │ ├── customer_notice_detail.html
|
||||
│ │ ├── desk.html
|
||||
│ │ ├── home.html
|
||||
│ │ ├── link_confirmation.html
|
||||
│ │ ├── link_unavailable.html
|
||||
│ │ ├── notice_detail.html
|
||||
│ │ ├── notices.html
|
||||
│ │ ├── receipt_detail.html
|
||||
│ │ ├── receipt_form.html
|
||||
│ │ ├── receipt_list.html
|
||||
│ │ └── staff_form.html
|
||||
│ ├── pwa
|
||||
│ │ └── service-worker.js
|
||||
│ ├── tickets
|
||||
│ │ ├── _announcement_status.html
|
||||
│ │ ├── _customer_dock.html
|
||||
│ │ ├── _image_viewer.html
|
||||
│ │ ├── _immediate_announcement.html
|
||||
│ │ ├── _messages.html
|
||||
@@ -906,6 +1148,19 @@ customerTicket
|
||||
│ │ └── staff_sms_templates.html
|
||||
│ └── base.html
|
||||
├── test-results
|
||||
│ ├── customer-tickets-design
|
||||
│ │ ├── chaharsu-1440.png
|
||||
│ │ ├── chaharsu-320.png
|
||||
│ │ ├── chaharsu-393.png
|
||||
│ │ ├── new-1440.png
|
||||
│ │ ├── new-320.png
|
||||
│ │ ├── new-393.png
|
||||
│ │ ├── profile-1440.png
|
||||
│ │ ├── profile-320.png
|
||||
│ │ ├── profile-393.png
|
||||
│ │ ├── tickets-1440.png
|
||||
│ │ ├── tickets-320.png
|
||||
│ │ └── tickets-393.png
|
||||
│ ├── e2e
|
||||
│ │ ├── design-prototypes_ticket-new-wizard_test_prototype_e2e.py__test_ticket_wizard_progressive_flow_preview_edit_and_attachment_removal
|
||||
│ │ │ ├── failure.png
|
||||
@@ -922,6 +1177,15 @@ customerTicket
|
||||
│ │ ├── tests_e2e_test_chat_coach.py__test_staff_sees_chat_guidance_after_two_rapid_replies
|
||||
│ │ │ ├── failure.png
|
||||
│ │ │ └── trace.zip
|
||||
│ │ ├── tests_e2e_test_customer_profile_design.py__test_profile_design_navigation_validation_and_save[1440]
|
||||
│ │ │ ├── failure.png
|
||||
│ │ │ └── trace.zip
|
||||
│ │ ├── tests_e2e_test_customer_profile_design.py__test_profile_design_navigation_validation_and_save[320]
|
||||
│ │ │ ├── failure.png
|
||||
│ │ │ └── trace.zip
|
||||
│ │ ├── tests_e2e_test_customer_profile_design.py__test_profile_design_navigation_validation_and_save[393]
|
||||
│ │ │ ├── failure.png
|
||||
│ │ │ └── trace.zip
|
||||
│ │ ├── tests_e2e_test_customer_unit_categories_e2e.py__test_customer_natural_language_search_selects_helpful_catalog_route[invoice]
|
||||
│ │ │ ├── failure.png
|
||||
│ │ │ └── trace.zip
|
||||
@@ -934,12 +1198,36 @@ customerTicket
|
||||
│ │ ├── tests_e2e_test_customer_unit_categories_e2e.py__test_customer_switches_unit_and_sees_its_categories
|
||||
│ │ │ ├── failure.png
|
||||
│ │ │ └── trace.zip
|
||||
│ │ ├── tests_e2e_test_magic_link_confirmation_e2e.py__test_cancel_returns_to_logged_in_account
|
||||
│ │ │ ├── failure.png
|
||||
│ │ │ └── trace.zip
|
||||
│ │ ├── tests_e2e_test_magic_link_confirmation_e2e.py__test_confirmation_form_works_without_its_javascript
|
||||
│ │ │ ├── failure.png
|
||||
│ │ │ └── trace.zip
|
||||
│ │ ├── tests_e2e_test_magic_link_confirmation_e2e.py__test_confirmed_logout_opens_ticket_with_readable_responsive_page[1280]
|
||||
│ │ │ ├── failure.png
|
||||
│ │ │ └── trace.zip
|
||||
│ │ ├── tests_e2e_test_magic_link_confirmation_e2e.py__test_confirmed_logout_opens_ticket_with_readable_responsive_page[320]
|
||||
│ │ │ ├── failure.png
|
||||
│ │ │ └── trace.zip
|
||||
│ │ ├── tests_e2e_test_magic_link_confirmation_e2e.py__test_confirmed_logout_opens_ticket_with_readable_responsive_page[390]
|
||||
│ │ │ ├── failure.png
|
||||
│ │ │ └── trace.zip
|
||||
│ │ ├── tests_e2e_test_message_read_receipts_e2e.py__test_revocation_wins_over_a_poll_response_already_in_flight
|
||||
│ │ │ ├── failure.png
|
||||
│ │ │ └── trace.zip
|
||||
│ │ ├── tests_e2e_test_mobile_ticket_viewport.py__test_magic_link_mobile_viewport_keeps_composer_messages_and_credit_visible
|
||||
│ │ │ ├── failure.png
|
||||
│ │ │ └── trace.zip
|
||||
│ │ ├── tests_e2e_test_mobile_ticket_viewport.py__test_staff_ticket_mobile_viewport_survives_open_close_and_rotation
|
||||
│ │ │ ├── failure.png
|
||||
│ │ │ └── trace.zip
|
||||
│ │ ├── tests_e2e_test_notice_deletion_e2e.py__test_notice_delete_requires_review_and_confirmation[1440]
|
||||
│ │ │ ├── failure.png
|
||||
│ │ │ └── trace.zip
|
||||
│ │ ├── tests_e2e_test_notice_deletion_e2e.py__test_notice_delete_requires_review_and_confirmation[320]
|
||||
│ │ │ ├── failure.png
|
||||
│ │ │ └── trace.zip
|
||||
│ │ ├── tests_e2e_test_organization_permissions_e2e.py__test_e2e_02_contact_group_picker_excludes_other_units
|
||||
│ │ │ ├── failure.png
|
||||
│ │ │ └── trace.zip
|
||||
@@ -952,6 +1240,24 @@ customerTicket
|
||||
│ │ ├── tests_e2e_test_organization_permissions_e2e.py__test_unit_change_renders_experts_when_a_legacy_cached_payload_has_no_queue
|
||||
│ │ │ ├── failure.png
|
||||
│ │ │ └── trace.zip
|
||||
│ │ ├── tests_e2e_test_payment_links_e2e.py__test_sms_payment_without_login_preserves_copy_and_submission[1440-True]
|
||||
│ │ │ ├── failure.png
|
||||
│ │ │ └── trace.zip
|
||||
│ │ ├── tests_e2e_test_payment_links_e2e.py__test_sms_payment_without_login_preserves_copy_and_submission[320-True]
|
||||
│ │ │ ├── failure.png
|
||||
│ │ │ └── trace.zip
|
||||
│ │ ├── tests_e2e_test_payment_links_e2e.py__test_sms_payment_without_login_preserves_copy_and_submission[390-False]
|
||||
│ │ │ ├── failure.png
|
||||
│ │ │ └── trace.zip
|
||||
│ │ ├── tests_e2e_test_payments_e2e.py__test_customer_search_disambiguation_and_context_shortcuts
|
||||
│ │ │ ├── failure.png
|
||||
│ │ │ └── trace.zip
|
||||
│ │ ├── tests_e2e_test_payments_e2e.py__test_independent_receipt_and_two_person_approval
|
||||
│ │ │ ├── failure.png
|
||||
│ │ │ └── trace.zip
|
||||
│ │ ├── tests_e2e_test_payments_e2e.py__test_staff_issues_identifier_notice_and_customer_sends_linked_receipt
|
||||
│ │ │ ├── failure.png
|
||||
│ │ │ └── trace.zip
|
||||
│ │ ├── tests_e2e_test_public_ticket_workflows_e2e.py__test_e2e_07_sales_general_queue_creates_verifiable_ticket
|
||||
│ │ │ ├── failure.png
|
||||
│ │ │ └── trace.zip
|
||||
@@ -997,11 +1303,18 @@ customerTicket
|
||||
│ │ ├── 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
|
||||
│ │ └── trace.zip
|
||||
│ │ ├── tests_e2e_test_staff_sharing_e2e.py__test_staff_can_share_one_message_track_opening_and_revoke
|
||||
│ │ │ ├── failure.png
|
||||
│ │ │ └── trace.zip
|
||||
│ │ ├── tests_e2e_test_ticket_notices_e2e.py__test_ticket_notices_toggle_and_customer_navigation[390]
|
||||
│ │ │ ├── failure.png
|
||||
│ │ │ └── trace.zip
|
||||
│ │ ├── magic-confirmation-1280.png
|
||||
│ │ ├── magic-confirmation-320.png
|
||||
│ │ └── magic-confirmation-390.png
|
||||
│ ├── customer-chat-guidance.png
|
||||
│ └── staff-chat-guidance.png
|
||||
│ ├── staff-chat-guidance.png
|
||||
│ └── ui_design_review.py
|
||||
├── tests
|
||||
│ ├── e2e
|
||||
│ │ ├── pages
|
||||
@@ -1009,20 +1322,36 @@ customerTicket
|
||||
│ │ │ ├── auth.py
|
||||
│ │ │ ├── base.py
|
||||
│ │ │ ├── customer_new_ticket.py
|
||||
│ │ │ ├── customer_profile.py
|
||||
│ │ │ ├── customer_tickets.py
|
||||
│ │ │ ├── magic_link.py
|
||||
│ │ │ ├── message_reads.py
|
||||
│ │ │ ├── notice_deletion.py
|
||||
│ │ │ ├── payment_link.py
|
||||
│ │ │ ├── payments.py
|
||||
│ │ │ ├── sharing.py
|
||||
│ │ │ ├── ticket_notices.py
|
||||
│ │ │ └── tickets.py
|
||||
│ │ ├── config.py
|
||||
│ │ ├── conftest.py
|
||||
│ │ ├── README.md
|
||||
│ │ ├── test_chat_coach.py
|
||||
│ │ ├── test_customer_profile_design.py
|
||||
│ │ ├── test_customer_tickets_design.py
|
||||
│ │ ├── test_customer_unit_categories_e2e.py
|
||||
│ │ ├── test_external_environment_smoke.py
|
||||
│ │ ├── test_magic_link_confirmation_e2e.py
|
||||
│ │ ├── test_message_read_receipts_e2e.py
|
||||
│ │ ├── test_mobile_ticket_viewport.py
|
||||
│ │ ├── test_notice_deletion_e2e.py
|
||||
│ │ ├── test_organization_permissions_e2e.py
|
||||
│ │ ├── test_payment_links_e2e.py
|
||||
│ │ ├── test_payments_e2e.py
|
||||
│ │ ├── test_public_ticket_workflows_e2e.py
|
||||
│ │ ├── test_pwa_versioning.py
|
||||
│ │ ├── test_staff_operations_ui.py
|
||||
│ │ └── test_staff_sharing_e2e.py
|
||||
│ │ ├── test_staff_sharing_e2e.py
|
||||
│ │ └── test_ticket_notices_e2e.py
|
||||
│ ├── js
|
||||
│ │ └── chat-coach.test.mjs
|
||||
│ ├── test_announcements.py
|
||||
@@ -1033,7 +1362,14 @@ customerTicket
|
||||
│ ├── test_domain.py
|
||||
│ ├── test_env.py
|
||||
│ ├── test_jalali_templates.py
|
||||
│ ├── test_magic_link_confirmation.py
|
||||
│ ├── test_message_read_receipts.py
|
||||
│ ├── test_organizational_ticketing.py
|
||||
│ ├── test_payment_concurrency.py
|
||||
│ ├── test_payment_links.py
|
||||
│ ├── test_payment_notice_concurrency.py
|
||||
│ ├── test_payment_notice_deletion.py
|
||||
│ ├── test_payments.py
|
||||
│ ├── test_permission_architecture.py
|
||||
│ ├── test_persian_text.py
|
||||
│ ├── test_production_seed.py
|
||||
@@ -1050,7 +1386,9 @@ customerTicket
|
||||
├── .env.local.example
|
||||
├── .gitignore
|
||||
├── .npmrc
|
||||
├── .test-db.sqlite3
|
||||
├── AGENTS.md
|
||||
├── alghortim_shenase.py
|
||||
├── db.sqlite3
|
||||
├── docker-compose.yml
|
||||
├── Dockerfile
|
||||
|
||||
@@ -15,6 +15,26 @@ class ConsoleBaleProvider:
|
||||
return NotificationResult(True, "console")
|
||||
|
||||
class BaleBotProvider:
|
||||
def send_photo(self, chat_id, photo, caption='', *, token=None, filename='receipt.jpg', mime_type='image/jpeg'):
|
||||
"""Upload private evidence bytes; do not expose a media URL or log a bot token."""
|
||||
token = settings.BALE_BOT_TOKEN if token is None else token
|
||||
if not token or not chat_id:
|
||||
return NotificationResult(False, error='توکن یا گروه بله تنظیم نشده است.')
|
||||
field, method = ('photo', 'sendPhoto') if mime_type in ('image/jpeg', 'image/png') else ('document', 'sendDocument')
|
||||
try:
|
||||
response = requests.post(f"{settings.BALE_API_BASE.rstrip('/')}/bot{token}/{method}",
|
||||
data={'chat_id': chat_id, 'caption': caption}, files={field: (filename, photo, mime_type)},
|
||||
timeout=settings.INTEGRATION_HTTP_TIMEOUT_SECONDS)
|
||||
if not response.ok:
|
||||
return NotificationResult(False, error=f'خطای HTTP بله: {response.status_code}')
|
||||
data = response.json()
|
||||
result = data.get('result') if isinstance(data, dict) else None
|
||||
if data.get('ok') is not True or not isinstance(result, dict) or not result.get('message_id'):
|
||||
return NotificationResult(False, error='بله ارسال تصویر را تأیید نکرد.')
|
||||
return NotificationResult(True, str(result['message_id']))
|
||||
except (requests.RequestException, ValueError, AttributeError) as error:
|
||||
return NotificationResult(False, error=f'خطا در ارتباط با بله ({type(error).__name__})')
|
||||
|
||||
def send_message(self, chat_id, text):
|
||||
if not chat_id: return NotificationResult(False, error="Bale chat id is not configured")
|
||||
response = requests.post(f"{settings.BALE_API_BASE.rstrip('/')}/bot{settings.BALE_BOT_TOKEN}/sendMessage", json={"chat_id": chat_id, "text": text, "disable_web_page_preview": True}, timeout=settings.INTEGRATION_HTTP_TIMEOUT_SECONDS)
|
||||
|
||||
@@ -85,6 +85,9 @@ def _prepare_sms(job):
|
||||
|
||||
|
||||
def _send_sms(job):
|
||||
if job.event_type == 'PAYMENT_RECEIPT_RECEIVED':
|
||||
from apps.payments.receipt_alerts import send_receipt_alert
|
||||
return send_receipt_alert(job, sms_provider_factory=get_sms_provider), None
|
||||
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)
|
||||
@@ -107,6 +110,9 @@ def _send_sms(job):
|
||||
return get_sms_provider().send_message(job.customer.mobile, message), issued
|
||||
|
||||
def _send_bale(job):
|
||||
if job.event_type == 'PAYMENT_RECEIPT_RECEIVED':
|
||||
from apps.payments.receipt_alerts import send_receipt_alert
|
||||
return send_receipt_alert(job)
|
||||
unit_chat_id = (getattr(job.ticket.owning_unit, "bale_chat_id", "") or "").strip()
|
||||
if job.event_type == "NEW_CUSTOMER_TICKET":
|
||||
return get_bale_provider().send_message(unit_chat_id, _bale_ticket_text(job.ticket)) if unit_chat_id else None
|
||||
@@ -349,6 +355,10 @@ class Command(BaseCommand):
|
||||
for job in jobs:
|
||||
issued_link = None
|
||||
try:
|
||||
if job.event_type == 'PAYMENT_RECEIPT_RECEIVED':
|
||||
from apps.payments.receipt_alerts import prepare_receipt_alert
|
||||
if not prepare_receipt_alert(job):
|
||||
continue
|
||||
if job.channel == NotificationJob.Channel.SMS and not _prepare_sms(job):
|
||||
continue
|
||||
if job.channel == NotificationJob.Channel.BALE and job.event_type == "CUSTOMER_TICKET_REPLY" and not _prepare_customer_reply_bale(job):
|
||||
|
||||
@@ -19,6 +19,7 @@ from . import policies
|
||||
from .models import PaymentAccessLink, PaymentNotice
|
||||
|
||||
SESSION_KEY = 'payment_access_link_id'
|
||||
SHARE_SESSION_KEY = 'payment_page_share'
|
||||
|
||||
|
||||
def token_digest(token):
|
||||
@@ -44,6 +45,24 @@ def issue_payment_link(*, notice=None, receipt=None):
|
||||
raise RuntimeError('عدم موفقیت در تولید لینک یکتای پرداخت.')
|
||||
|
||||
|
||||
def notice_share_url(request, notice):
|
||||
"""Reuse a validated capability; only a logged-in owner can issue a new one."""
|
||||
shared = request.session.get(SHARE_SESSION_KEY, {})
|
||||
token = shared.get('token', '')
|
||||
link = active_links().filter(pk=shared.get('link_id'), notice=notice,
|
||||
token_hash=token_digest(token)).first() if token else None
|
||||
grant = getattr(request, 'payment_access_link', None)
|
||||
if grant and (link is None or link.pk != grant.pk):
|
||||
raise PermissionDenied('برای کپی لینک، دوباره لینک پرداخت دریافتی را باز کنید.')
|
||||
if link is None:
|
||||
from .services import audit
|
||||
link, url = issue_payment_link(notice=notice)
|
||||
token = url.rstrip('/').rsplit('/', 1)[-1]
|
||||
request.session[SHARE_SESSION_KEY] = {'link_id': link.pk, 'token': token}
|
||||
audit(request.user, 'PAYMENT_LINK_CREATED', link)
|
||||
return settings.PUBLIC_BASE_URL.rstrip('/') + reverse('payment_short_link', args=[token])
|
||||
|
||||
|
||||
def active_links():
|
||||
return PaymentAccessLink.objects.select_related('customer', 'notice', 'receipt').filter(
|
||||
notice__deleted_at__isnull=True,
|
||||
|
||||
@@ -126,3 +126,91 @@ for model in [models.PaymentNotice,models.PaymentReceipt,models.PaymentReceiptRe
|
||||
models.ReceiptApprovalAssignment,models.ReceiptDecision,models.PaymentAuditEvent,
|
||||
models.PaymentNotification,models.PaymentNotificationAttempt]:
|
||||
admin.site.register(model,EvidenceAdmin)
|
||||
|
||||
|
||||
class ReceiptAlertRecipientInline(admin.TabularInline):
|
||||
model = models.PaymentReceiptAlertRecipient
|
||||
extra = 1
|
||||
can_delete = False
|
||||
|
||||
def has_view_permission(self, request, obj=None):
|
||||
return policies.has_permission(request.user, 'manage_receipt_alerts')
|
||||
|
||||
has_add_permission = has_view_permission
|
||||
has_change_permission = has_view_permission
|
||||
|
||||
|
||||
@admin.register(models.PaymentReceiptAlertConfiguration)
|
||||
class ReceiptAlertConfigurationAdmin(AuditedConfigurationAdmin):
|
||||
from .forms import ReceiptAlertConfigurationForm
|
||||
form = ReceiptAlertConfigurationForm
|
||||
permission = 'manage_receipt_alerts'
|
||||
singleton_configuration = True
|
||||
list_display = ('__str__', 'bale_enabled', 'sms_enabled', 'notify_revisions', 'updated_at')
|
||||
readonly_fields = ('updated_at',)
|
||||
inlines = [ReceiptAlertRecipientInline]
|
||||
fieldsets = (
|
||||
('بله — تصویر فیش', {'fields': ('bale_enabled', 'bale_token', 'clear_bale_token', 'bale_chat_id', 'bale_caption')}),
|
||||
('پیامک مدیران', {'fields': ('sms_enabled', 'sms_template')}),
|
||||
('فیش اصلاحشده', {'fields': ('notify_revisions', 'updated_at')}),
|
||||
)
|
||||
|
||||
def save_related(self, request, form, formsets, change):
|
||||
super().save_related(request, form, formsets, change)
|
||||
services.audit(request.user, 'RECEIPT_ALERT_RECIPIENTS_CHANGED', form.instance,
|
||||
{'recipients': list(form.instance.recipients.values('id', 'name', 'mobile', 'is_active'))})
|
||||
|
||||
|
||||
@admin.register(models.PaymentReceiptAlertRecipient)
|
||||
class ReceiptAlertRecipientAdmin(AuditedConfigurationAdmin):
|
||||
permission = 'manage_receipt_alerts'
|
||||
list_display = ('name', 'mobile', 'is_active')
|
||||
list_filter = ('is_active',)
|
||||
search_fields = ('name', 'mobile')
|
||||
|
||||
|
||||
@admin.register(models.PaymentReceiptAlert)
|
||||
class ReceiptAlertAdmin(EvidenceAdmin):
|
||||
list_display = ('receipt_number', 'recipient_name', 'recipient_mobile', 'channel', 'status', 'attempts', 'delivery_status')
|
||||
list_select_related = ('job', 'revision__receipt', 'recipient')
|
||||
readonly_fields = ('channel', 'status', 'attempts', 'delivery_status', 'last_error', 'sent_at', 'delivery_description')
|
||||
|
||||
def has_view_permission(self, request, obj=None):
|
||||
return policies.has_permission(request.user, 'manage_receipt_alerts') or super().has_view_permission(request, obj)
|
||||
|
||||
def get_queryset(self, request):
|
||||
if policies.has_permission(request.user, 'manage_receipt_alerts'):
|
||||
return admin.ModelAdmin.get_queryset(self, request)
|
||||
return super().get_queryset(request)
|
||||
|
||||
@admin.display(description='فیش')
|
||||
def receipt_number(self, obj):
|
||||
return obj.revision.receipt.number
|
||||
|
||||
@admin.display(description='کانال')
|
||||
def channel(self, obj):
|
||||
return obj.job.get_channel_display()
|
||||
|
||||
@admin.display(description='وضعیت ارسال')
|
||||
def status(self, obj):
|
||||
return obj.job.get_status_display()
|
||||
|
||||
@admin.display(description='تعداد تلاش')
|
||||
def attempts(self, obj):
|
||||
return obj.job.attempts
|
||||
|
||||
@admin.display(description='دریافت پیامک')
|
||||
def delivery_status(self, obj):
|
||||
return obj.job.get_delivery_status_display() or 'استعلام ثبت نشده'
|
||||
|
||||
@admin.display(description='آخرین خطا')
|
||||
def last_error(self, obj):
|
||||
return obj.job.last_error
|
||||
|
||||
@admin.display(description='زمان ارسال')
|
||||
def sent_at(self, obj):
|
||||
return obj.job.sent_at
|
||||
|
||||
@admin.display(description='شرح دریافت')
|
||||
def delivery_description(self, obj):
|
||||
return obj.job.delivery_description
|
||||
@@ -0,0 +1,22 @@
|
||||
from string import Formatter
|
||||
|
||||
from django.core.exceptions import ValidationError
|
||||
|
||||
ALERT_TAGS = ('amount_rial', 'customer_name', 'customer_mobile', 'received_at', 'paid_date',
|
||||
'receipt_number', 'receipt_link', 'revision_version', 'manager_name')
|
||||
DEFAULT_RECEIPT_SMS = 'فیش واریزی به مبلغ {amount_rial} ریال توسط {customer_name} به شماره تماس {customer_mobile} ارسال شد.'
|
||||
DEFAULT_RECEIPT_CAPTION = ('فیش واریزی جدید\nمشتری: {customer_name}\nشماره تماس: {customer_mobile}\n'
|
||||
'مبلغ: {amount_rial} ریال\nزمان ارسال: {received_at}\nتاریخ واریز: {paid_date}\n'
|
||||
'شماره فیش: {receipt_number} · نسخه {revision_version}\n{receipt_link}')
|
||||
ALERT_TEMPLATE_HELP = 'تگهای مجاز: ' + '، '.join('{' + tag + '}' for tag in ALERT_TAGS)
|
||||
|
||||
|
||||
def validate_alert_template(value):
|
||||
if not value.strip():
|
||||
raise ValidationError('متن اعلان نمیتواند خالی باشد.')
|
||||
try:
|
||||
for _, field, spec, conversion in Formatter().parse(value):
|
||||
if field is not None and (field not in ALERT_TAGS or spec or conversion):
|
||||
raise ValueError
|
||||
except ValueError:
|
||||
raise ValidationError('قالب معتبر نیست؛ فقط تگهای معرفیشده و بدون قالببندی اضافی مجازند.')
|
||||
@@ -4,6 +4,12 @@ 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 normalize_deposit_identifier(value):
|
||||
"""Remove leading zeroes from deposit IDs only; retain internal/check digits."""
|
||||
value = str(value or '').translate(str.maketrans('۰۱۲۳۴۵۶۷۸۹٠١٢٣٤٥٦٧٨٩', '01234567890123456789')).strip()
|
||||
return (value.lstrip('0') or '0') if value else ''
|
||||
|
||||
|
||||
def generate_id_algorithm(id_code: str) -> str:
|
||||
"""
|
||||
Calculates the verification code based on the reverse-engineered logic:
|
||||
@@ -39,4 +45,4 @@ def generate_id_algorithm(id_code: str) -> str:
|
||||
# فرمتدهی دو رقمی (مثلاً 6 میشود 06)
|
||||
check_digit_str = f"{check_digit:02}"
|
||||
|
||||
return f"{id_code}{check_digit_str}"
|
||||
return normalize_deposit_identifier(f"{id_code}{check_digit_str}")
|
||||
@@ -8,6 +8,29 @@ from . import validators
|
||||
from .models import BankInstitution, CompanyBankAccount, PaymentNotice
|
||||
|
||||
|
||||
class ReceiptAlertConfigurationForm(forms.ModelForm):
|
||||
clear_bale_token = forms.BooleanField(label='پاک کردن توکن ذخیرهشده', required=False)
|
||||
|
||||
class Meta:
|
||||
from .models import PaymentReceiptAlertConfiguration
|
||||
model = PaymentReceiptAlertConfiguration
|
||||
fields = '__all__'
|
||||
widgets = {'bale_token': forms.PasswordInput(render_value=False)}
|
||||
help_texts = {'bale_token': 'برای حفظ توکن فعلی این فیلد را خالی بگذارید؛ برای جایگزینی، توکن تازه وارد کنید.'}
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
self.initial['bale_token'] = ''
|
||||
|
||||
def clean(self):
|
||||
data = super().clean()
|
||||
if data.get('clear_bale_token'):
|
||||
data['bale_token'] = ''
|
||||
elif not data.get('bale_token') and self.instance.pk:
|
||||
data['bale_token'] = self.instance.bale_token
|
||||
return data
|
||||
|
||||
|
||||
class BankAccountForm(forms.ModelForm):
|
||||
revision_token = forms.CharField(required=False, widget=forms.HiddenInput)
|
||||
|
||||
|
||||
@@ -29,6 +29,8 @@ def open_link(request, token):
|
||||
logout(request)
|
||||
if not request.user.is_authenticated:
|
||||
access.grant_payment_access(request, link)
|
||||
# Retain the token across redirects in the session; the link model still stores only its hash.
|
||||
request.session[access.SHARE_SESSION_KEY] = {'link_id': link.pk, 'token': token}
|
||||
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, {
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
def normalize_existing_identifiers(apps, schema_editor):
|
||||
digit_map = str.maketrans('۰۱۲۳۴۵۶۷۸۹٠١٢٣٤٥٦٧٨٩', '01234567890123456789')
|
||||
for name in ('PaymentNotice', 'PaymentReceiptRevision'):
|
||||
records = apps.get_model('payments', name).objects.using(schema_editor.connection.alias)
|
||||
for pk, original in records.exclude(deposit_id='').values_list('pk', 'deposit_id').iterator(chunk_size=500):
|
||||
value = original.translate(digit_map).strip()
|
||||
normalized = (value.lstrip('0') or '0') if value else ''
|
||||
if normalized != original:
|
||||
records.filter(pk=pk).update(deposit_id=normalized)
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
dependencies = [('payments', '0005_paymentnotice_deleted_at')]
|
||||
operations = [migrations.RunPython(normalize_existing_identifiers, migrations.RunPython.noop)]
|
||||
@@ -0,0 +1,73 @@
|
||||
# Generated by Django 5.1.1 on 2026-10-07 09:49
|
||||
|
||||
import apps.payments.alert_templates
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('notifications', '0018_alter_smstemplate_body'),
|
||||
('payments', '0006_normalize_deposit_identifiers'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='PaymentReceiptAlertConfiguration',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('bale_enabled', models.BooleanField(default=False, verbose_name='ارسال تصویر فیش به بله')),
|
||||
('bale_token', models.CharField(blank=True, max_length=500, verbose_name='توکن ربات بله پرداخت')),
|
||||
('bale_chat_id', models.CharField(blank=True, max_length=150, verbose_name='شناسه گروه بله')),
|
||||
('bale_caption', models.TextField(default='فیش واریزی جدید\nمشتری: {customer_name}\nشماره تماس: {customer_mobile}\nمبلغ: {amount_rial} ریال\nزمان ارسال: {received_at}\nتاریخ واریز: {paid_date}\nشماره فیش: {receipt_number} · نسخه {revision_version}\n{receipt_link}', help_text='تگ\u200cهای مجاز: {amount_rial}، {customer_name}، {customer_mobile}، {received_at}، {paid_date}، {receipt_number}، {receipt_link}، {revision_version}، {manager_name}', max_length=3000, validators=[apps.payments.alert_templates.validate_alert_template], verbose_name='متن همراه تصویر')),
|
||||
('sms_enabled', models.BooleanField(default=False, verbose_name='پیامک فیش جدید به مدیران')),
|
||||
('sms_template', models.TextField(default='فیش واریزی به مبلغ {amount_rial} ریال توسط {customer_name} به شماره تماس {customer_mobile} ارسال شد.', help_text='تگ\u200cهای مجاز: {amount_rial}، {customer_name}، {customer_mobile}، {received_at}، {paid_date}، {receipt_number}، {receipt_link}، {revision_version}، {manager_name}', max_length=1000, validators=[apps.payments.alert_templates.validate_alert_template], verbose_name='متن پیامک مدیران')),
|
||||
('notify_revisions', models.BooleanField(default=True, verbose_name='اطلاع\u200cرسانی فیش اصلاح\u200cشده مشتری')),
|
||||
('updated_at', models.DateTimeField(auto_now=True, verbose_name='آخرین ویرایش')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'تنظیمات اطلاع\u200cرسانی فیش مشتری',
|
||||
'verbose_name_plural': 'تنظیمات اطلاع\u200cرسانی فیش مشتری',
|
||||
'permissions': [('manage_receipt_alerts', 'Manage receipt alert settings and recipients')],
|
||||
'constraints': [models.CheckConstraint(condition=models.Q(('pk', 1)), name='pay_receipt_alert_singleton')],
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='PaymentReceiptAlertRecipient',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('name', models.CharField(max_length=150, verbose_name='نام مدیر')),
|
||||
('mobile', models.CharField(max_length=20, verbose_name='شماره موبایل مدیر')),
|
||||
('is_active', models.BooleanField(default=True, verbose_name='دریافت پیامک فعال')),
|
||||
('configuration', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='recipients', to='payments.paymentreceiptalertconfiguration')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'مدیر دریافت\u200cکننده پیامک فیش',
|
||||
'verbose_name_plural': 'مدیران دریافت\u200cکننده پیامک فیش',
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='PaymentReceiptAlert',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('recipient_name', models.CharField(blank=True, max_length=150, verbose_name='نام گیرنده هنگام ثبت')),
|
||||
('recipient_mobile', models.CharField(blank=True, max_length=20, verbose_name='موبایل گیرنده هنگام ثبت')),
|
||||
('bale_chat_id', models.CharField(blank=True, max_length=150, verbose_name='گروه هنگام ثبت')),
|
||||
('context', models.JSONField()),
|
||||
('message_template', models.TextField(verbose_name='قالب هنگام ثبت')),
|
||||
('rendered_body', models.TextField(blank=True, verbose_name='متن ارسال')),
|
||||
('job', models.OneToOneField(on_delete=django.db.models.deletion.PROTECT, related_name='receipt_alert', to='notifications.notificationjob')),
|
||||
('revision', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='alerts', to='payments.paymentreceiptrevision')),
|
||||
('recipient', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, to='payments.paymentreceiptalertrecipient')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'سابقه اعلان فیش مشتری',
|
||||
'verbose_name_plural': 'سوابق اعلان فیش مشتری',
|
||||
},
|
||||
),
|
||||
migrations.AddConstraint(
|
||||
model_name='paymentreceiptalertrecipient',
|
||||
constraint=models.UniqueConstraint(fields=('configuration', 'mobile'), name='pay_receipt_alert_mobile'),
|
||||
),
|
||||
]
|
||||
@@ -4,6 +4,8 @@ from pathlib import Path
|
||||
from django.conf import settings
|
||||
from django.db import models
|
||||
from apps.core.models import PublicIdModel, TimeStampedModel
|
||||
from .deposit_identifier import normalize_deposit_identifier
|
||||
from .alert_templates import DEFAULT_RECEIPT_SMS, DEFAULT_RECEIPT_CAPTION, ALERT_TEMPLATE_HELP, validate_alert_template
|
||||
|
||||
MAX_AMOUNT = 9_007_199_254_740_991
|
||||
|
||||
@@ -87,6 +89,10 @@ class PaymentNotice(PublicIdModel, TimeStampedModel):
|
||||
cancelled_at = models.DateTimeField(null=True, blank=True)
|
||||
deleted_at = models.DateTimeField(null=True, blank=True, editable=False)
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
self.deposit_id = normalize_deposit_identifier(self.deposit_id)
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
class Meta:
|
||||
ordering = ['-created_at', '-pk']
|
||||
constraints = [models.CheckConstraint(condition=models.Q(amount__gt=0, amount__lte=MAX_AMOUNT), name='pay_notice_amount')]
|
||||
@@ -145,6 +151,10 @@ class PaymentReceiptRevision(models.Model):
|
||||
original_name = models.CharField(max_length=255)
|
||||
file_size = models.PositiveBigIntegerField()
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
self.deposit_id = normalize_deposit_identifier(self.deposit_id)
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
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')]
|
||||
|
||||
@@ -233,3 +243,84 @@ class PaymentNotificationAttempt(models.Model):
|
||||
provider_code = models.CharField(max_length=50, blank=True)
|
||||
provider_id = models.CharField(max_length=500, blank=True)
|
||||
error = models.TextField(blank=True)
|
||||
|
||||
|
||||
class PaymentReceiptAlertConfiguration(models.Model):
|
||||
"""Independent, initially disabled destinations for customer receipt uploads."""
|
||||
bale_enabled = models.BooleanField('ارسال تصویر فیش به بله', default=False)
|
||||
bale_token = models.CharField('توکن ربات بله پرداخت', max_length=500, blank=True)
|
||||
bale_chat_id = models.CharField('شناسه گروه بله', max_length=150, blank=True)
|
||||
bale_caption = models.TextField('متن همراه تصویر', default=DEFAULT_RECEIPT_CAPTION,
|
||||
validators=[validate_alert_template], help_text=ALERT_TEMPLATE_HELP, max_length=3000)
|
||||
sms_enabled = models.BooleanField('پیامک فیش جدید به مدیران', default=False)
|
||||
sms_template = models.TextField('متن پیامک مدیران', default=DEFAULT_RECEIPT_SMS,
|
||||
validators=[validate_alert_template], help_text=ALERT_TEMPLATE_HELP, max_length=1000)
|
||||
notify_revisions = models.BooleanField('اطلاعرسانی فیش اصلاحشده مشتری', default=True)
|
||||
updated_at = models.DateTimeField('آخرین ویرایش', auto_now=True)
|
||||
|
||||
class Meta:
|
||||
verbose_name = 'تنظیمات اطلاعرسانی فیش مشتری'
|
||||
verbose_name_plural = 'تنظیمات اطلاعرسانی فیش مشتری'
|
||||
permissions = [('manage_receipt_alerts', 'Manage receipt alert settings and recipients')]
|
||||
constraints = [models.CheckConstraint(condition=models.Q(pk=1), name='pay_receipt_alert_singleton')]
|
||||
|
||||
def __str__(self):
|
||||
return 'اطلاعرسانی فیش مشتری'
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
self.pk = 1
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
def clean(self):
|
||||
from django.core.exceptions import ValidationError
|
||||
self.pk = 1
|
||||
self.bale_token = self.bale_token.strip()
|
||||
self.bale_chat_id = self.bale_chat_id.strip()
|
||||
if self.bale_token and any(char.isspace() or char in '/?#' for char in self.bale_token):
|
||||
raise ValidationError({'bale_token': 'توکن معتبر ربات را وارد کنید.'})
|
||||
if self.bale_enabled and (not self.bale_token or not self.bale_chat_id):
|
||||
raise ValidationError('برای فعال کردن بله، توکن ربات و شناسه گروه را تکمیل کنید.')
|
||||
|
||||
|
||||
class PaymentReceiptAlertRecipient(models.Model):
|
||||
configuration = models.ForeignKey(PaymentReceiptAlertConfiguration, on_delete=models.PROTECT, related_name='recipients')
|
||||
name = models.CharField('نام مدیر', max_length=150)
|
||||
mobile = models.CharField('شماره موبایل مدیر', max_length=20)
|
||||
is_active = models.BooleanField('دریافت پیامک فعال', default=True)
|
||||
|
||||
class Meta:
|
||||
verbose_name = 'مدیر دریافتکننده پیامک فیش'
|
||||
verbose_name_plural = 'مدیران دریافتکننده پیامک فیش'
|
||||
constraints = [models.UniqueConstraint(fields=['configuration', 'mobile'], name='pay_receipt_alert_mobile')]
|
||||
|
||||
def __str__(self):
|
||||
return self.name
|
||||
|
||||
def clean(self):
|
||||
from apps.accounts.models import normalize_mobile
|
||||
from django.core.exceptions import ValidationError
|
||||
from .validators import digits
|
||||
try:
|
||||
self.mobile = normalize_mobile(digits(self.mobile))
|
||||
except ValueError:
|
||||
raise ValidationError({'mobile': 'شماره موبایل معتبر مدیر را وارد کنید.'})
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
self.clean()
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
|
||||
class PaymentReceiptAlert(models.Model):
|
||||
job = models.OneToOneField('notifications.NotificationJob', on_delete=models.PROTECT, related_name='receipt_alert')
|
||||
revision = models.ForeignKey(PaymentReceiptRevision, on_delete=models.PROTECT, related_name='alerts')
|
||||
recipient = models.ForeignKey(PaymentReceiptAlertRecipient, null=True, blank=True, on_delete=models.PROTECT)
|
||||
recipient_name = models.CharField('نام گیرنده هنگام ثبت', max_length=150, blank=True)
|
||||
recipient_mobile = models.CharField('موبایل گیرنده هنگام ثبت', max_length=20, blank=True)
|
||||
bale_chat_id = models.CharField('گروه هنگام ثبت', max_length=150, blank=True)
|
||||
context = models.JSONField()
|
||||
message_template = models.TextField('قالب هنگام ثبت')
|
||||
rendered_body = models.TextField('متن ارسال', blank=True)
|
||||
|
||||
class Meta:
|
||||
verbose_name = 'سابقه اعلان فیش مشتری'
|
||||
verbose_name_plural = 'سوابق اعلان فیش مشتری'
|
||||
@@ -9,6 +9,10 @@ EVENTS = {'PAYMENT_NOTICE_CREATED', 'PAYMENT_RECEIPT_APPROVED'}
|
||||
TAGS = ['recipient_name', 'company_name', 'notice_number', 'receipt_number', 'amount_rial', 'invoice_number', 'payment_link']
|
||||
|
||||
|
||||
def notice_sms_pending(notice):
|
||||
return PaymentNotification.objects.filter(notice=notice, job__status__in=['PENDING', 'PROCESSING', 'RETRYING']).exists()
|
||||
|
||||
|
||||
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,
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
"""Receipt-only outbox handlers. No network operation runs during receipt registration."""
|
||||
import logging
|
||||
from pathlib import Path
|
||||
|
||||
from django.conf import settings
|
||||
from django.db import transaction
|
||||
from django.urls import reverse
|
||||
|
||||
from apps.core.templatetags.jalali import jalali_date, jalali_datetime
|
||||
from apps.integrations.sms.base import SMSResult
|
||||
from apps.notifications.services import enqueue
|
||||
from .alert_templates import validate_alert_template
|
||||
from .models import PaymentReceiptAlert, PaymentReceiptAlertConfiguration
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
RECEIPT_ALERT_EVENT = 'PAYMENT_RECEIPT_RECEIVED'
|
||||
|
||||
|
||||
def receipt_alert_context(revision):
|
||||
customer = revision.receipt.customer
|
||||
profile = getattr(customer, 'customer_profile', None)
|
||||
name = f'{profile.first_name} {profile.last_name}'.strip() if profile else ''
|
||||
return {'customer_name': name or customer.get_full_name() or 'نام ثبت نشده',
|
||||
'customer_mobile': customer.local_mobile, 'amount_rial': f'{revision.amount:,}',
|
||||
'received_at': jalali_datetime(revision.created_at), 'paid_date': jalali_date(revision.paid_date),
|
||||
'receipt_number': revision.receipt.number, 'revision_version': str(revision.version), 'manager_name': '',
|
||||
'receipt_link': settings.PUBLIC_BASE_URL.rstrip('/') + reverse('staff_payments:receipt_detail', args=[revision.receipt.public_id])}
|
||||
|
||||
|
||||
def queue_receipt_alerts(revision, actor):
|
||||
if actor.account_type != 'CUSTOMER' or actor.pk != revision.receipt.customer_id:
|
||||
return
|
||||
configuration = PaymentReceiptAlertConfiguration.objects.filter(pk=1).first()
|
||||
if not configuration or not (configuration.bale_enabled or configuration.sms_enabled) or (revision.version > 1 and not configuration.notify_revisions):
|
||||
return
|
||||
context = receipt_alert_context(revision)
|
||||
destinations = []
|
||||
if configuration.bale_enabled:
|
||||
destinations.append(('BALE', None, configuration.bale_caption))
|
||||
if configuration.sms_enabled:
|
||||
destinations.extend(('SMS', recipient, configuration.sms_template) for recipient in configuration.recipients.filter(is_active=True))
|
||||
for channel, recipient, template in destinations:
|
||||
try:
|
||||
with transaction.atomic():
|
||||
job = enqueue(channel, RECEIPT_ALERT_EVENT, None,
|
||||
f'receipt-alert:{revision.pk}:{channel}:{recipient.pk if recipient else "group"}', customer=revision.receipt.customer)
|
||||
PaymentReceiptAlert.objects.get_or_create(job=job, defaults={'revision': revision, 'recipient': recipient,
|
||||
'recipient_name': recipient.name if recipient else '', 'recipient_mobile': recipient.mobile if recipient else '',
|
||||
'bale_chat_id': configuration.bale_chat_id if channel == 'BALE' else '', 'message_template': template,
|
||||
'context': {**context, 'manager_name': recipient.name if recipient else ''}})
|
||||
except Exception as error:
|
||||
logger.error('Receipt alert destination queue failed revision=%s channel=%s type=%s', revision.pk, channel, type(error).__name__)
|
||||
|
||||
|
||||
def safe_queue_receipt_alerts(revision, actor):
|
||||
# A failed configuration lookup/queue write rolls back only this savepoint.
|
||||
try:
|
||||
with transaction.atomic():
|
||||
queue_receipt_alerts(revision, actor)
|
||||
except Exception as error:
|
||||
logger.error('Receipt alert queue failed revision=%s type=%s', revision.pk, type(error).__name__)
|
||||
|
||||
|
||||
def prepare_receipt_alert(job):
|
||||
configuration = PaymentReceiptAlertConfiguration.objects.filter(pk=1).first()
|
||||
alert = PaymentReceiptAlert.objects.select_related('recipient').get(job=job)
|
||||
if job.channel == 'BALE':
|
||||
enabled = configuration and configuration.bale_enabled and configuration.bale_chat_id == alert.bale_chat_id
|
||||
else:
|
||||
enabled = configuration and configuration.sms_enabled and alert.recipient and alert.recipient.is_active and alert.recipient.mobile == alert.recipient_mobile
|
||||
if enabled:
|
||||
return True
|
||||
job.status = 'CANCELLED'
|
||||
job.last_error = 'اطلاعرسانی این کانال یا گیرنده غیرفعال شده یا مقصد تغییر کرده است.'
|
||||
job.save(update_fields=['status', 'last_error'])
|
||||
return False
|
||||
|
||||
|
||||
def send_receipt_alert(job, sms_provider_factory=None):
|
||||
"""A destination failure is returned to the worker's existing bounded retry policy."""
|
||||
try:
|
||||
alert = PaymentReceiptAlert.objects.select_related('revision').get(job=job)
|
||||
if not alert.rendered_body:
|
||||
validate_alert_template(alert.message_template)
|
||||
alert.rendered_body = alert.message_template.format(**alert.context)
|
||||
alert.save(update_fields=['rendered_body'])
|
||||
if job.channel == 'SMS':
|
||||
from apps.integrations.sms.providers import get_sms_provider
|
||||
return (sms_provider_factory or get_sms_provider)().send_message(alert.recipient_mobile, alert.rendered_body)
|
||||
from apps.integrations.bale.providers import BaleBotProvider
|
||||
configuration = PaymentReceiptAlertConfiguration.objects.get(pk=1)
|
||||
if not configuration.bale_token:
|
||||
return SMSResult(False, error='توکن ربات بله پرداخت تنظیم نشده است.')
|
||||
if len(alert.rendered_body) > 4096:
|
||||
return SMSResult(False, error='متن همراه تصویر بله بیش از ۴۰۹۶ کاراکتر است.')
|
||||
with alert.revision.image.open('rb') as image:
|
||||
return BaleBotProvider().send_photo(alert.bale_chat_id, image, alert.rendered_body,
|
||||
token=configuration.bale_token, filename=f'receipt-{alert.revision.pk}{Path(alert.revision.image.name).suffix}',
|
||||
mime_type=alert.revision.mime_type)
|
||||
except Exception as error:
|
||||
# HTTP exception strings can contain credentials in the request URL. Never persist them.
|
||||
return SMSResult(False, error=f'خطا در ارسال اعلان فیش ({type(error).__name__})')
|
||||
@@ -5,6 +5,34 @@ from django.db.models.functions import Coalesce
|
||||
from .models import PaymentReceiptRevision
|
||||
|
||||
|
||||
def notice_sms_history(notice):
|
||||
"""Separate gateway acceptance from final handset delivery; preserve every send attempt."""
|
||||
from django.db.models import Prefetch
|
||||
from .models import PaymentNotificationAttempt
|
||||
notifications = notice.paymentnotification_set.select_related('job__staff_user__staff_profile').prefetch_related(
|
||||
Prefetch('attempt_log', queryset=PaymentNotificationAttempt.objects.order_by('-started_at', '-pk'), to_attr='send_attempts')
|
||||
).order_by('-job__created_at', '-pk')
|
||||
for notification in notifications:
|
||||
job = notification.job
|
||||
notification.manual_resend = ':resend:' in job.dedupe_key
|
||||
notification.delivery_class = 'info'
|
||||
if job.status != 'SENT':
|
||||
notification.delivery_label = 'هنوز ارسال موفق ثبت نشده است.'
|
||||
elif not job.provider_message_id:
|
||||
notification.delivery_label = 'شناسه رهگیری ارائه نشده؛ دریافت توسط گوشی قابل تأیید نیست.'
|
||||
elif not job.delivery_status:
|
||||
notification.delivery_label = 'دریافت توسط گوشی قابل تأیید نیست.'
|
||||
elif job.delivery_is_final:
|
||||
notification.delivery_label = job.get_delivery_status_display()
|
||||
notification.delivery_class = 'approved' if job.delivery_status == 'DELIVERED' else 'rejected'
|
||||
elif job.delivery_status == 'PENDING':
|
||||
notification.delivery_label = 'در انتظار استعلام دریافت توسط گوشی'
|
||||
else:
|
||||
suffix = 'در حال پیگیری' if job.delivery_next_check_at else 'دریافت توسط گوشی تأیید نشده است'
|
||||
notification.delivery_label = f'نتیجه غیرقطعی: {job.get_delivery_status_display()}؛ {suffix}'
|
||||
return notifications
|
||||
|
||||
|
||||
def notices_with_totals(queryset):
|
||||
approved = PaymentReceiptRevision.objects.filter(
|
||||
receipt__notice_id=OuterRef('pk'), receipt__status='APPROVED',
|
||||
|
||||
@@ -17,6 +17,36 @@ def actor_name(actor):
|
||||
return getattr(profile, 'display_name', '') or actor.get_full_name() or actor.username
|
||||
|
||||
|
||||
@transaction.atomic
|
||||
def resend_notice_sms(actor, notice, request_key):
|
||||
from uuid import UUID
|
||||
from .notifications import context, notice_sms_pending
|
||||
from .models import PaymentNotification
|
||||
from apps.notifications.services import enqueue
|
||||
if not policies.can_create(actor, notice.owning_unit) or not policies.notice_actions(PaymentNotice.objects.filter(pk=notice.pk), actor).exists():
|
||||
raise PermissionDenied
|
||||
try:
|
||||
key = str(UUID(str(request_key)))
|
||||
except (ValueError, TypeError, AttributeError):
|
||||
raise ValidationError('درخواست ارسال معتبر نیست؛ صفحه را دوباره باز کنید.')
|
||||
# Same customer -> notice lock order as issuance, receipt submission and deletion.
|
||||
customer = User.objects.select_for_update().get(pk=notice.customer_id)
|
||||
notice = PaymentNotice.objects.select_for_update().get(pk=notice.pk)
|
||||
if notice.deleted_at or notice.cancelled_at or not customer.is_active or not customer.mobile:
|
||||
raise ValidationError('این اعلامیه یا مشتری امکان ارسال پیامک ندارد.')
|
||||
dedupe_key = f'payment-notice:{notice.public_id}:resend:{key}'
|
||||
previous = PaymentNotification.objects.filter(notice=notice, job__dedupe_key=dedupe_key).select_related('job').first()
|
||||
if previous:
|
||||
return previous.job
|
||||
if notice_sms_pending(notice):
|
||||
raise ValidationError('پیامک این اعلامیه در صف یا در حال ارسال است؛ تا پایان تلاش جاری منتظر بمانید.')
|
||||
job = enqueue('SMS', 'PAYMENT_NOTICE_CREATED', None, dedupe_key, customer=customer, staff_user=actor)
|
||||
PaymentNotification.objects.create(job=job, notice=notice, unit=notice.owning_unit,
|
||||
context=context(customer, notice=notice, amount=notice.amount))
|
||||
audit(actor, 'NOTICE_SMS_RESEND_REQUESTED', notice, {'job_id': job.pk})
|
||||
return job
|
||||
|
||||
|
||||
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 {})
|
||||
@@ -24,7 +54,10 @@ def audit(actor, action, obj, details=None):
|
||||
|
||||
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']
|
||||
snapshot = json.loads(serializers.serialize('json', [obj]))[0]['fields']
|
||||
if 'bale_token' in snapshot:
|
||||
snapshot['bale_token'] = '[configured]' if snapshot['bale_token'] else ''
|
||||
return snapshot
|
||||
|
||||
|
||||
@transaction.atomic
|
||||
@@ -60,7 +93,8 @@ def issue_notice(actor, data):
|
||||
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', ''))
|
||||
from .deposit_identifier import normalize_deposit_identifier
|
||||
deposit_id = normalize_deposit_identifier(data.get('deposit_id', ''))
|
||||
if account.requires_deposit_id and not deposit_id:
|
||||
raise ValidationError('برای این حساب شناسه واریز مشتری الزامی است.')
|
||||
key = data.get('submission_key')
|
||||
@@ -108,9 +142,10 @@ def assign_cycle(revision, actor):
|
||||
|
||||
|
||||
def receipt_metadata(data):
|
||||
from .deposit_identifier import normalize_deposit_identifier
|
||||
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', ''),
|
||||
'deposit_id': normalize_deposit_identifier(data.get('deposit_id', '')), 'tracking_number': data.get('tracking_number', ''),
|
||||
'description': data.get('description', '')}
|
||||
|
||||
|
||||
@@ -156,6 +191,8 @@ def _submit_receipt(actor, customer, data, file_info, created_files):
|
||||
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})
|
||||
from .receipt_alerts import safe_queue_receipt_alerts
|
||||
safe_queue_receipt_alerts(revision, actor)
|
||||
return receipt
|
||||
|
||||
|
||||
@@ -302,6 +339,8 @@ def revise_receipt(actor, receipt_id, version, data):
|
||||
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})
|
||||
from .receipt_alerts import safe_queue_receipt_alerts
|
||||
safe_queue_receipt_alerts(revision, actor)
|
||||
return receipt
|
||||
except Exception:
|
||||
for storage,name in created_files:
|
||||
|
||||
@@ -9,6 +9,7 @@ urlpatterns = [
|
||||
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>/delete/', views.notice_delete, name='notice_delete'),
|
||||
path('notices/<uuid:public_id>/resend-sms/', views.notice_resend_sms, name='notice_resend_sms'),
|
||||
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'),
|
||||
|
||||
@@ -6,6 +6,7 @@ urlpatterns = [
|
||||
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('bank-accounts/<uuid:public_id>/share-link/', views.customer_notice_share_link, name='notice_share_link'),
|
||||
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'),
|
||||
|
||||
+50
-3
@@ -42,6 +42,8 @@ def home(request):
|
||||
|
||||
|
||||
def complete_notice(notice):
|
||||
from .deposit_identifier import normalize_deposit_identifier
|
||||
notice.deposit_id = normalize_deposit_identifier(notice.deposit_id)
|
||||
snapshot = notice.account_snapshot
|
||||
approved = getattr(notice, 'approved_total', None)
|
||||
if approved is None:
|
||||
@@ -57,6 +59,8 @@ def complete_notice(notice):
|
||||
('شماره حساب', snapshot.get('account_number')), ('شبا', snapshot.get('iban')),
|
||||
('کارت', snapshot.get('card_number')), ('شناسه واریز', notice.deposit_id),
|
||||
('فاکتور', notice.invoice_number), ('توضیحات', notice.description)] if value)
|
||||
if notice.deposit_id:
|
||||
notice.copy_text += '\nوارد کردن شناسه واریز هنگام پرداخت الزامی و اجباری است؛ آن را در فرم بانکی، موبایلبانک یا اینترنتبانک وارد کنید.'
|
||||
return notice
|
||||
|
||||
|
||||
@@ -84,6 +88,17 @@ def customer_notice_detail(request, public_id):
|
||||
notice=complete_notice(notice), open_notice=str(notice.public_id), financial_page=True))
|
||||
|
||||
|
||||
@require_POST
|
||||
@financial_required(allow_payment_link=True)
|
||||
def customer_notice_share_link(request, public_id):
|
||||
notice = get_object_or_404(access.visible_payments(request, PaymentNotice.objects.all()),
|
||||
public_id=public_id, customer__is_active=True)
|
||||
url = access.notice_share_url(request, notice)
|
||||
response = JsonResponse({'url': url})
|
||||
response['Cache-Control'] = 'private, no-store'
|
||||
return response
|
||||
|
||||
|
||||
def receipt_form_page(request, customer, staff=False):
|
||||
link = getattr(request, 'payment_access_link', None)
|
||||
initial = access.receipt_initial(request, customer)
|
||||
@@ -93,9 +108,13 @@ def receipt_form_page(request, customer, staff=False):
|
||||
try:
|
||||
receipt = services.submit_receipt(customer if link else request.user, customer, form.cleaned_data, access_link=link)
|
||||
messages.success(request, 'فیش ثبت شد و در انتظار بررسی است.')
|
||||
if request.headers.get('Accept') == 'application/json':
|
||||
return JsonResponse({'redirect_url': reverse('staff_payments:receipt_detail' if staff else 'payments:receipt_detail', args=[receipt.public_id])})
|
||||
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)
|
||||
if request.method == 'POST' and request.headers.get('Accept') == 'application/json':
|
||||
return JsonResponse({'errors': form.errors.get_json_data()}, status=422)
|
||||
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]
|
||||
@@ -254,6 +273,9 @@ def filtered(queryset, request):
|
||||
@financial_required(staff=True)
|
||||
def desk(request):
|
||||
tab = request.GET.get('tab', 'receipts')
|
||||
if tab not in {'receipts', 'notices'}:
|
||||
tab = 'receipts'
|
||||
mine = tab == 'receipts' and (request.GET.get('mine') == '1' or ('tab' not in request.GET and 'mine' not in request.GET))
|
||||
receipts = selectors.receipts_with_current_values(policies.visible(PaymentReceipt.objects.select_related('customer__customer_profile', 'notice__created_by__staff_profile'), request.user))
|
||||
notice_items = selectors.notices_with_totals(policies.visible(PaymentNotice.objects.select_related('customer__customer_profile', 'created_by__staff_profile', 'account'), request.user))
|
||||
if tab == 'notices':
|
||||
@@ -274,7 +296,7 @@ def desk(request):
|
||||
items = items.filter(notice__isnull=True)
|
||||
elif request.GET.get('kind') == 'linked':
|
||||
items = items.filter(notice__isnull=False)
|
||||
if request.GET.get('mine'):
|
||||
if 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'),
|
||||
@@ -289,7 +311,12 @@ def desk(request):
|
||||
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,
|
||||
query['tab'] = tab
|
||||
if mine:
|
||||
query['mine'] = '1'
|
||||
else:
|
||||
query.pop('mine', None)
|
||||
return render(request, 'payments/desk.html', staff_context(page_obj=page, tab=tab, mine=mine, statuses=PaymentReceipt.Status.choices,
|
||||
can_create=policies.can_create(request.user), banks=CompanyBankAccount.objects.all(), query_string=query.urlencode()))
|
||||
|
||||
|
||||
@@ -337,12 +364,28 @@ def notice_detail(request, public_id):
|
||||
request.session.pop('payment_link_share', None)
|
||||
else:
|
||||
share = {}
|
||||
from uuid import uuid4
|
||||
from .notifications import notice_sms_pending
|
||||
return render(request, 'payments/notice_detail.html', staff_context(notice=notice,
|
||||
can_delete_notice=policies.can_delete_notice(request.user, notice),
|
||||
may_delete_notice=policies.may_delete_notice(request.user, 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')))
|
||||
can_resend_notice_sms=policies.can_create(request.user, notice.owning_unit) and notice.customer.is_active and not notice.cancelled_at,
|
||||
notice_sms_pending=notice_sms_pending(notice), notice_sms_request_key=str(uuid4()),
|
||||
receipts=policies.visible(notice.receipts.all(), request.user), notifications=selectors.notice_sms_history(notice)))
|
||||
|
||||
|
||||
@financial_required(staff=True)
|
||||
@require_POST
|
||||
def notice_resend_sms(request, public_id):
|
||||
notice = get_object_or_404(policies.notice_actions(PaymentNotice.objects.all(), request.user), public_id=public_id)
|
||||
try:
|
||||
services.resend_notice_sms(request.user, notice, request.POST.get('request_key', ''))
|
||||
messages.success(request, 'درخواست ارسال مجدد پیامک در صف ثبت شد؛ نتیجه ارسال و دریافت در سوابق نمایش داده میشود.')
|
||||
except ValidationError as error:
|
||||
messages.error(request, '؛ '.join(error.messages))
|
||||
return redirect('staff_payments:notice_detail', public_id=public_id)
|
||||
|
||||
|
||||
@financial_required(staff=True)
|
||||
@@ -474,9 +517,13 @@ def receipt_revise(request, public_id):
|
||||
try:
|
||||
services.revise_receipt(request.payment_customer,receipt.public_id,int(request.POST.get('version','0')),form.cleaned_data)
|
||||
messages.success(request,'نسخه اصلاحی ثبت شد و دوباره بررسی میشود.')
|
||||
if request.headers.get('Accept') == 'application/json':
|
||||
return JsonResponse({'redirect_url': reverse('payments:receipt_detail', args=[receipt.public_id])})
|
||||
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 'نسخه فیش معتبر نیست.')
|
||||
if request.method == 'POST' and request.headers.get('Accept') == 'application/json':
|
||||
return JsonResponse({'errors': form.errors.get_json_data()}, status=422)
|
||||
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)]
|
||||
|
||||
@@ -18,11 +18,12 @@
|
||||
- اعلامیه، فیش و تصمیم تأیید مستقلاند. ثبت تصویر یا تأیید یک نفر از دو نفر، پرداخت را نهایی نمیکند.
|
||||
- مبلغ عدد صحیح **ریال** است؛ نمایش حروفی تومان در مرورگر با BigInt انجام میشود و مقدار ریال به سرور میرود.
|
||||
- نمایش تاریخ شمسی و انتخاب سهقسمتی است. سرور با jdatetime روز/ماه/سال را مستقل از JS بررسی و Gregorian را در DateField ذخیره میکند. تاریخ ناقص، غیرواقعی، آینده و خارج از بازه مجاز رد میشود.
|
||||
- شناسهها، شمارهها و شبا رشتهاند؛ صفر ابتدایی حفظ میشود. شبا و کارت موجود اعتبارسنجی میشوند.
|
||||
- شماره حساب، کارت و شبا رشتهاند و صفر ابتدایی آنها حفظ میشود. شناسه واریز رشته است اما صفرهای ابتدایی آن پس از محاسبه رقم کنترل حذف میشوند؛ تولید، ذخیره و نمایش همین مقدار را استفاده میکنند. migration `0006_normalize_deposit_identifiers` شناسه اعلامیهها و نسخههای فیش قبلی را هماهنگ میکند و snapshot بانکی یا ممیزی تاریخی را تغییر نمیدهد. شبا و کارت موجود اعتبارسنجی میشوند.
|
||||
- حساب شناسهدار در صدور اعلامیه الزاماً شناسه میخواهد. در ثبت فیش، شناسه اعلامیه فقط برای **همان حساب** معتبر است؛ مقصد متفاوت شناسه خودش را لازم دارد.
|
||||
- حساب فقط فعال در پنل کارشناسان، در زمینه اعلامیه اختصاصی همان مشتری قابل استفاده است؛ در واریز مستقل قابل استفاده نیست. غیرفعال کلی بودن، ثبت جدید را متوقف میکند و سوابق را پنهان نمیکند.
|
||||
- snapshot حساب و شناسه اعلامیه حفظ میشود؛ ویرایش بانک، شماره تاریخی اعلامیه یا نسخه فیش را تغییر نمیدهد.
|
||||
- فرم صدور و فرم فیش nonce دارند؛ ارسال تکراری همان داده رکورد یا پیامک جدید ایجاد نمیکند. استفاده مجدد از nonce برای اطلاعات مالی متفاوت رد میشود.
|
||||
- فرم مشترک فیش مشتری، کارشناس، دسترسی لینک پرداخت و اصلاح فیش، تصویر را با XMLHttpRequest ارسال میکند: درصد و حجم واقعی درخواست، زمان سپریشده و تخمین زمان باقیمانده نمایش داده میشوند. پس از پایان انتقال، مرحله بررسی و ثبت سرور جداگانه نمایش داده میشود. دکمه ارسال و فیلدها تا پاسخ سرور قفلاند؛ در خطا، تصویر و nonce حفظ میشوند و امکان تلاش مجدد وجود دارد. درخواست دارای `Accept: application/json` مقصد ادامه را در `redirect_url` یا خطاهای فرم را با وضعیت ۴۲۲ دریافت میکند؛ ارسال معمولی بدون JavaScript همچنان از redirect استفاده میکند.
|
||||
- وضعیت اعلامیه از مبلغ نسخه جاری فیشهای تأییدشده بدون مغایرت مقصد محاسبه میشود؛ پرداخت جزئی و باقیمانده نمایش داده میشوند.
|
||||
|
||||
## تنظیمات ضروری پیش از استفاده مالی واقعی
|
||||
@@ -75,7 +76,13 @@
|
||||
|
||||
`recipient_name`, `company_name`, `notice_number`, `receipt_number`, `amount_rial`, `invoice_number`, `payment_link`
|
||||
|
||||
صف فعلی `NotificationJob` و worker `process_notifications` به کار میروند. اعلامیه به معنی صفشدن پیامک است؛ دریافت پیامک نیازمند اجرای worker و تنظیم provider است. وضعیت ارسال و دلیوری مستقل از وضعیت مالی است. متن و نسخه الگو در اولین تلاش موفق آمادهسازی snapshot میشود؛ retry همان متن را حفظ میکند. تلاش ناموفقِ آمادهسازی قالب یا ارتباط provider نیز تاریخچه دارد. URL پیامک ورود کامل مشتری را لازم دارد.
|
||||
صف فعلی `NotificationJob` و worker `process_notifications` به کار میروند. اعلامیه به معنی صفشدن پیامک است؛ دریافت پیامک نیازمند اجرای worker و تنظیم provider است. وضعیت ارسال و دلیوری مستقل از وضعیت مالی است. متن و نسخه الگو در اولین تلاش موفق آمادهسازی snapshot میشود؛ retry همان متن را حفظ میکند. تلاش ناموفقِ آمادهسازی قالب یا ارتباط provider نیز تاریخچه دارد. URL پیامک لینک موقت مستقل از ورود کامل مشتری است.
|
||||
|
||||
در جزئیات اعلامیه کارشناسان، «ارسال مجدد پیامک اعلامیه واریز» از مسیر POST `notices/<uuid>/resend-sms/` یک job و `PaymentNotification` مستقل میسازد؛ ارسالهای قبلی و نتیجه دریافت آنها بازنویسی نمیشوند. کلید UUID فرم درخواست تکراری را یکسان میکند و قفل مشتری سپس اعلامیه، درخواستهای همزمان را هماهنگ میکند. تا پایان job در صف/پردازش/تلاش خودکار، درخواست جدید ثبت نمیشود. مجوز ایجاد در دامنه واحد و دسترسی به اعلامیه، فعال بودن مشتری و لغو/حذف نشدن اعلامیه بررسی میشوند؛ رویداد `NOTICE_SMS_RESEND_REQUESTED` ممیزی میشود. worker همان handler و قالبهای فعلی را استفاده میکند و برای هر ارسال مجدد لینک پرداخت تازه میسازد.
|
||||
|
||||
سوابق، زمان درخواست و ارسال، درخواستکننده، متن آمادهشده، تمام تلاشهای ارسال با خطا و شناسه و کد provider، وضعیت دریافت، شرح/کد و زمان آخرین استعلام و استعلام بعدی را نمایش میدهند. `SENT` فقط پذیرش سرویس است؛ رسیدن به گوشی نیاز به نتیجه قطعی دلیوری دارد. پاسخهای غیرقطعی یا پایان پایش بدون تأیید دریافت، صریحاً غیرقطعی نمایش داده میشوند. سوابق با تازهسازی صفحه بهروز میشوند.
|
||||
|
||||
میز پرداخت کارشناسان بهطور پیشفرض فیشهای «نیازمند اقدام من» را نمایش میدهد؛ `?tab=receipts` همه فیشهای مجاز و `?tab=notices` اعلامیهها را انتخاب میکند. صفحهبندی ۲۵تایی با شمارنده صفحه و محدوده نتایج، تب و فیلترها را نگه میدارد.
|
||||
|
||||
در آزمونها provider mock شده است؛ پیامک واقعی برای مشتری ارسال نشده و worker روی صف واقعی توسعه اجرا نشده است.
|
||||
|
||||
|
||||
@@ -23,6 +23,12 @@
|
||||
|
||||
شماره حساب، شبا و سایر اطلاعات از snapshot اعلامیه نمایش داده میشوند. شناسه واریز و تذکر آن قرمز است. امضای مسیر بدون ورود از `SiteBranding.developer_credit_text` خوانده میشود.
|
||||
|
||||
## کپی اطلاعات و اشتراک صفحه
|
||||
|
||||
شناسه واریز در تولید، ذخیره، نمایش و متن کپی بدون صفرهای ابتدایی است؛ رقم کنترل و صفرهای داخلی حفظ میشوند. در صورت وجود شناسه، متن کپی کامل صریحاً میگوید وارد کردن شناسه واریز هنگام پرداخت الزامی و اجباری است.
|
||||
|
||||
زیر دکمه کپی کامل در جزئیات واریز، «کپی لینک کوتاه این صفحه برای ارسال به دیگران» قرار دارد. درخواست POST با CSRF و کنترل مالکیت/محدوده اجرا میشود. مشتری واردشده میتواند لینک موقت همین اعلامیه را بسازد؛ پیامکی ارسال نمیشود. بازدیدکننده ناشناس فقط لینک معتبر دریافتشده را کپی میکند؛ انقضا یا محدوده دسترسی تمدید نمیشود. برای کپی پس از redirect، توکن در نشست سمت سرور نگهداری میشود؛ مدل لینک همچنان فقط هش توکن را ذخیره میکند. نشستهای قدیمی فاقد توکن باید لینک دریافتی را دوباره باز کنند. Clipboard ناموجود، کپی دستی قابل انتخاب را نمایش میدهد.
|
||||
|
||||
## تست دائمی
|
||||
|
||||
از WSL و در ریشه پروژه اجرا کنید:
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
# اطلاعرسانی فیش مشتری
|
||||
|
||||
پس از ثبت موفق فیش مشتری، اپ `payments` اعلانها را در صف `NotificationJob` قرار میدهد. ثبت از حساب مشتری و لینک کوتاه پرداخت هر دو پشتیبانی میشوند. فیشی که کارشناس به نمایندگی از مشتری ثبت میکند اعلان ندارد. تصویر هر نسخه مستقل است؛ گزینه «اطلاعرسانی فیش اصلاحشده مشتری» تعیین میکند نسخههای اصلاحی نیز اعلان داشته باشند.
|
||||
|
||||
## راهاندازی
|
||||
|
||||
۱. migration `payments.0007_receipt_alerts` را اجرا کنید. هر دو کانال در شروع خاموشاند و بدون تنظیمات هیچ اعلانی ساخته نمیشود.
|
||||
|
||||
۲. در Django Admin، بخش پرداختها، «تنظیمات اطلاعرسانی فیش مشتری» را باز کنید:
|
||||
|
||||
`/x5admin/payments/paymentreceiptalertconfiguration/`
|
||||
|
||||
- بله: توکن ربات، شناسه گروه و متن همراه تصویر را وارد و کانال را فعال کنید. ربات باید مجوز ارسال به گروه داشته باشد. این توکن از `BALE_BOT_TOKEN` مربوط به تیکتها مستقل است.
|
||||
- پیامک: کانال را فعال کنید، متن پیامک را تنظیم کنید و در جدول همان صفحه مدیران دریافتکننده را با نام و شماره موبایل اضافه کنید. هر مدیر گزینه فعال مستقل دارد. شمارهها با قالب موبایل فعلی پروژه یکسانسازی میشوند و شماره تکراری پذیرفته نمیشود. درگاه پیامک فعلی پروژه استفاده میشود.
|
||||
- برای حفظ توکن ذخیرهشده، فیلد توکن را خالی بگذارید. برای تعویض، توکن تازه وارد کنید. برای حذف، ابتدا بله را خاموش و گزینه پاککردن توکن را انتخاب کنید. مقدار توکن در HTML فرم و ممیزی پرداخت درج نمیشود؛ توکن در دیتابیس تنظیمات نگهداری میشود و دسترسی دیتابیس/نسخه پشتیبان باید محدود باشد.
|
||||
|
||||
۳. Worker موجود `process_notifications` باید با نسخه جدید کد اجرا شود و به storage تصاویر فیش دسترسی داشته باشد. در محیط تولید از محیط مجازی همان سرور استفاده کنید. اجرای Worker قبلی را پس از انتشار بازراهاندازی کنید؛ Worker جدید جداگانه لازم نیست.
|
||||
|
||||
مجوز مدیریت تنظیمات و مدیران: `payments.manage_receipt_alerts`. مدیرعامل یا superuser فعال با حساب STAFF نیز مطابق سیاست فعلی پرداخت دسترسی دارد.
|
||||
|
||||
## متن و سابقه
|
||||
|
||||
تگهای متن: `{amount_rial}` (ریال)، `{customer_name}`، `{customer_mobile}`، `{received_at}` (تاریخ و ساعت شمسی)، `{paid_date}` (تاریخ واریز شمسی)، `{receipt_number}`، `{revision_version}`، `{receipt_link}` و `{manager_name}`. نام خالی مشتری با «نام ثبت نشده» جایگزین میشود. قالببندی اضافی یا دسترسی به ویژگیهای شیء در تگها پذیرفته نمیشود.
|
||||
|
||||
بله فایل خصوصی را مستقیماً همراه متن دریافت میکند؛ آدرس عمومی تصویر ایجاد نمیشود. لینک پیشفرض متن به جزئیات محافظتشده کارشناسان است. PNG/JPEG بهصورت تصویر و دیگر فرمتهای مجاز فیش بهصورت فایل تصویری ارسال میشوند.
|
||||
|
||||
صفحه سوابق:
|
||||
|
||||
`/x5admin/payments/paymentreceiptalert/`
|
||||
|
||||
برای هر نسخه و مقصد، سابقه مستقل شامل گیرنده، متن، کانال، تعداد تلاش، خطای آخر و نتیجه دریافت پیامک وجود دارد. پذیرش سرویس پیامک با رسیدن به گوشی متفاوت است؛ نتیجه دریافت از استعلام Worker و قابلیت درگاه میآید. سوابق فقط خواندنیاند.
|
||||
|
||||
## رفتار هنگام خطا و تغییر تنظیمات
|
||||
|
||||
- ثبت فیش هیچ درخواست شبکهای برای اعلان انجام نمیدهد. ساخت صف در همان تراکنش است؛ rollback ثبت فیش، صف آن را نیز حذف میکند. nonce و کلید یکتای نسخه/کانال/گیرنده جلوی ساخت اعلان تکراری را میگیرند.
|
||||
- خرابی صف یک مقصد با savepoint جدا مدیریت میشود؛ مقصدهای دیگر و ثبت فیش ادامه پیدا میکنند. اگر خواندن تنظیمات یا ساخت صف ممکن نباشد، خطای بدون اطلاعات محرمانه در لاگ ثبت میشود؛ چنین خطایی صف قابل تلاش مجدد ایجاد نمیکند.
|
||||
- خطای شبکه/درگاه/فایل در Worker به خطای اعلان تبدیل میشود. تلاش مجدد از سیاست محدود فعلی پیامک و بله پیروی میکند و ثبت یا بررسی فیش را تغییر نمیدهد. خطای یک اعلان پردازش مقصدهای دیگر را متوقف نمیکند.
|
||||
- خاموشکردن کانال یا مدیر، یا تغییر شماره/شناسه گروه، اعلانهای قبلی ارسالنشده آن مقصد را لغو میکند. توکن جدید برای تلاشهای بعدی خوانده میشود. متن و اطلاعات مشتری/گیرنده مربوط به زمان ثبت نسخه حفظ میشوند؛ تغییر متن، پیامهای جدید را تغییر میدهد.
|
||||
- همانند صف فعلی پروژه، اگر سرویس پیام را بپذیرد ولی پاسخ شبکه گم شود، تلاش بعدی ممکن است ارسال بیرونی را تکرار کند؛ سرویس بیرونی تضمین exactly-once ندارد.
|
||||
|
||||
تستها در `tests/test_payment_receipt_alerts.py` تمام ارسالها را mock میکنند و به گروه یا موبایل واقعی پیام نمیفرستند.
|
||||
@@ -119,3 +119,5 @@
|
||||
.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}}
|
||||
.pay-pagination{flex-wrap:wrap}.pay-pagination small{flex-basis:100%;text-align:center;color:#526c86}.pay-sms-panel{overflow-wrap:anywhere}.pay-sms-panel>form>.pay-button{width:100%;white-space:normal;line-height:1.8}.pay-sms-panel button:disabled{opacity:.6;cursor:not-allowed}.pay-sms-record{padding:16px 0;border-top:1px solid #e7edf2}.pay-sms-record header{display:flex;justify-content:space-between;gap:8px;flex-wrap:wrap}.pay-sms-record p{margin:8px 0;font-size:13px;line-height:1.9}.pay-sms-record time{font-size:12px;color:#526c86}.pay-sms-panel .pay-status{white-space:normal;max-width:100%;line-height:1.8}.pay-sms-record summary{cursor:pointer;font-size:13px;padding:10px 0;color:#17435d}.pay-sms-body{font-size:13px;line-height:1.9;background:#f0f8fc;padding:12px;border-radius:10px}.pay-sms-attempt{padding:10px 0;border-top:1px dashed #dce5ed}.pay-sms-error{color:#9f1f1f}
|
||||
.receipt-upload{padding:18px;margin:16px 0;border:1px solid #c7dfeb;border-radius:16px;background:#f0f8fc;color:#17435d;overflow-wrap:anywhere}.receipt-upload[hidden]{display:none}.receipt-upload p{margin:8px 0;font-size:14px}.receipt-upload small{display:block;color:#526c86}.receipt-upload-meter{display:flex;align-items:center;gap:12px;margin-top:12px}.receipt-upload-meter progress{width:100%;flex:1;min-width:0;height:12px;accent-color:#087d8b;direction:ltr}.receipt-upload-meter span{min-width:44px;text-align:center;font-weight:700;font-variant-numeric:tabular-nums}.customer-receipt-actions button:disabled{opacity:.65;cursor:wait}.customer-receipt-form.is-uploading input,.customer-receipt-form.is-uploading select,.customer-receipt-form.is-uploading textarea{opacity:.75}
|
||||
@@ -1,4 +1,5 @@
|
||||
/* Temporary payment access follows the Chaharsu style guide. */
|
||||
.pay-page-share{margin:12px 0 18px;padding-top:16px;border-top:1px solid #e7edf2}.pay-page-share>.pay-button{display:flex;gap:10px;max-width:100%;min-height:48px;white-space:normal;text-align:start;line-height:1.8}.pay-page-share>.pay-button:focus-visible{outline:3px solid #1977ce;outline-offset:3px}.pay-page-share>.pay-button:disabled{opacity:.65;cursor:wait}.pay-page-share p{margin:8px 0 0;color:#526c86;font-size:13px;line-height:1.9}.pay-page-share .pay-share-feedback:empty{display:none}@media(max-width:639px){.pay-page-share>.pay-button{width:100%;justify-content:center}}
|
||||
.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}
|
||||
|
||||
@@ -48,7 +48,7 @@
|
||||
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 (data.customer !== selectedCustomer || !/^9[0-9]{11}$/.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 = 'شناسه آماده است؛ برای جایگزینی مقدار فعلی، دکمه جایگزینی را بزنید.';
|
||||
|
||||
@@ -13,6 +13,22 @@ async function copy(text){
|
||||
}
|
||||
}
|
||||
document.querySelectorAll('[data-copy]').forEach(button=>button.addEventListener('click',()=>copy(button.dataset.copy)));
|
||||
document.querySelectorAll('[data-notice-sms]').forEach(form=>form.addEventListener('submit',event=>{
|
||||
if(form.dataset.sending==='true'){event.preventDefault();return;}
|
||||
form.dataset.sending='true';const button=form.querySelector('button');button.disabled=true;button.textContent='در حال ثبت درخواست ارسال…';
|
||||
}));
|
||||
document.querySelectorAll('[data-share-link]').forEach(form=>form.addEventListener('submit',async event=>{
|
||||
event.preventDefault();
|
||||
const button=form.querySelector('button'),feedback=form.querySelector('[role="status"]');
|
||||
button.disabled=true;button.setAttribute('aria-busy','true');feedback.textContent='در حال آمادهسازی لینک…';
|
||||
try{
|
||||
const response=await fetch(form.action,{method:'POST',body:new FormData(form),credentials:'same-origin',cache:'no-store',headers:{'Accept':'application/json'}});
|
||||
if(!response.ok || !response.headers.get('Content-Type')?.includes('application/json'))throw new Error('share unavailable');
|
||||
const data=await response.json();
|
||||
await copy(data.url);feedback.textContent='لینک آماده است؛ میتوانید آن را برای دیگران ارسال کنید.';
|
||||
}catch{feedback.textContent='دریافت لینک ممکن نشد. دوباره تلاش کنید یا لینک پرداخت دریافتی را باز کنید.';}
|
||||
finally{button.disabled=false;button.removeAttribute('aria-busy');}
|
||||
}));
|
||||
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]');
|
||||
|
||||
@@ -42,8 +42,8 @@ fields.forEach(field=>{const controls=field.querySelectorAll('input,select,texta
|
||||
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'});}
|
||||
const summary=document.querySelector('#receipt-errors'),list=summary.querySelector('ul');list.replaceChildren();errors.forEach((message,name)=>{const item=document.createElement('li'),control=q(name);if(control && typeof control.focus==='function'){const link=document.createElement('a');link.href='#'+control.id;link.textContent=message;link.onclick=event=>{event.preventDefault();control.focus();control.scrollIntoView({block:'center'});};item.append(link);}else item.textContent=message;list.append(item);});summary.hidden=!errors.size;
|
||||
if(errors.size){const first=q(errors.keys().next().value);if(first && typeof first.focus==='function'){first.focus();first.scrollIntoView({block:'center'});}else{summary.tabIndex=-1;summary.focus();summary.scrollIntoView({block:'center'});}}
|
||||
}
|
||||
async function validateImage(file){
|
||||
if(!file)return 'تصویر فیش واریز را انتخاب کنید.';
|
||||
@@ -53,7 +53,7 @@ async function validateImage(file){
|
||||
}
|
||||
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();
|
||||
event.preventDefault();if(busy)return;busy=true;button.disabled=true;button.textContent='در حال بررسی تصویر…';document.querySelector('#form-status').textContent='';const version=revision,errors=new Map();let navigating=false;
|
||||
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','اعلامیه را انتخاب کنید یا واریز مستقل را بزنید.');
|
||||
@@ -63,8 +63,11 @@ form.addEventListener('submit',async event=>{
|
||||
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;}
|
||||
const result=await window.ReceiptUpload.send(form);
|
||||
if(result.redirect_url){const destination=new URL(result.redirect_url,location.href);if(destination.origin!==location.origin)throw new Error('پاسخ ثبت فیش معتبر نیست.');navigating=true;location.assign(destination.href);return;}
|
||||
showErrors(new Map(Object.entries(result.errors).map(([name,items])=>[name,items.map(item=>item.message).join(' ')])));
|
||||
}catch(error){showErrors(new Map([['__all__',error.message || 'ارسال فیش انجام نشد؛ دوباره تلاش کنید.']]));}
|
||||
finally{if(!navigating){busy=false;button.disabled=false;button.textContent='ثبت و ارسال فیش';}}
|
||||
});
|
||||
if(document.querySelector('#receipt-errors').hidden===false){const field=form.querySelector('[aria-invalid=true]');if(field){field.focus();field.scrollIntoView({block:'center'});}}
|
||||
})();
|
||||
@@ -0,0 +1,65 @@
|
||||
/* Real multipart upload progress; registration remains authoritative on the server. */
|
||||
(() => {
|
||||
'use strict';
|
||||
const number=new Intl.NumberFormat('fa-IR',{maximumFractionDigits:1});
|
||||
function bytes(value){return value>=1048576?number.format(value/1048576)+' مگابایت':number.format(value/1024)+' کیلوبایت';}
|
||||
function duration(seconds){return seconds>=60?number.format(Math.ceil(seconds/60))+' دقیقه':number.format(Math.max(1,Math.ceil(seconds)))+' ثانیه';}
|
||||
function send(form){
|
||||
const payload=new FormData(form),panel=document.querySelector('#receipt-upload');
|
||||
const title=document.querySelector('#receipt-upload-title'),meter=document.querySelector('#receipt-upload-progress');
|
||||
const percent=document.querySelector('#receipt-upload-percent'),amount=document.querySelector('#receipt-upload-bytes'),time=document.querySelector('#receipt-upload-time');
|
||||
const button=document.querySelector('#submit-receipt');
|
||||
const controls=[...form.querySelectorAll('input,select,textarea,button')].map(control=>[control,control.disabled]);
|
||||
controls.forEach(([control])=>{control.disabled=true;});form.classList.add('is-uploading');button.setAttribute('aria-busy','true');
|
||||
panel.hidden=false;title.textContent='در حال ارسال فیش…';button.textContent='در حال ارسال…';
|
||||
meter.value=0;percent.textContent='۰٪';amount.textContent='در انتظار شروع ارسال';
|
||||
const started=performance.now();let loaded=0,total=0,lastProgress=started,processing=false;
|
||||
function updateTime(){
|
||||
if(processing)return;
|
||||
const elapsed=(performance.now()-started)/1000;
|
||||
const estimate=loaded>0 && elapsed>=1 && total>loaded;
|
||||
time.textContent='زمان سپریشده: '+number.format(Math.floor(elapsed))+' ثانیه'+(performance.now()-lastProgress>5000?' · منتظر ادامه ارسال؛ سرعت اتصال تغییر کرده است.':estimate?' · حدود '+duration((total-loaded)*elapsed/loaded)+' دیگر برای ارسال':' · در حال محاسبه زمان باقیمانده…');
|
||||
}
|
||||
function process(){
|
||||
processing=true;meter.value=100;percent.textContent='۱۰۰٪';
|
||||
title.textContent='ارسال کامل شد؛ در حال بررسی و ثبت فیش…';button.textContent='در حال ثبت…';
|
||||
time.textContent='منتظر تأیید ثبت از سرور؛ زمان این مرحله به سرعت پردازش بستگی دارد.';
|
||||
}
|
||||
updateTime();panel.scrollIntoView({block:'nearest'});
|
||||
return new Promise((resolve,reject)=>{
|
||||
const xhr=new XMLHttpRequest(),timer=setInterval(updateTime,1000);let settled=false;
|
||||
function finish(error,result){
|
||||
if(settled)return;settled=true;clearInterval(timer);
|
||||
if(!result?.redirect_url){
|
||||
controls.forEach(([control,disabled])=>{control.disabled=disabled;});form.classList.remove('is-uploading');button.removeAttribute('aria-busy');
|
||||
title.textContent='ثبت فیش انجام نشد';time.textContent='اطلاعات و تصویر حفظ شدهاند؛ پس از رفع مشکل دوباره ارسال کنید.';
|
||||
}
|
||||
if(error)reject(error);else resolve(result);
|
||||
}
|
||||
xhr.upload.addEventListener('progress',event=>{
|
||||
loaded=event.loaded;total=event.lengthComputable?event.total:0;lastProgress=performance.now();
|
||||
amount.textContent='ارسالشده: '+bytes(loaded)+(total?' از '+bytes(total):'');
|
||||
if(total){const value=Math.min(100,Math.floor(loaded/total*100));meter.value=value;percent.textContent=number.format(value)+'٪';}
|
||||
else{meter.removeAttribute('value');percent.textContent='در حال ارسال';}
|
||||
updateTime();
|
||||
});
|
||||
xhr.upload.addEventListener('load',process);
|
||||
xhr.addEventListener('load',()=>{
|
||||
const result=xhr.response;
|
||||
if(xhr.status>=200 && xhr.status<300 && result?.redirect_url){finish(null,result);return;}
|
||||
if(xhr.status===422 && result?.errors){finish(null,result);return;}
|
||||
const message=[401,403,410].includes(xhr.status)?'دسترسی یا نشست شما معتبر نیست؛ صفحه را دوباره باز کنید.':xhr.status===413?'حجم تصویر بیش از حد مجاز سرور است؛ تصویر کوچکتری انتخاب کنید.':'ثبت فیش تأیید نشد؛ دوباره تلاش کنید. در صورت تکرار، وضعیت فیشهای خود را بررسی کنید.';
|
||||
finish(new Error(message));
|
||||
});
|
||||
xhr.addEventListener('error',()=>finish(new Error('ارتباط با سرور قطع شد؛ اتصال اینترنت را بررسی و دوباره ارسال کنید.')));
|
||||
xhr.addEventListener('abort',()=>finish(new Error('ارسال متوقف شد؛ دوباره تلاش کنید.')));
|
||||
try{
|
||||
xhr.open('POST',form.action || location.href);xhr.responseType='json';
|
||||
xhr.setRequestHeader('Accept','application/json');
|
||||
xhr.setRequestHeader('X-CSRFToken',payload.get('csrfmiddlewaretoken'));
|
||||
xhr.send(payload);
|
||||
}catch(error){finish(error);}
|
||||
});
|
||||
}
|
||||
window.ReceiptUpload=Object.freeze({send});
|
||||
})();
|
||||
@@ -29,6 +29,12 @@
|
||||
<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>
|
||||
<section id="receipt-upload" class="receipt-upload" aria-labelledby="receipt-upload-title" hidden>
|
||||
<strong id="receipt-upload-title" role="status">در حال ارسال فیش…</strong>
|
||||
<div class="receipt-upload-meter"><progress id="receipt-upload-progress" max="100" value="0" aria-label="پیشرفت ارسال فیش"></progress><span id="receipt-upload-percent">۰٪</span></div>
|
||||
<p id="receipt-upload-bytes"></p><p id="receipt-upload-time"></p>
|
||||
<small>لطفاً تا پایان ثبت، این صفحه را باز نگه دارید.</small>
|
||||
</section>
|
||||
<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>
|
||||
|
||||
@@ -14,6 +14,11 @@
|
||||
<!-- 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 notice_page and not staff_notice %}<form class="pay-page-share" method="post" action="{% url 'payments:notice_share_link' notice.public_id %}" data-share-link>
|
||||
{% csrf_token %}<button type="submit" class="pay-button secondary">{% include 'payments/_icon.html' with name='copy' %}<span>کپی لینک کوتاه این صفحه برای ارسال به دیگران</span></button>
|
||||
<p>دارندهٔ لینک به اطلاعات همین واریز دسترسی دارد؛ لینک تا پایان اعتبار آن قابل استفاده است.</p>
|
||||
<p class="pay-share-feedback" role="status" aria-live="polite"></p>
|
||||
</form>{% endif %}
|
||||
{% 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 %}
|
||||
@@ -0,0 +1,39 @@
|
||||
{% load jalali %}
|
||||
<section class="pay-card pay-sms-panel" aria-labelledby="notice-sms-title">
|
||||
<h2 id="notice-sms-title">پیامک اعلامیه و سوابق ارسال</h2>
|
||||
<p class="pay-section-help">پذیرش پیامک توسط سرویس به معنی رسیدن به گوشی نیست؛ وضعیت دریافت بر اساس استعلام سرویس پیامک نمایش داده میشود.</p>
|
||||
{% if can_resend_notice_sms %}
|
||||
<form method="post" action="{% url 'staff_payments:notice_resend_sms' notice.public_id %}" data-notice-sms>
|
||||
{% csrf_token %}<input type="hidden" name="request_key" value="{{ notice_sms_request_key }}">
|
||||
<button class="pay-button secondary" type="submit" {% if notice_sms_pending %}disabled{% endif %}>ارسال مجدد پیامک اعلامیه واریز</button>
|
||||
</form>
|
||||
{% if notice_sms_pending %}<p class="pay-section-help" role="status">پیامک در صف یا در حال ارسال است. نتیجه پس از پردازش سامانه و با تازهسازی این صفحه نمایش داده میشود.</p>{% endif %}
|
||||
{% endif %}
|
||||
<div class="pay-sms-history">
|
||||
{% for notification in notifications %}
|
||||
<article class="pay-sms-record">
|
||||
<header><strong>{% if notification.manual_resend %}ارسال مجدد{% else %}ارسال اولیه{% endif %}</strong><time>{{ notification.job.created_at|jalali_datetime }}</time></header>
|
||||
<p>وضعیت ارسال: <strong>{% if notification.job.status == 'SENT' %}پذیرفتهشده توسط سرویس پیامک{% else %}{{ notification.job.get_status_display }}{% endif %}</strong></p>
|
||||
<p>وضعیت دریافت: <span class="pay-status {{ notification.delivery_class }}">{{ notification.delivery_label }}</span></p>
|
||||
{% if notification.job.staff_user %}<p>درخواستکننده: {{ notification.job.staff_user.staff_profile.display_name|default:notification.job.staff_user.get_full_name|default:notification.job.staff_user.username }}</p>{% endif %}
|
||||
{% if notification.job.sent_at %}<p>زمان ارسال: {{ notification.job.sent_at|jalali_datetime }}</p>{% endif %}
|
||||
{% if notification.job.status == 'RETRYING' %}<p>تلاش خودکار بعدی: {{ notification.job.next_attempt_at|jalali_datetime }}</p>{% endif %}
|
||||
{% if notification.job.last_error %}<p class="pay-sms-error">علت خطای ارسال: {{ notification.job.last_error }}</p>{% endif %}
|
||||
{% if notification.job.delivery_checked_at %}<p>آخرین استعلام: {{ notification.job.delivery_checked_at|jalali_datetime }} · {{ notification.job.delivery_check_attempts }} نوبت</p>{% endif %}
|
||||
{% if notification.job.delivery_next_check_at %}<p>استعلام بعدی: {{ notification.job.delivery_next_check_at|jalali_datetime }}</p>{% endif %}
|
||||
{% if notification.job.delivery_description %}<p>شرح نتیجه دریافت: {{ notification.job.delivery_description }}</p>{% endif %}
|
||||
{% if notification.job.delivery_status_code %}<p>کد وضعیت دریافت: <bdi>{{ notification.job.delivery_status_code }}</bdi></p>{% endif %}
|
||||
<details><summary>جزئیات پیام و تلاشهای ارسال ({{ notification.send_attempts|length }})</summary>
|
||||
{% if notification.rendered_body %}<div class="pay-sms-body">{{ notification.rendered_body|linebreaksbr }}</div>{% endif %}
|
||||
{% for attempt in notification.send_attempts %}<div class="pay-sms-attempt">
|
||||
<p><time>{{ attempt.started_at|jalali_datetime }}</time> · {% if attempt.success == True %}پذیرفتهشده توسط سرویس{% elif attempt.success == False %}تلاش ناموفق{% else %}نتیجه تلاش هنوز ثبت نشده است{% endif %}</p>
|
||||
{% if attempt.finished_at %}<p>پایان تلاش: {{ attempt.finished_at|jalali_datetime }}</p>{% endif %}
|
||||
{% if attempt.provider_id %}<p>شناسه رهگیری: <bdi>{{ attempt.provider_id }}</bdi></p>{% endif %}
|
||||
{% if attempt.provider_code %}<p>کد پاسخ سرویس: <bdi>{{ attempt.provider_code }}</bdi></p>{% endif %}
|
||||
{% if attempt.error %}<p class="pay-sms-error">{{ attempt.error }}</p>{% endif %}
|
||||
{% empty %}<p>هنوز تلاشی برای ارسال ثبت نشده است.</p>{% endfor %}
|
||||
</details>
|
||||
</article>
|
||||
{% empty %}<p class="pay-section-help">اطلاعرسانی ثبتشدهای وجود ندارد.</p>{% endfor %}
|
||||
</div>
|
||||
</section>
|
||||
@@ -1 +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 %}
|
||||
{% if always or 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 %}{% if always and page_obj.paginator.count %}<small>نمایش {{ page_obj.start_index }} تا {{ page_obj.end_index }} از {{ page_obj.paginator.count }} مورد</small>{% endif %}</nav>{% endif %}
|
||||
@@ -8,13 +8,13 @@
|
||||
{% 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=receipts" {% if tab != 'notices' and not 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>
|
||||
<a href="?tab=receipts&mine=1" {% if 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 %}
|
||||
{% if 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>
|
||||
@@ -30,11 +30,11 @@
|
||||
<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>
|
||||
<a class="pay-reset" href="?tab={{ tab }}{% if 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>
|
||||
<div class="pay-list-head"><h2 id="pay-results-title">{% if tab == 'notices' %}اعلامیههای واریز{% elif 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>
|
||||
@@ -51,9 +51,9 @@
|
||||
</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>
|
||||
<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 mine %}در حال حاضر فیشی برای اقدام شما وجود ندارد.{% elif tab == 'notices' %}اعلامیههای صادرشده برای مشتریان اینجا نمایش داده میشوند.{% else %}فیشهای ارسالی مشتریان برای بررسی اینجا نمایش داده میشوند.{% endif %}</p></div>
|
||||
{% endfor %}
|
||||
</section>
|
||||
{% include 'payments/_pagination.html' %}
|
||||
{% include 'payments/_pagination.html' with always=True %}
|
||||
</section>
|
||||
{% endblock %}
|
||||
@@ -10,7 +10,7 @@
|
||||
<aside class="pay-detail-aside">
|
||||
{% if can_share_payment_link %}<section class="pay-card"><h2>لینک موقت پرداخت</h2><p class="pay-section-help">برای مشاهده اعلامیه و ارسال فیش، مشتری نیازی به ورود ندارد. پس از پایان اعتبار، میتوانید لینک تازه بسازید.</p>{% if payment_share_url %}<p class="pay-share-url" dir="ltr">{{ payment_share_url }}</p><button type="button" class="pay-button secondary" data-copy="{{ payment_share_url }}">کپی لینک پرداخت</button>{% endif %}<form method="post" action="{% url 'staff_payments:notice_payment_link' notice.public_id %}">{% csrf_token %}<button class="pay-button" type="submit">ساخت لینک تازه پرداخت</button></form></section>{% endif %}
|
||||
<section class="pay-card"><h2>فیشهای این اعلامیه</h2>{% for receipt in receipts %}<a class="pay-linked-record" href="{% url 'staff_payments:receipt_detail' receipt.public_id %}"><bdi>{{ receipt.number }}</bdi><span class="pay-status {{ receipt.status|lower }}">{{ receipt.get_status_display }}</span>{% include 'payments/_icon.html' with name='arrow' %}</a>{% empty %}<p class="pay-section-help">هنوز فیشی برای این اعلامیه ثبت نشده است.</p>{% endfor %}</section>
|
||||
<section class="pay-card"><h2>اطلاعرسانی پیامک</h2>{% for notification in notifications %}<div class="pay-notification-row"><strong>{{ notification.job.get_status_display }}</strong><span>وضعیت دریافت: {{ notification.job.get_delivery_status_display }}</span></div>{% empty %}<p class="pay-section-help">اطلاعرسانی ثبتشدهای وجود ندارد.</p>{% endfor %}</section>
|
||||
{% include 'payments/_notice_sms.html' %}
|
||||
{% if can_delete_notice %}<details class="pay-delete-panel"><summary>حذف اعلامیه واریز</summary><form method="post" action="{% url 'staff_payments:notice_delete' notice.public_id %}">{% csrf_token %}<p role="note">آیا از حذف اعلامیه «{{ notice.subject }}» با شماره {{ notice.number }} مطمئن هستید؟ پس از حذف، اعلامیه از فهرستها خارج میشود و لینکهای آن دیگر قابل استفاده نیستند. این اقدام از پنل قابل بازگشت نیست.</p><p>حذف فقط تا زمانی مجاز است که هیچ فیشی برای این اعلامیه ثبت نشده باشد.</p><label><input type="checkbox" name="confirm" value="delete" required> هشدار را خواندم و حذف این اعلامیه را تأیید میکنم.</label><div class="pay-actions"><button class="pay-button pay-danger" type="submit">تأیید و حذف اعلامیه</button></div></form></details>{% elif may_delete_notice %}<section class="pay-card"><h2>حذف اعلامیه</h2><p class="pay-section-help">برای این اعلامیه فیش ثبت شده است؛ حذف آن امکانپذیر نیست، حتی اگر فیش رد شده باشد.</p></section>{% endif %}
|
||||
</aside>
|
||||
</div>
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
{% extends 'payments/base.html' %}{% load static %}{% block title %}{% if staff_form %}ثبت فیش برای مشتری{% else %}ارسال فیش واریزی{% endif %}{% endblock %}
|
||||
{% block content %}{% include 'payments/_customer_receipt_form.html' %}{% endblock %}
|
||||
{% block extra_scripts %}{{ block.super }}<script defer src="{% static 'js/chaharsu/receipt-amount.js' %}?v={{ static_asset_version }}"></script><script defer src="{% static 'js/chaharsu/receipt-form.js' %}?v={{ static_asset_version }}"></script><script defer src="{% static 'js/chaharsu/customer-receipt.js' %}?v={{ static_asset_version }}"></script>{% endblock %}
|
||||
{% block extra_scripts %}{{ block.super }}<script defer src="{% static 'js/chaharsu/receipt-amount.js' %}?v={{ static_asset_version }}"></script><script defer src="{% static 'js/chaharsu/receipt-upload.js' %}?v={{ static_asset_version }}"></script><script defer src="{% static 'js/chaharsu/receipt-form.js' %}?v={{ static_asset_version }}"></script><script defer src="{% static 'js/chaharsu/customer-receipt.js' %}?v={{ static_asset_version }}"></script>{% endblock %}
|
||||
@@ -4,6 +4,31 @@ from .payments import PaymentsPage
|
||||
|
||||
|
||||
class PaymentLinkPage(PaymentsPage):
|
||||
def prepare_copy(self, manual=False):
|
||||
self.manual_copy = manual
|
||||
if manual:
|
||||
self.page.add_init_script("Object.defineProperty(navigator, 'clipboard', {value: {writeText: async () => {throw new Error('Clipboard unavailable');}}});")
|
||||
else:
|
||||
self.page.context.grant_permissions(['clipboard-read', 'clipboard-write'])
|
||||
|
||||
def copied_text(self, button_name):
|
||||
self.page.get_by_role('button', name=button_name, exact=True).click()
|
||||
if button_name == 'کپی لینک کوتاه این صفحه برای ارسال به دیگران':
|
||||
expect(self.page.locator('.pay-share-feedback')).to_contain_text('لینک آماده است')
|
||||
if self.manual_copy:
|
||||
dialog = self.page.get_by_role('dialog')
|
||||
expect(dialog).to_be_visible()
|
||||
value = dialog.locator('textarea').input_value()
|
||||
dialog.get_by_role('button', name='بستن', exact=True).click()
|
||||
return value
|
||||
expect(self.page.get_by_text('کپی شد', exact=True)).to_be_visible()
|
||||
return self.page.evaluate('navigator.clipboard.readText()')
|
||||
|
||||
def copy_short_link(self):
|
||||
value = self.copied_text('کپی لینک کوتاه این صفحه برای ارسال به دیگران')
|
||||
expect(self.page.locator('.pay-share-feedback')).to_contain_text('لینک آماده است')
|
||||
return value
|
||||
|
||||
def open_link(self, path):
|
||||
self.goto(path)
|
||||
expect(self.page.get_by_role('heading', name='جزئیات واریز', exact=True)).to_be_visible()
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
from playwright.sync_api import expect
|
||||
|
||||
from .base import BasePage
|
||||
|
||||
|
||||
class ReceiptUploadPage(BasePage):
|
||||
def control_transport(self):
|
||||
"""Pause the browser transport while keeping the final POST real."""
|
||||
self.page.add_init_script("""(() => {
|
||||
const Native = window.XMLHttpRequest;
|
||||
window.receiptTransport = {count: 0};
|
||||
window.XMLHttpRequest = class extends Native {
|
||||
open(method, url, ...rest) {
|
||||
this.receiptRequest = method === 'POST' && /payment-receipt|receipts/.test(url);
|
||||
super.open(method, url, ...rest);
|
||||
}
|
||||
send(payload) {
|
||||
if (!this.receiptRequest) return super.send(payload);
|
||||
window.receiptTransport.count++;
|
||||
window.receiptTransport.current = this;
|
||||
this.release = () => super.send(payload);
|
||||
this.upload.dispatchEvent(new ProgressEvent('progress', {
|
||||
lengthComputable: true, loaded: 524288, total: 1048576
|
||||
}));
|
||||
}
|
||||
};
|
||||
})();""")
|
||||
|
||||
def fill_receipt(self, account_id, png):
|
||||
self.page.locator('#id_paid_amount').fill('۱۲۳۴۵')
|
||||
self.page.locator('#id_destination_account').select_option(str(account_id))
|
||||
self.page.get_by_role('button', name='امروز', exact=True).click()
|
||||
self.page.locator('#id_receipt_image').set_input_files({'name': 'receipt.png', 'mimeType': 'image/png', 'buffer': png})
|
||||
|
||||
def submit(self):
|
||||
self.page.get_by_role('button', name='ثبت و ارسال فیش', exact=True).click()
|
||||
|
||||
def assert_uploading(self):
|
||||
expect(self.page.locator('#receipt-upload')).to_be_visible()
|
||||
expect(self.page.locator('#receipt-upload-percent')).to_have_text('۵۰٪')
|
||||
expect(self.page.locator('#receipt-upload-bytes')).to_have_text('ارسالشده: ۵۱۲ کیلوبایت از ۱ مگابایت')
|
||||
expect(self.page.locator('#receipt-upload-time')).to_contain_text('دیگر برای ارسال')
|
||||
expect(self.page.locator('#submit-receipt')).to_be_disabled()
|
||||
expect(self.page.locator('#id_receipt_image')).to_be_disabled()
|
||||
|
||||
def repeat_submission(self):
|
||||
self.page.locator('#receipt-form').evaluate("form => {form.requestSubmit(); form.requestSubmit();}")
|
||||
assert self.page.evaluate('window.receiptTransport.count') == 1
|
||||
|
||||
def processing(self):
|
||||
self.page.evaluate("window.receiptTransport.current.upload.dispatchEvent(new Event('load'))")
|
||||
expect(self.page.locator('#receipt-upload-percent')).to_have_text('۱۰۰٪')
|
||||
expect(self.page.locator('#receipt-upload-title')).to_contain_text('در حال بررسی و ثبت فیش')
|
||||
expect(self.page.locator('#submit-receipt')).to_be_disabled()
|
||||
|
||||
def fail_network(self):
|
||||
self.page.evaluate("window.receiptTransport.current.dispatchEvent(new Event('error'))")
|
||||
expect(self.page.locator('#receipt-errors')).to_contain_text('ارتباط با سرور قطع شد')
|
||||
expect(self.page.locator('#submit-receipt')).to_be_enabled()
|
||||
expect(self.page.locator('#id_receipt_image')).to_be_enabled()
|
||||
assert self.page.locator('#id_receipt_image').evaluate('input => input.files.length') == 1
|
||||
|
||||
def reject_fields(self):
|
||||
self.page.evaluate("""() => {
|
||||
const request = window.receiptTransport.current;
|
||||
Object.defineProperty(request, 'status', {value: 422});
|
||||
Object.defineProperty(request, 'response', {value: {errors: {
|
||||
paid_amount: [{message: 'مبلغ با پرداخت مطابقت ندارد.'}],
|
||||
__all__: [{message: 'اطلاعات بانکی را دوباره بررسی کنید.'}]
|
||||
}}});
|
||||
request.dispatchEvent(new Event('load'));
|
||||
}""")
|
||||
expect(self.page.locator('#receipt-errors')).to_contain_text('اطلاعات بانکی را دوباره بررسی کنید.')
|
||||
expect(self.page.locator('#error-paid_amount')).to_have_text('مبلغ با پرداخت مطابقت ندارد.')
|
||||
expect(self.page.locator('#submit-receipt')).to_be_enabled()
|
||||
|
||||
def finish(self, revision=False):
|
||||
self.page.evaluate('window.receiptTransport.current.release()')
|
||||
expect(self.page.locator('.flash')).to_contain_text('نسخه اصلاحی ثبت شد' if revision else 'فیش ثبت شد و در انتظار بررسی است.')
|
||||
@@ -0,0 +1,44 @@
|
||||
from django.urls import reverse
|
||||
from playwright.sync_api import expect
|
||||
|
||||
from .base import BasePage
|
||||
|
||||
|
||||
class StaffPaymentsPage(BasePage):
|
||||
def open_desk(self, parameters=''):
|
||||
self.goto(reverse('staff_payments:desk') + parameters)
|
||||
|
||||
def assert_action_tab(self):
|
||||
expect(self.page.get_by_role('link', name='نیازمند اقدام من', exact=True)).to_have_attribute('aria-current', 'page')
|
||||
expect(self.page.get_by_role('heading', name='فیشهای نیازمند اقدام شما')).to_be_visible()
|
||||
|
||||
def search(self, query):
|
||||
self.page.locator('[name=q]').fill(query)
|
||||
self.page.get_by_role('button', name='جستوجو', exact=True).click()
|
||||
|
||||
def next_page(self):
|
||||
pagination = self.page.get_by_role('navigation', name='صفحهبندی', exact=True)
|
||||
expect(pagination).to_contain_text('صفحه 1 از 2')
|
||||
pagination.get_by_role('link', name='بعدی', exact=True).click()
|
||||
expect(pagination).to_contain_text('صفحه 2 از 2')
|
||||
|
||||
def open_notice(self, public_id):
|
||||
self.goto(reverse('staff_payments:notice_detail', args=[public_id]))
|
||||
|
||||
def inspect_history(self):
|
||||
panel = self.page.locator('.pay-sms-panel')
|
||||
expect(panel).to_contain_text('رسیده به گوشی')
|
||||
expect(panel).to_contain_text('پذیرفتهشده توسط سرویس پیامک')
|
||||
expect(panel).to_contain_text('اعتبار پنل کافی نیست')
|
||||
for details in panel.locator('details').all():
|
||||
details.locator('summary').click()
|
||||
expect(panel).to_contain_text('old-delivered-id')
|
||||
expect(panel).to_contain_text('کد پاسخ سرویس: -19')
|
||||
|
||||
def resend_sms(self):
|
||||
button = self.page.get_by_role('button', name='ارسال مجدد پیامک اعلامیه واریز', exact=True)
|
||||
expect(button).to_be_enabled()
|
||||
button.click()
|
||||
expect(self.page.locator('.flash')).to_contain_text('درخواست ارسال مجدد پیامک در صف ثبت شد')
|
||||
expect(button).to_be_disabled()
|
||||
expect(self.page.locator('.pay-sms-history .pay-sms-record')).to_have_count(3)
|
||||
@@ -7,11 +7,47 @@ from apps.core.models import SiteBranding
|
||||
from apps.payments.access import issue_payment_link
|
||||
from apps.payments import services
|
||||
from tests.e2e.pages.payment_link import PaymentLinkPage
|
||||
from tests.e2e.pages.auth import AuthenticationPage
|
||||
from tests.e2e.test_payments_e2e import payment_case, PNG
|
||||
from tests.e2e.test_payments_e2e import PASSWORD
|
||||
from django.urls import reverse
|
||||
|
||||
pytestmark = [pytest.mark.e2e, pytest.mark.django_db(transaction=True)]
|
||||
|
||||
|
||||
@pytest.mark.parametrize('width,via_link,manual', [(320, True, True), (1440, True, False),
|
||||
(320, False, False), (1440, False, True)])
|
||||
def test_notice_sharing_and_full_copy(live_server, browser_page, payment_case, width, via_link, manual):
|
||||
customer, account, reviewers = payment_case
|
||||
notice = services.issue_notice(reviewers[0], {'customer': customer, 'account': account,
|
||||
'subject': 'اشتراک اطلاعات واریز', 'amount': 12345, 'deposit_id': '۰۰۰۱۲۳۰۴'})
|
||||
_, original_url = issue_payment_link(notice=notice)
|
||||
with browser_page() as page:
|
||||
page.set_viewport_size({'width': width, 'height': 900})
|
||||
view = PaymentLinkPage(page, live_server.url)
|
||||
view.prepare_copy(manual)
|
||||
if via_link:
|
||||
view.open_link(urlsplit(original_url).path)
|
||||
else:
|
||||
AuthenticationPage(page, live_server.url).login_customer(customer, PASSWORD)
|
||||
view.goto(reverse('payments:notice_detail', args=[notice.public_id]))
|
||||
expect(page.locator('.pay-deposit-required dd')).to_have_text('12304')
|
||||
full_copy = view.copied_text('کپی کامل اطلاعات')
|
||||
assert 'شناسه واریز: 12304' in full_copy
|
||||
assert 'وارد کردن شناسه واریز هنگام پرداخت الزامی و اجباری است' in full_copy
|
||||
assert 'شماره حساب: 000123' in full_copy
|
||||
shared_url = view.copy_short_link()
|
||||
assert urlsplit(shared_url).path.startswith('/p/')
|
||||
if via_link:
|
||||
assert shared_url == original_url
|
||||
assert page.evaluate('document.documentElement.scrollWidth <= document.documentElement.clientWidth + 1')
|
||||
with browser_page() as recipient_page:
|
||||
recipient = PaymentLinkPage(recipient_page, live_server.url)
|
||||
recipient.open_link(urlsplit(shared_url).path)
|
||||
expect(recipient_page.locator('.pay-deposit-required dd')).to_have_text('12304')
|
||||
expect(recipient_page.get_by_role('link', name='پروفایل من')).to_have_count(0)
|
||||
|
||||
|
||||
@pytest.mark.parametrize('width,linked', [(1440, True), (320, True), (390, False)])
|
||||
def test_sms_payment_without_login_preserves_copy_and_submission(live_server, browser_page, payment_case, width, linked):
|
||||
customer, account, reviewers = payment_case
|
||||
@@ -25,7 +61,7 @@ def test_sms_payment_without_login_preserves_copy_and_submission(live_server, br
|
||||
page.set_viewport_size({'width': width, 'height': 900})
|
||||
view = PaymentLinkPage(page, live_server.url)
|
||||
view.open_link(urlsplit(url).path)
|
||||
expect(page.locator('.pay-deposit-required dd')).to_have_text('000123')
|
||||
expect(page.locator('.pay-deposit-required dd')).to_have_text('123')
|
||||
expect(page.get_by_text('واریز بدون این شناسه معتبر نخواهد بود.', exact=False)).to_be_visible()
|
||||
expect(page.locator('.cs-footer')).to_have_text(branding.developer_credit_text)
|
||||
expect(page.get_by_role('link', name='پروفایل من')).to_have_count(0)
|
||||
|
||||
@@ -150,7 +150,7 @@ def test_staff_issues_identifier_notice_and_customer_sends_linked_receipt(live_s
|
||||
page.get_by_role('link',name='ارسال فیش این واریز',exact=True).click()
|
||||
expect(page.get_by_label('واریز مرتبط (پیشنهادی)',exact=True)).to_be_checked()
|
||||
expect(page.locator('#id_destination_account')).to_have_value(str(account.pk))
|
||||
expect(page.locator('#id_deposit_id')).to_have_value('00123')
|
||||
expect(page.locator('#id_deposit_id')).to_have_value('123')
|
||||
page.locator('#id_paid_amount').fill('100000')
|
||||
page.get_by_role('button',name='امروز',exact=True).click()
|
||||
page.locator('#id_receipt_image').set_input_files({'name':'receipt.png','mimeType':'image/png','buffer':PNG})
|
||||
@@ -362,13 +362,13 @@ def test_notice_identifier_generation_preserves_manual_value_and_customer_contex
|
||||
page.locator('#id_account').select_option(str(account.pk))
|
||||
expect(page.locator('#id_deposit_id')).to_have_attribute('required', '')
|
||||
generate.click()
|
||||
expect(page.locator('#id_deposit_id')).to_have_value('0912000902169')
|
||||
expect(page.locator('#id_deposit_id')).to_have_value('912000902169')
|
||||
page.locator('#id_deposit_id').fill('manual-123')
|
||||
generate.click()
|
||||
expect(page.locator('#identifier-candidate')).to_have_text('0912000902169')
|
||||
expect(page.locator('#identifier-candidate')).to_have_text('912000902169')
|
||||
expect(page.locator('#id_deposit_id')).to_have_value('manual-123')
|
||||
page.get_by_role('button', name='جایگزینی شناسه', exact=True).click()
|
||||
expect(page.locator('#id_deposit_id')).to_have_value('0912000902169')
|
||||
expect(page.locator('#id_deposit_id')).to_have_value('912000902169')
|
||||
page.get_by_role('button', name='تغییر مشتری', exact=True).click()
|
||||
expect(page.locator('#id_deposit_id')).to_have_value('')
|
||||
expect(generate).to_be_disabled()
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
from urllib.parse import urlsplit
|
||||
import uuid
|
||||
|
||||
import jdatetime
|
||||
import pytest
|
||||
from django.contrib.auth.models import Permission
|
||||
from django.core.files.uploadedfile import SimpleUploadedFile
|
||||
from django.urls import reverse
|
||||
from django.utils import timezone
|
||||
from playwright.sync_api import expect
|
||||
|
||||
from apps.payments import services
|
||||
from apps.payments.access import issue_payment_link
|
||||
from apps.payments.models import PaymentReceipt
|
||||
from tests.e2e.pages.auth import AuthenticationPage
|
||||
from tests.e2e.pages.receipt_upload import ReceiptUploadPage
|
||||
from tests.e2e.test_payments_e2e import payment_case, PNG, PASSWORD
|
||||
|
||||
pytestmark = [pytest.mark.e2e, pytest.mark.django_db(transaction=True)]
|
||||
|
||||
|
||||
@pytest.mark.parametrize('mode,width', [('independent', 320), ('linked', 1440), ('staff', 320), ('link', 320)])
|
||||
def test_receipt_upload_progress_processing_and_single_submission(live_server, browser_page, payment_case, mode, width):
|
||||
customer, account, reviewers = payment_case
|
||||
creator = reviewers[0]
|
||||
creator.user_permissions.add(Permission.objects.get(content_type__app_label='payments', codename='add_paymentnotice'))
|
||||
notice = services.issue_notice(creator, {'customer': customer, 'account': account, 'subject': 'تست پیشرفت ارسال', 'amount': 12345})
|
||||
_, link_url = issue_payment_link(notice=notice)
|
||||
with browser_page() as page:
|
||||
page.set_viewport_size({'width': width, 'height': 900})
|
||||
view = ReceiptUploadPage(page, live_server.url)
|
||||
view.control_transport()
|
||||
if mode == 'staff':
|
||||
AuthenticationPage(page, live_server.url).login_staff(creator, PASSWORD)
|
||||
view.goto(reverse('staff_payments:receipt_new', args=[customer.public_id]))
|
||||
elif mode == 'link':
|
||||
view.goto(urlsplit(link_url).path)
|
||||
page.get_by_role('link', name='ارسال فیش این واریز', exact=True).click()
|
||||
else:
|
||||
AuthenticationPage(page, live_server.url).login_customer(customer, PASSWORD)
|
||||
view.goto(reverse('payments:receipt_new') + ('?notice=' + str(notice.public_id) if mode == 'linked' else ''))
|
||||
if mode in ('independent', 'staff'):
|
||||
page.get_by_label('واریز مستقل', exact=True).check()
|
||||
view.fill_receipt(account.pk, PNG)
|
||||
view.submit()
|
||||
view.assert_uploading()
|
||||
view.repeat_submission()
|
||||
assert page.evaluate('document.documentElement.scrollWidth <= document.documentElement.clientWidth + 1')
|
||||
view.processing()
|
||||
view.finish()
|
||||
assert PaymentReceipt.objects.filter(customer=customer).count() == 1
|
||||
|
||||
|
||||
@pytest.mark.parametrize('failure', ['network', 'validation'])
|
||||
def test_failed_upload_retains_image_and_can_retry(live_server, browser_page, payment_case, failure):
|
||||
customer, account, _ = payment_case
|
||||
with browser_page() as page:
|
||||
view = ReceiptUploadPage(page, live_server.url)
|
||||
view.control_transport()
|
||||
AuthenticationPage(page, live_server.url).login_customer(customer, PASSWORD)
|
||||
view.goto(reverse('payments:receipt_new'))
|
||||
page.get_by_label('واریز مستقل', exact=True).check()
|
||||
view.fill_receipt(account.pk, PNG)
|
||||
key = page.locator('[name=submission_key]').input_value()
|
||||
view.submit()
|
||||
view.assert_uploading()
|
||||
view.fail_network() if failure == 'network' else view.reject_fields()
|
||||
expect(page.locator('#id_paid_amount')).to_have_value('۱۲٬۳۴۵')
|
||||
assert page.locator('[name=submission_key]').input_value() == key
|
||||
view.submit()
|
||||
expect(page.locator('#receipt-upload-title')).to_have_text('در حال ارسال فیش…')
|
||||
view.finish()
|
||||
assert PaymentReceipt.objects.filter(customer=customer).count() == 1
|
||||
|
||||
|
||||
def test_revised_receipt_uses_upload_progress(live_server, browser_page, payment_case, client):
|
||||
customer, account, reviewers = payment_case
|
||||
today = jdatetime.date.fromgregorian(date=timezone.localdate())
|
||||
client.force_login(customer)
|
||||
response = client.post(reverse('payments:receipt_new'), {
|
||||
'receipt_mode': 'independent', 'paid_amount': '12345', 'destination_account': account.pk,
|
||||
'paid_day': str(today.day), 'paid_month': str(today.month), 'paid_year': str(today.year),
|
||||
'submission_key': str(uuid.uuid4()),
|
||||
'receipt_image': SimpleUploadedFile('receipt.png', PNG, content_type='image/png'),
|
||||
})
|
||||
assert response.status_code == 302
|
||||
receipt = PaymentReceipt.objects.get(customer=customer)
|
||||
services.decide_receipt(reviewers[0], receipt.public_id, 1, 'REJECT', 'تصویر واضحتر لازم است')
|
||||
with browser_page() as page:
|
||||
view = ReceiptUploadPage(page, live_server.url)
|
||||
view.control_transport()
|
||||
AuthenticationPage(page, live_server.url).login_customer(customer, PASSWORD)
|
||||
view.goto(reverse('payments:receipt_revise', args=[receipt.public_id]))
|
||||
view.fill_receipt(account.pk, PNG)
|
||||
view.submit()
|
||||
view.assert_uploading()
|
||||
view.repeat_submission()
|
||||
view.processing()
|
||||
view.finish(revision=True)
|
||||
receipt.refresh_from_db()
|
||||
assert receipt.current_version == 2 and receipt.revisions.count() == 2
|
||||
@@ -0,0 +1,76 @@
|
||||
import uuid
|
||||
from urllib.parse import parse_qs, urlsplit
|
||||
|
||||
import jdatetime
|
||||
import pytest
|
||||
from django.core.files.uploadedfile import SimpleUploadedFile
|
||||
from django.urls import reverse
|
||||
from django.utils import timezone
|
||||
|
||||
from apps.notifications.models import NotificationJob
|
||||
from apps.payments import services
|
||||
from apps.payments.models import PaymentNotification, PaymentNotificationAttempt
|
||||
from tests.e2e.pages.auth import AuthenticationPage
|
||||
from tests.e2e.pages.staff_payments import StaffPaymentsPage
|
||||
from tests.e2e.test_payments_e2e import payment_case, PASSWORD, PNG
|
||||
|
||||
pytestmark = [pytest.mark.e2e, pytest.mark.django_db(transaction=True)]
|
||||
|
||||
|
||||
@pytest.mark.parametrize('notices', [False, True])
|
||||
def test_staff_default_action_tab_and_pagination(live_server, browser_page, payment_case, client, notices):
|
||||
customer, account, reviewers = payment_case
|
||||
if notices:
|
||||
for index in range(26):
|
||||
services.issue_notice(reviewers[0], {'customer': customer, 'account': account, 'subject': f'اعلامیه صفحه بندی {index}', 'amount': 12345})
|
||||
else:
|
||||
client.force_login(customer)
|
||||
today = jdatetime.date.fromgregorian(date=timezone.localdate())
|
||||
for _ in range(26):
|
||||
response = client.post(reverse('payments:receipt_new'), {'receipt_mode': 'independent',
|
||||
'paid_amount': '12345', 'destination_account': account.pk, 'submission_key': str(uuid.uuid4()),
|
||||
'paid_day': str(today.day), 'paid_month': str(today.month), 'paid_year': str(today.year),
|
||||
'receipt_image': SimpleUploadedFile('receipt.png', PNG, content_type='image/png')})
|
||||
assert response.status_code == 302
|
||||
with browser_page() as page:
|
||||
page.set_viewport_size({'width': 320, 'height': 900})
|
||||
AuthenticationPage(page, live_server.url).login_staff(reviewers[0], PASSWORD)
|
||||
view = StaffPaymentsPage(page, live_server.url)
|
||||
view.open_desk()
|
||||
view.assert_action_tab()
|
||||
if notices:
|
||||
page.get_by_role('link', name='اعلامیههای واریز', exact=True).click()
|
||||
view.search(customer.username)
|
||||
view.next_page()
|
||||
query = parse_qs(urlsplit(page.url).query)
|
||||
assert query['q'] == [customer.username]
|
||||
assert query['tab'] == ['notices' if notices else 'receipts']
|
||||
if not notices:
|
||||
assert query['mine'] == ['1']
|
||||
view.assert_action_tab()
|
||||
assert page.evaluate('document.documentElement.scrollWidth <= document.documentElement.clientWidth + 1')
|
||||
|
||||
|
||||
@pytest.mark.parametrize('width', [320, 1440])
|
||||
def test_staff_sms_history_and_resend(live_server, browser_page, payment_case, width):
|
||||
customer, account, reviewers = payment_case
|
||||
notice = services.issue_notice(reviewers[0], {'customer': customer, 'account': account, 'subject': 'اعلامیه با سوابق پیامک', 'amount': 12345})
|
||||
first = PaymentNotification.objects.get(notice=notice)
|
||||
NotificationJob.objects.filter(pk=first.job_id).update(status='FAILED', attempts=1, last_error='اعتبار پنل کافی نیست')
|
||||
PaymentNotificationAttempt.objects.create(notification=first, finished_at=timezone.now(), success=False,
|
||||
provider_code='-19', error='اعتبار پنل کافی نیست')
|
||||
delivered = services.resend_notice_sms(reviewers[0], notice, str(uuid.uuid4()))
|
||||
NotificationJob.objects.filter(pk=delivered.pk).update(status='SENT', provider_message_id='old-delivered-id',
|
||||
sent_at=timezone.now(), delivery_status='DELIVERED', delivery_is_final=True, delivery_checked_at=timezone.now())
|
||||
PaymentNotificationAttempt.objects.create(notification=delivered.payment_notification, finished_at=timezone.now(),
|
||||
success=True, provider_code='2', provider_id='old-delivered-id')
|
||||
with browser_page() as page:
|
||||
page.set_viewport_size({'width': width, 'height': 900})
|
||||
AuthenticationPage(page, live_server.url).login_staff(reviewers[0], PASSWORD)
|
||||
view = StaffPaymentsPage(page, live_server.url)
|
||||
view.open_notice(notice.public_id)
|
||||
view.inspect_history()
|
||||
assert page.evaluate('document.documentElement.scrollWidth <= document.documentElement.clientWidth + 1')
|
||||
view.resend_sms()
|
||||
assert PaymentNotification.objects.filter(notice=notice).count() == 3
|
||||
assert PaymentNotification.objects.filter(notice=notice, job__status='PENDING').count() == 1
|
||||
@@ -31,6 +31,71 @@ def open_notice(client, case):
|
||||
return link
|
||||
|
||||
|
||||
def test_customer_shares_short_link_once_without_sending_sms(case, client):
|
||||
endpoint = reverse('payments:notice_share_link', args=[case['notice'].public_id])
|
||||
client.force_login(case['customer'])
|
||||
jobs = NotificationJob.objects.count()
|
||||
assert client.get(endpoint).status_code == 405
|
||||
response = client.post(endpoint)
|
||||
assert response.status_code == 200
|
||||
assert response['Cache-Control'] == 'private, no-store'
|
||||
url = response.json()['url']
|
||||
assert re.fullmatch(r'/p/[a-z0-9]{10}/', urlsplit(url).path)
|
||||
assert client.post(endpoint).json()['url'] == url
|
||||
assert PaymentAccessLink.objects.count() == 1
|
||||
assert NotificationJob.objects.count() == jobs
|
||||
recipient = Client()
|
||||
page = recipient.get(urlsplit(url).path, follow=True)
|
||||
assert page.status_code == 200
|
||||
assert case['notice'].number in page.content.decode()
|
||||
assert '_auth_user_id' not in recipient.session
|
||||
assert recipient.post(endpoint).json()['url'] == url
|
||||
|
||||
|
||||
def test_anonymous_share_reuses_original_expiry_and_revocation(case, client):
|
||||
link, url = issue_payment_link(notice=case['notice'])
|
||||
endpoint = reverse('payments:notice_share_link', args=[case['notice'].public_id])
|
||||
client.get(urlsplit(url).path, follow=True)
|
||||
before = link.expires_at
|
||||
assert client.post(endpoint).json()['url'] == url
|
||||
link.refresh_from_db()
|
||||
assert link.expires_at == before
|
||||
assert PaymentAccessLink.objects.count() == 1
|
||||
link.revoked_at = timezone.now()
|
||||
link.save(update_fields=['revoked_at'])
|
||||
assert client.post(endpoint).status_code == 410
|
||||
assert PaymentAccessLink.objects.count() == 1
|
||||
|
||||
|
||||
def test_share_link_checks_owner_scope_and_csrf(case, client):
|
||||
endpoint = reverse('payments:notice_share_link', args=[case['notice'].public_id])
|
||||
other = User.objects.create_user(username='other-share-owner', account_type='CUSTOMER')
|
||||
client.force_login(other)
|
||||
assert client.post(endpoint).status_code == 404
|
||||
client.force_login(case['creator'])
|
||||
assert client.post(endpoint).status_code == 403
|
||||
csrf_client = Client(enforce_csrf_checks=True)
|
||||
csrf_client.force_login(case['customer'])
|
||||
assert csrf_client.post(endpoint).status_code == 403
|
||||
open_notice(client := Client(), case)
|
||||
outside = services.issue_notice(case['creator'], {'customer': case['customer'], 'account': case['account'],
|
||||
'subject': 'اعلامیه خارج از لینک اشتراک', 'amount': 100, 'deposit_id': '00044'})
|
||||
assert client.post(reverse('payments:notice_share_link', args=[outside.public_id])).status_code == 404
|
||||
|
||||
|
||||
@pytest.mark.parametrize('with_identifier', [False, True])
|
||||
def test_full_copy_mentions_required_identifier_only_when_present(case, client, with_identifier):
|
||||
from apps.payments.models import PaymentNotice
|
||||
PaymentNotice.objects.filter(pk=case['notice'].pk).update(deposit_id='۰۰۰۱۲۳' if with_identifier else '')
|
||||
client.force_login(case['customer'])
|
||||
response = client.get(reverse('payments:notice_detail', args=[case['notice'].public_id]))
|
||||
text = response.context['notice'].copy_text
|
||||
assert ('وارد کردن شناسه واریز هنگام پرداخت الزامی و اجباری است' in text) == with_identifier
|
||||
assert ('شناسه واریز: 123' in text) == with_identifier
|
||||
assert 'شماره حساب: 000123456' in text
|
||||
assert 'کپی لینک کوتاه این صفحه برای ارسال به دیگران' in response.content.decode()
|
||||
|
||||
|
||||
def test_sms_link_is_temporary_reusable_and_does_not_log_in(case, client, settings):
|
||||
settings.PAYMENT_LINK_TTL_DAYS = 3
|
||||
job = NotificationJob.objects.get(event_type='PAYMENT_NOTICE_CREATED')
|
||||
@@ -112,7 +177,7 @@ def test_public_notice_preserves_copy_warning_and_database_signature(case, clien
|
||||
open_notice(client, case)
|
||||
response = client.get(reverse('payments:notice_detail', args=[case['notice'].public_id]))
|
||||
html = response.content.decode()
|
||||
assert 'data-copy="000123"' in html
|
||||
assert 'data-copy="123"' in html
|
||||
assert 'کپی کامل اطلاعات' in html and 'pay-deposit-required' in html
|
||||
assert case['account'].name in html
|
||||
assert 'نام حساب: ' + case['account'].name in response.context['notice'].copy_text
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"""PostgreSQL-only regression for simultaneous notice deletion/receipt submission."""
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from threading import Barrier
|
||||
from uuid import uuid4
|
||||
|
||||
import pytest
|
||||
from django.core.exceptions import ValidationError
|
||||
@@ -9,12 +10,38 @@ from django.db import close_old_connections, connection
|
||||
from apps.payments import services
|
||||
from apps.payments.forms import ReceiptForm
|
||||
from apps.payments.models import PaymentReceipt
|
||||
from apps.payments.models import PaymentNotification
|
||||
from apps.notifications.models import NotificationJob
|
||||
from tests.test_payments import case, data, upload
|
||||
|
||||
pytestmark = [pytest.mark.django_db(transaction=True),
|
||||
pytest.mark.skipif(connection.vendor != 'postgresql', reason='Requires PostgreSQL row locks')]
|
||||
|
||||
|
||||
@pytest.mark.parametrize('same_key', [True, False])
|
||||
def test_simultaneous_notice_sms_resend_creates_one_job(case, same_key):
|
||||
original = PaymentNotification.objects.get(notice=case['notice'])
|
||||
NotificationJob.objects.filter(pk=original.job_id).update(status='FAILED')
|
||||
first_key = str(uuid4())
|
||||
keys = [first_key, first_key if same_key else str(uuid4())]
|
||||
barrier = Barrier(2)
|
||||
def resend(key):
|
||||
close_old_connections()
|
||||
try:
|
||||
barrier.wait(timeout=10)
|
||||
try:
|
||||
return services.resend_notice_sms(case['creator'], case['notice'], key).pk
|
||||
except ValidationError:
|
||||
return 'blocked'
|
||||
finally:
|
||||
connection.close()
|
||||
with ThreadPoolExecutor(max_workers=2) as pool:
|
||||
outcomes = list(pool.map(resend, keys))
|
||||
assert PaymentNotification.objects.filter(notice=case['notice']).count() == 2
|
||||
assert len(set(outcomes)) == (1 if same_key else 2)
|
||||
assert outcomes.count('blocked') == (0 if same_key else 1)
|
||||
|
||||
|
||||
def test_simultaneous_delete_and_receipt_allow_exactly_one_operation(case):
|
||||
form = ReceiptForm(data(case), {'receipt_image': upload()}, customer=case['customer'])
|
||||
assert form.is_valid(), form.errors
|
||||
|
||||
@@ -0,0 +1,269 @@
|
||||
"""Receipt alert integration regressions; providers are always mocked."""
|
||||
from datetime import timedelta
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import Mock
|
||||
|
||||
import pytest
|
||||
import requests
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.core.management import call_command
|
||||
from django.db import IntegrityError, transaction
|
||||
from django.urls import reverse
|
||||
from django.utils import timezone
|
||||
|
||||
from apps.core.templatetags.jalali import jalali_datetime
|
||||
from apps.integrations.bale.providers import BaleBotProvider
|
||||
from apps.integrations.sms.base import SMSDeliveryResult, SMSResult
|
||||
from apps.notifications.management.commands import process_notifications as worker
|
||||
from apps.notifications.models import NotificationJob
|
||||
from apps.payments import receipt_alerts, services
|
||||
from apps.payments.alert_templates import validate_alert_template
|
||||
from apps.payments.forms import ReceiptAlertConfigurationForm, ReceiptForm
|
||||
from apps.payments.models import (PaymentAuditEvent, PaymentReceipt, PaymentReceiptAlert,
|
||||
PaymentReceiptAlertConfiguration, PaymentReceiptAlertRecipient)
|
||||
from tests.test_payment_links import open_notice
|
||||
from tests.test_payments import case, data, grant, submit, upload, PNG
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def enabled():
|
||||
config = PaymentReceiptAlertConfiguration.objects.create(bale_enabled=True, bale_token='private-payment-token',
|
||||
bale_chat_id='receipt-group', sms_enabled=True)
|
||||
for name, mobile, active in [('مدیر اول', '09121110001', True), ('مدیر دوم', '09121110002', True), ('خاموش', '09121110003', False)]:
|
||||
PaymentReceiptAlertRecipient.objects.create(configuration=config, name=name, mobile=mobile, is_active=active)
|
||||
return config
|
||||
|
||||
|
||||
def run(job):
|
||||
call_command('process_notifications', once=True, job_id=job.pk)
|
||||
job.refresh_from_db()
|
||||
return job
|
||||
|
||||
|
||||
def test_initially_disabled_and_staff_uploads_do_not_notify(case, enabled, monkeypatch):
|
||||
network = Mock(side_effect=AssertionError('Network must not run in registration'))
|
||||
monkeypatch.setattr(requests, 'post', network)
|
||||
enabled.bale_enabled = enabled.sms_enabled = False
|
||||
enabled.save()
|
||||
submit(case)
|
||||
enabled.bale_enabled = enabled.sms_enabled = True
|
||||
enabled.save()
|
||||
form = ReceiptForm(data(case, linked=False), {'receipt_image': upload()}, customer=case['customer'])
|
||||
assert form.is_valid(), form.errors
|
||||
services.submit_receipt(case['creator'], case['customer'], form.cleaned_data)
|
||||
assert not PaymentReceiptAlert.objects.exists()
|
||||
network.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.parametrize('anonymous,linked', [(False, True), (False, False), (True, True), (True, False)])
|
||||
def test_all_customer_entry_points_enqueue_once_without_network(case, enabled, client, monkeypatch, anonymous, linked):
|
||||
network = Mock(side_effect=AssertionError('Network must not run in registration'))
|
||||
monkeypatch.setattr(requests, 'post', network)
|
||||
case['customer'].first_name = 'مشتری'
|
||||
case['customer'].last_name = 'آزمایشی'
|
||||
case['customer'].save()
|
||||
if anonymous:
|
||||
open_notice(client, case)
|
||||
else:
|
||||
client.force_login(case['customer'])
|
||||
values = data(case, linked=linked)
|
||||
endpoint = reverse('payments:receipt_new')
|
||||
for _ in range(2):
|
||||
response = client.post(endpoint, {**values, 'receipt_image': upload()}, HTTP_ACCEPT='application/json')
|
||||
assert response.status_code == 200, response.content
|
||||
receipt = PaymentReceipt.objects.get(customer=case['customer'])
|
||||
alerts = list(PaymentReceiptAlert.objects.filter(revision__receipt=receipt))
|
||||
assert len(alerts) == 3
|
||||
assert {a.recipient_mobile for a in alerts} == {'', '+989121110001', '+989121110002'}
|
||||
assert alerts[0].context['customer_name'] == 'مشتری آزمایشی'
|
||||
assert alerts[0].context['customer_mobile'] == '09120009999'
|
||||
assert alerts[0].context['received_at'] == jalali_datetime(receipt.revisions.get().created_at)
|
||||
assert alerts[0].context['amount_rial'] == '12,345'
|
||||
assert all(enabled.bale_token not in str(a.context) + a.message_template + str(a.job.payload) for a in alerts)
|
||||
network.assert_not_called()
|
||||
|
||||
|
||||
def test_customer_corrections_have_separate_idempotent_alerts_and_can_be_disabled(case, enabled):
|
||||
receipt = submit(case)
|
||||
services.decide_receipt(case['first'], receipt.public_id, 1, 'REJECT', 'تصویر خوانا نیست')
|
||||
form = ReceiptForm(data(case), {'receipt_image': upload()}, customer=case['customer'])
|
||||
assert form.is_valid(), form.errors
|
||||
services.revise_receipt(case['customer'], receipt.public_id, 1, form.cleaned_data)
|
||||
assert PaymentReceiptAlert.objects.count() == 6
|
||||
receipt_alerts.safe_queue_receipt_alerts(receipt.revisions.get(version=2), case['customer'])
|
||||
assert PaymentReceiptAlert.objects.count() == 6
|
||||
enabled.notify_revisions = False
|
||||
enabled.save()
|
||||
services.decide_receipt(case['first'], receipt.public_id, 2, 'REJECT', 'اصلاح لازم است')
|
||||
form = ReceiptForm(data(case), {'receipt_image': upload()}, customer=case['customer'])
|
||||
assert form.is_valid()
|
||||
services.revise_receipt(case['customer'], receipt.public_id, 2, form.cleaned_data)
|
||||
assert PaymentReceiptAlert.objects.count() == 6
|
||||
|
||||
|
||||
def test_outer_rollback_removes_receipt_and_alerts(case, enabled):
|
||||
with pytest.raises(RuntimeError), transaction.atomic():
|
||||
submit(case)
|
||||
raise RuntimeError('rollback')
|
||||
assert not PaymentReceipt.objects.exists() and not PaymentReceiptAlert.objects.exists()
|
||||
assert not NotificationJob.objects.filter(event_type=receipt_alerts.RECEIPT_ALERT_EVENT).exists()
|
||||
|
||||
|
||||
def test_destination_database_failure_does_not_break_receipt_or_other_destinations(case, enabled, monkeypatch, caplog):
|
||||
original = PaymentReceiptAlert.objects.get_or_create
|
||||
def fail_group(**kwargs):
|
||||
if kwargs['job'].channel == 'BALE':
|
||||
raise IntegrityError('secret must not be logged')
|
||||
return original(**kwargs)
|
||||
monkeypatch.setattr(PaymentReceiptAlert.objects, 'get_or_create', fail_group)
|
||||
receipt = submit(case)
|
||||
assert receipt.revisions.count() == 1 and PaymentReceiptAlert.objects.count() == 2
|
||||
assert not NotificationJob.objects.filter(event_type=receipt_alerts.RECEIPT_ALERT_EVENT, channel='BALE').exists()
|
||||
assert 'IntegrityError' in caplog.text and 'secret must not be logged' not in caplog.text
|
||||
|
||||
|
||||
def test_configuration_failure_isolated_from_registration(case, enabled, monkeypatch):
|
||||
monkeypatch.setattr(receipt_alerts, 'queue_receipt_alerts', Mock(side_effect=IntegrityError('unavailable')))
|
||||
receipt = submit(case)
|
||||
assert PaymentReceipt.objects.filter(pk=receipt.pk).exists()
|
||||
assert receipt.revisions.count() == 1
|
||||
|
||||
|
||||
def test_worker_sends_private_photo_with_database_token_and_sms_to_managers(case, enabled, monkeypatch, settings):
|
||||
settings.BALE_BOT_TOKEN = 'existing-ticket-token'
|
||||
receipt = submit(case)
|
||||
posted, sent = [], []
|
||||
def post(url, **kwargs):
|
||||
posted.append((url, kwargs['data'], kwargs['files']['photo'][1].read(), kwargs['timeout']))
|
||||
return SimpleNamespace(ok=True, json=lambda: {'ok': True, 'result': {'message_id': 72}})
|
||||
class Provider:
|
||||
def send_message(self, mobile, body):
|
||||
sent.append((mobile, body))
|
||||
return SMSResult(True, 'manager-sms-id')
|
||||
def get_delivery_status(self, provider_id):
|
||||
return SMSDeliveryResult('DELIVERED', '0', 'رسیده به گوشی مدیر', True)
|
||||
monkeypatch.setattr(requests, 'post', post)
|
||||
monkeypatch.setattr(worker, 'get_sms_provider', lambda: Provider())
|
||||
for alert in receipt.revisions.get().alerts.select_related('job'):
|
||||
assert run(alert.job).status == 'SENT'
|
||||
assert len(posted) == 1 and posted[0][0].endswith('/botprivate-payment-token/sendPhoto')
|
||||
assert posted[0][1]['chat_id'] == 'receipt-group' and posted[0][2] == PNG
|
||||
assert 'نام ثبت نشده' in posted[0][1]['caption'] and '09120009999' in posted[0][1]['caption']
|
||||
assert jalali_datetime(receipt.revisions.get().created_at) in posted[0][1]['caption']
|
||||
assert len(sent) == 2 and {mobile for mobile, _ in sent} == {'+989121110001', '+989121110002'}
|
||||
assert all('12,345' in body and '09120009999' in body for _, body in sent)
|
||||
sms = NotificationJob.objects.filter(event_type=receipt_alerts.RECEIPT_ALERT_EVENT, channel='SMS').first()
|
||||
assert sms.delivery_status == 'PENDING'
|
||||
NotificationJob.objects.filter(pk=sms.pk).update(delivery_next_check_at=timezone.now() - timedelta(seconds=1))
|
||||
assert run(sms).delivery_status == 'DELIVERED'
|
||||
assert len(sent) == 2
|
||||
|
||||
|
||||
@pytest.mark.parametrize('channel', ['BALE', 'SMS'])
|
||||
def test_outages_retry_bounded_and_preserve_receipt(case, enabled, monkeypatch, settings, channel):
|
||||
settings.SMS_MAX_RETRIES = 1
|
||||
settings.MAX_NOTIFICATION_ATTEMPTS = 2
|
||||
receipt = submit(case)
|
||||
monkeypatch.setattr(requests, 'post', Mock(side_effect=requests.Timeout('private-payment-token')))
|
||||
monkeypatch.setattr(worker, 'get_sms_provider', lambda: SimpleNamespace(send_message=Mock(side_effect=RuntimeError('private-payment-token'))))
|
||||
job = PaymentReceiptAlert.objects.filter(job__channel=channel).first().job
|
||||
run(job)
|
||||
assert job.status == ('RETRYING' if channel == 'SMS' else 'FAILED') and job.attempts == 1
|
||||
assert 'private-payment-token' not in job.last_error
|
||||
NotificationJob.objects.filter(pk=job.pk).update(next_attempt_at=timezone.now())
|
||||
run(job)
|
||||
assert job.status == 'FAILED' and job.attempts == 2
|
||||
NotificationJob.objects.filter(pk=job.pk).update(next_attempt_at=timezone.now())
|
||||
run(job)
|
||||
assert job.attempts == 2 and receipt.revisions.get().image.storage.exists(receipt.revisions.get().image.name)
|
||||
|
||||
|
||||
@pytest.mark.parametrize('change', ['bale_off', 'sms_off', 'group_changed', 'mobile_changed', 'manager_off'])
|
||||
def test_pending_jobs_respect_switches_and_destination_changes(case, enabled, monkeypatch, change):
|
||||
submit(case)
|
||||
channel = 'BALE' if change in ['bale_off', 'group_changed'] else 'SMS'
|
||||
alert = PaymentReceiptAlert.objects.filter(job__channel=channel).first()
|
||||
if change == 'bale_off': enabled.bale_enabled = False
|
||||
if change == 'sms_off': enabled.sms_enabled = False
|
||||
if change == 'group_changed': enabled.bale_chat_id = 'other-group'
|
||||
enabled.save()
|
||||
if change in ['mobile_changed', 'manager_off']:
|
||||
recipient = alert.recipient
|
||||
if change == 'mobile_changed': recipient.mobile = '09124440000'
|
||||
else: recipient.is_active = False
|
||||
recipient.save()
|
||||
post = Mock(side_effect=AssertionError('Cancelled destination'))
|
||||
provider = Mock(side_effect=AssertionError('Cancelled destination'))
|
||||
monkeypatch.setattr(requests, 'post', post)
|
||||
monkeypatch.setattr(worker, 'get_sms_provider', provider)
|
||||
assert run(alert.job).status == 'CANCELLED'
|
||||
post.assert_not_called(); provider.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.parametrize('payload', [[], {'ok': False}, {'ok': True, 'result': {}}, {'ok': True, 'result': None}])
|
||||
def test_malformed_bale_response_never_becomes_success(monkeypatch, payload):
|
||||
monkeypatch.setattr(requests, 'post', lambda *a, **k: SimpleNamespace(ok=True, json=lambda: payload))
|
||||
assert not BaleBotProvider().send_photo('group', upload(), token='secret').success
|
||||
|
||||
|
||||
@pytest.mark.parametrize('template', ['{customer_name.__class__}', '{customer_name[0]}', '{amount_rial:99}', '{customer_name!r}', '{unknown}', '{}', '{broken', ''])
|
||||
def test_templates_reject_unsafe_or_missing_tags(template):
|
||||
with pytest.raises(ValidationError):
|
||||
validate_alert_template(template)
|
||||
|
||||
|
||||
def config_values(config, **extra):
|
||||
return {'bale_enabled': config.bale_enabled, 'bale_token': '', 'bale_chat_id': config.bale_chat_id,
|
||||
'bale_caption': config.bale_caption, 'sms_enabled': config.sms_enabled,
|
||||
'sms_template': config.sms_template, 'notify_revisions': config.notify_revisions, **extra}
|
||||
|
||||
|
||||
def test_configuration_form_keeps_hides_replaces_and_clears_token(enabled):
|
||||
assert enabled.bale_token not in ReceiptAlertConfigurationForm(instance=enabled).as_p()
|
||||
for value, expected in [('', enabled.bale_token), ('rotated-token', 'rotated-token')]:
|
||||
form = ReceiptAlertConfigurationForm(config_values(enabled, bale_token=value), instance=enabled)
|
||||
assert form.is_valid(), form.errors
|
||||
assert form.save().bale_token == expected
|
||||
form = ReceiptAlertConfigurationForm(config_values(enabled, clear_bale_token=True), instance=enabled)
|
||||
assert not form.is_valid()
|
||||
enabled.refresh_from_db()
|
||||
form = ReceiptAlertConfigurationForm(config_values(enabled, bale_enabled=False, clear_bale_token=True), instance=enabled)
|
||||
assert form.is_valid(), form.errors
|
||||
assert form.save().bale_token == ''
|
||||
|
||||
|
||||
def test_admin_can_manage_configuration_and_recipients_without_exposing_token(case, enabled, client):
|
||||
user = case['creator']; user.is_staff = True; user.save()
|
||||
grant(user, 'manage_receipt_alerts')
|
||||
client.force_login(user)
|
||||
endpoint = reverse('admin:payments_paymentreceiptalertconfiguration_change', args=[1])
|
||||
page = client.get(endpoint)
|
||||
assert page.status_code == 200 and enabled.bale_token not in page.content.decode()
|
||||
recipients = list(enabled.recipients.all())
|
||||
values = config_values(enabled, sms_template='مدیر {manager_name}: {amount_rial} ریال از {customer_mobile}')
|
||||
values.update({'recipients-TOTAL_FORMS': len(recipients), 'recipients-INITIAL_FORMS': len(recipients),
|
||||
'recipients-MIN_NUM_FORMS': 0, 'recipients-MAX_NUM_FORMS': 1000, '_save': 'Save'})
|
||||
for i, recipient in enumerate(recipients):
|
||||
values.update({f'recipients-{i}-id': recipient.pk, f'recipients-{i}-configuration': 1,
|
||||
f'recipients-{i}-name': recipient.name, f'recipients-{i}-mobile': recipient.mobile})
|
||||
if recipient.is_active: values[f'recipients-{i}-is_active'] = 'on'
|
||||
response = client.post(endpoint, values)
|
||||
assert response.status_code == 302, response.context['adminform'].form.errors if response.status_code == 200 else response.content
|
||||
enabled.refresh_from_db()
|
||||
assert enabled.bale_token == 'private-payment-token' and enabled.sms_template == values['sms_template']
|
||||
audit = PaymentAuditEvent.objects.filter(action='CONFIG_SAVED').latest('pk')
|
||||
assert enabled.bale_token not in str(audit.details)
|
||||
submit(case)
|
||||
assert client.get(reverse('admin:payments_paymentreceiptalert_changelist')).status_code == 200
|
||||
assert client.get(reverse('admin:payments_paymentreceiptalert_change', args=[PaymentReceiptAlert.objects.first().pk])).status_code == 200
|
||||
client.force_login(case['outsider'])
|
||||
assert client.get(endpoint).status_code in [302, 403]
|
||||
|
||||
|
||||
def test_recipient_invalid_mobile_is_validation_error_and_normalization_deduplicates(enabled):
|
||||
with pytest.raises(ValidationError):
|
||||
PaymentReceiptAlertRecipient.objects.create(configuration=enabled, name='نام', mobile='123')
|
||||
with pytest.raises(IntegrityError), transaction.atomic():
|
||||
PaymentReceiptAlertRecipient.objects.create(configuration=enabled, name='نام', mobile='۰۹۱۲۱۱۱۰۰۰۱')
|
||||
+94
-5
@@ -25,6 +25,51 @@ def upload():
|
||||
return SimpleUploadedFile('receipt.png', PNG, content_type='image/png')
|
||||
|
||||
|
||||
@pytest.mark.parametrize('staff', [False, True])
|
||||
def test_receipt_upload_json_success_keeps_flash_and_deduplicates(case, client, staff):
|
||||
client.force_login(case['creator'] if staff else case['customer'])
|
||||
endpoint = reverse('staff_payments:receipt_new', args=[case['customer'].public_id]) if staff else reverse('payments:receipt_new')
|
||||
values = data(case)
|
||||
first = client.post(endpoint, {**values, 'receipt_image': upload()}, HTTP_ACCEPT='application/json')
|
||||
assert first.status_code == 200
|
||||
receipt = PaymentReceipt.objects.get(customer=case['customer'])
|
||||
expected = reverse('staff_payments:receipt_detail' if staff else 'payments:receipt_detail', args=[receipt.public_id])
|
||||
assert first.json() == {'redirect_url': expected}
|
||||
repeated = client.post(endpoint, {**values, 'receipt_image': upload()}, HTTP_ACCEPT='application/json')
|
||||
assert repeated.json() == first.json()
|
||||
assert PaymentReceipt.objects.filter(customer=case['customer']).count() == 1
|
||||
assert 'فیش ثبت شد و در انتظار بررسی است.' in client.get(expected).content.decode()
|
||||
|
||||
|
||||
def test_receipt_upload_json_field_and_service_errors(case, client, monkeypatch):
|
||||
client.force_login(case['customer'])
|
||||
endpoint = reverse('payments:receipt_new')
|
||||
invalid = client.post(endpoint, {**data(case), 'paid_amount': '0', 'receipt_image': upload()}, HTTP_ACCEPT='application/json')
|
||||
assert invalid.status_code == 422
|
||||
assert invalid.json()['errors']['paid_amount'][0]['message']
|
||||
def unavailable(*args, **kwargs):
|
||||
raise ValidationError('اعلامیه دیگر قابل پرداخت نیست.')
|
||||
monkeypatch.setattr(services, 'submit_receipt', unavailable)
|
||||
response = client.post(endpoint, {**data(case), 'receipt_image': upload()}, HTTP_ACCEPT='application/json')
|
||||
assert response.status_code == 422
|
||||
assert response.json()['errors']['__all__'][0]['message'] == 'اعلامیه دیگر قابل پرداخت نیست.'
|
||||
assert not PaymentReceipt.objects.filter(customer=case['customer']).exists()
|
||||
|
||||
|
||||
def test_receipt_revision_upload_json_errors_and_success(case, client):
|
||||
receipt = submit(case)
|
||||
services.decide_receipt(case['first'], receipt.public_id, 1, 'REJECT', 'تصویر خوانا نیست')
|
||||
client.force_login(case['customer'])
|
||||
endpoint = reverse('payments:receipt_revise', args=[receipt.public_id])
|
||||
stale = client.post(endpoint, {**data(case), 'version': '0', 'receipt_image': upload()}, HTTP_ACCEPT='application/json')
|
||||
assert stale.status_code == 422 and stale.json()['errors']['__all__']
|
||||
valid = client.post(endpoint, {**data(case), 'version': '1', 'receipt_image': upload()}, HTTP_ACCEPT='application/json')
|
||||
assert valid.json() == {'redirect_url': reverse('payments:receipt_detail', args=[receipt.public_id])}
|
||||
receipt.refresh_from_db()
|
||||
assert receipt.current_version == 2 and receipt.revisions.count() == 2
|
||||
assert 'نسخه اصلاحی ثبت شد' in client.get(valid.json()['redirect_url']).content.decode()
|
||||
|
||||
|
||||
def grant(user, code):
|
||||
user.user_permissions.add(Permission.objects.get(content_type__app_label='payments',codename=code))
|
||||
for attr in ['_perm_cache','_user_perm_cache','_group_perm_cache']:
|
||||
@@ -234,7 +279,7 @@ def test_independent_receipt_and_server_date(case,client):
|
||||
assert receipt.notice is None
|
||||
assert receipt.revisions.get().paid_date == timezone.localdate()
|
||||
client.force_login(case['outsider'])
|
||||
response = client.get(reverse('staff_payments:desk'),{'kind':'independent'})
|
||||
response = client.get(reverse('staff_payments:desk'),{'tab':'receipts','kind':'independent'})
|
||||
assert receipt.number in response.content.decode()
|
||||
assert client.get(reverse('staff_payments:count')).json()['pending_receipts'] == 1
|
||||
|
||||
@@ -414,7 +459,7 @@ def test_revision_retains_notice_snapshot_and_identifier(case):
|
||||
services.revise_receipt(case['customer'], receipt.public_id, 1, form.cleaned_data)
|
||||
revision = receipt.revisions.get(version=2)
|
||||
assert revision.account_snapshot['account_number'] == '000123456'
|
||||
assert revision.deposit_id == '000123'
|
||||
assert revision.deposit_id == '123'
|
||||
|
||||
|
||||
def test_financial_unit_sms_template_accepts_financial_tags(case):
|
||||
@@ -723,7 +768,7 @@ def test_linked_receipt_rejection_history_and_staff_identity(case, client):
|
||||
staff = client.get(reverse('staff_payments:receipt_detail', args=[receipt.public_id])).content.decode()
|
||||
assert all(value in staff for value in ['مریم', 'امینی', 'اصفهان', 'گلپایگان', case['customer'].local_mobile])
|
||||
assert 'افراد تعیینشده' not in staff
|
||||
listing = client.get(reverse('staff_payments:desk'), {'q': 'گلپایگان'}).content.decode()
|
||||
listing = client.get(reverse('staff_payments:desk'), {'tab': 'receipts', 'q': 'گلپایگان'}).content.decode()
|
||||
assert 'مریم' in listing and 'ایجادکننده اعلامیه: creator' in listing
|
||||
|
||||
|
||||
@@ -787,7 +832,7 @@ def test_customer_notice_has_independent_owned_detail_url(case, client):
|
||||
assert staff.context['notice_data'][0]['url'] == reverse('staff_payments:notice_detail', args=[case['notice'].public_id])
|
||||
|
||||
|
||||
@pytest.mark.parametrize('mobile,expected', [('09120009999','0912000999938'), ('09120009021','0912000902169'), ('09000000000','0900000000090')])
|
||||
@pytest.mark.parametrize('mobile,expected', [('09120009999','912000999938'), ('09120009021','912000902169'), ('09000000000','900000000090')])
|
||||
def test_deposit_identifier_reference_vectors(mobile, expected):
|
||||
from apps.payments.deposit_identifier import generate_id_algorithm
|
||||
assert generate_id_algorithm(mobile) == expected
|
||||
@@ -800,7 +845,7 @@ def test_deposit_identifier_endpoint_uses_stored_mobile_and_permissions(case, cl
|
||||
before = case['customer'].payment_notices.count()
|
||||
response = client.post(endpoint, {'customer': case['customer'].pk, 'mobile': '09000000000'})
|
||||
assert response.status_code == 200
|
||||
assert response.json()['identifier'] == '0912000999938'
|
||||
assert response.json()['identifier'] == '912000999938'
|
||||
assert response['Cache-Control'] == 'no-store'
|
||||
assert case['customer'].payment_notices.count() == before
|
||||
assert client.get(endpoint).status_code == 405
|
||||
@@ -813,3 +858,47 @@ def test_deposit_identifier_endpoint_uses_stored_mobile_and_permissions(case, cl
|
||||
client.force_login(case['creator'])
|
||||
PaymentAccessConfiguration.objects.update_or_create(singleton=1, defaults={'open_create':False})
|
||||
assert client.post(endpoint, {'customer': case['customer'].pk}).status_code == 403
|
||||
|
||||
|
||||
@pytest.mark.parametrize('value,expected', [('', ''), ('000', '0'), ('۰۰۰۱۲۳۰۴', '12304'),
|
||||
('٠٠٩١٢٣٠٦', '912306'), (' 00123 ', '123'), ('manual-123', 'manual-123')])
|
||||
def test_deposit_identifier_normalization(value, expected):
|
||||
from apps.payments.deposit_identifier import normalize_deposit_identifier
|
||||
assert normalize_deposit_identifier(value) == expected
|
||||
|
||||
|
||||
def test_generator_strips_zeroes_after_calculating_check_digits():
|
||||
from apps.payments.deposit_identifier import generate_id_algorithm
|
||||
assert generate_id_algorithm('00009120009999') == '912000999938'
|
||||
assert generate_id_algorithm('000') == '0'
|
||||
assert generate_id_algorithm('invalid').startswith('Error:')
|
||||
|
||||
|
||||
def test_notice_and_receipt_store_normalized_identifiers(case):
|
||||
notice = case['notice']
|
||||
notice.refresh_from_db()
|
||||
assert notice.deposit_id == '123'
|
||||
assert notice.account_snapshot['account_number'] == '000123456'
|
||||
receipt = submit(case)
|
||||
assert receipt.revisions.get(version=1).deposit_id == '123'
|
||||
notice.deposit_id = '٠٠١٢٣٠٤'
|
||||
notice.save(update_fields=['deposit_id'])
|
||||
notice.refresh_from_db()
|
||||
assert notice.deposit_id == '12304'
|
||||
|
||||
|
||||
def test_existing_identifier_migration_preserves_bank_snapshot(case):
|
||||
from importlib import import_module
|
||||
from django.apps import apps
|
||||
from django.db import connection
|
||||
from apps.payments.models import PaymentNotice, PaymentReceiptRevision
|
||||
receipt = submit(case)
|
||||
PaymentNotice.objects.filter(pk=case['notice'].pk).update(deposit_id='۰۰۰۱۲۳۰۴')
|
||||
PaymentReceiptRevision.objects.filter(receipt=receipt).update(deposit_id='00012304')
|
||||
migration = import_module('apps.payments.migrations.0006_normalize_deposit_identifiers')
|
||||
from types import SimpleNamespace
|
||||
migration.normalize_existing_identifiers(apps, SimpleNamespace(connection=connection))
|
||||
case['notice'].refresh_from_db()
|
||||
assert case['notice'].deposit_id == '12304'
|
||||
assert receipt.revisions.get(version=1).deposit_id == '12304'
|
||||
assert case['notice'].account_snapshot['account_number'] == '000123456'
|
||||
@@ -0,0 +1,153 @@
|
||||
import uuid
|
||||
from urllib.parse import parse_qs, urlsplit
|
||||
|
||||
import pytest
|
||||
from django.core.exceptions import PermissionDenied, ValidationError
|
||||
from django.core.management import call_command
|
||||
from django.test import Client
|
||||
from django.urls import reverse
|
||||
from django.utils import timezone
|
||||
|
||||
from apps.integrations.sms.base import SMSDeliveryResult, SMSResult
|
||||
from apps.notifications.models import NotificationJob
|
||||
from apps.notifications.management.commands import process_notifications
|
||||
from apps.payments import services
|
||||
from apps.payments.models import PaymentAuditEvent, PaymentAccessConfiguration, PaymentNotification, PaymentNotice
|
||||
from tests.test_payments import case, submit, grant
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
|
||||
def test_default_action_tab_and_explicit_all_receipts(case, client):
|
||||
receipt = submit(case)
|
||||
client.force_login(case['first'])
|
||||
first = client.get(reverse('staff_payments:desk'))
|
||||
assert first.context['mine'] is True and receipt.number in first.content.decode()
|
||||
assert 'صفحه 1 از 1' in first.content.decode()
|
||||
services.decide_receipt(case['first'], receipt.public_id, 1, 'APPROVE')
|
||||
assert receipt.number not in client.get(reverse('staff_payments:desk')).content.decode()
|
||||
assert receipt.number in client.get(reverse('staff_payments:desk'), {'tab': 'receipts'}).content.decode()
|
||||
client.force_login(case['outsider'])
|
||||
assert receipt.number not in client.get(reverse('staff_payments:desk')).content.decode()
|
||||
assert client.get(reverse('staff_payments:desk'), {'mine': '0'}).context['mine'] is False
|
||||
|
||||
|
||||
@pytest.mark.parametrize('tab', ['notices', 'receipts', 'mine'])
|
||||
def test_desk_pagination_preserves_tab_and_filters(case, client, tab):
|
||||
if tab == 'notices':
|
||||
for index in range(26):
|
||||
services.issue_notice(case['creator'], {'customer': case['customer'], 'account': case['account'],
|
||||
'subject': f'صفحه بندی {index}', 'amount': 12345, 'deposit_id': '123'})
|
||||
else:
|
||||
for _ in range(26):
|
||||
submit(case)
|
||||
client.force_login(case['first'])
|
||||
parameters = {'q': case['customer'].username, 'account': case['account'].pk}
|
||||
if tab != 'mine':
|
||||
parameters['tab'] = tab
|
||||
first = client.get(reverse('staff_payments:desk'), parameters)
|
||||
assert len(first.context['page_obj']) == 25
|
||||
query = parse_qs(first.context['query_string'])
|
||||
assert query['tab'] == ['notices' if tab == 'notices' else 'receipts']
|
||||
assert query.get('mine') == (['1'] if tab == 'mine' else None)
|
||||
assert query['q'] == [case['customer'].username]
|
||||
second = client.get(reverse('staff_payments:desk'), {**parameters, 'page': 2})
|
||||
assert second.context['page_obj'].number == 2 and len(second.context['page_obj']) >= 1
|
||||
assert not {item.pk for item in first.context['page_obj']} & {item.pk for item in second.context['page_obj']}
|
||||
|
||||
|
||||
def test_resend_uses_worker_keeps_attempts_and_delivery_history(case, client, monkeypatch, settings):
|
||||
settings.SMS_MAX_RETRIES = 0
|
||||
sent = []
|
||||
class Provider:
|
||||
def send_message(self, mobile, body):
|
||||
sent.append((mobile, body))
|
||||
return SMSResult(False, error='اعتبار پنل کافی نیست', provider_code='-19') if len(sent) == 1 else SMSResult(True, 'new-sms-id', provider_code='2')
|
||||
def get_delivery_status(self, provider_id):
|
||||
assert provider_id == 'new-sms-id'
|
||||
return SMSDeliveryResult('DELIVERED', '0', 'پیامک به گوشی رسید', True)
|
||||
monkeypatch.setattr(process_notifications, 'get_sms_provider', lambda: Provider())
|
||||
original = PaymentNotification.objects.get(notice=case['notice'])
|
||||
call_command('process_notifications', once=True, job_id=original.job_id)
|
||||
original.refresh_from_db()
|
||||
original.job.refresh_from_db()
|
||||
assert original.job.status == 'FAILED'
|
||||
endpoint = reverse('staff_payments:notice_resend_sms', args=[case['notice'].public_id])
|
||||
detail = reverse('staff_payments:notice_detail', args=[case['notice'].public_id])
|
||||
client.force_login(case['creator'])
|
||||
key = str(uuid.uuid4())
|
||||
assert client.get(endpoint).status_code == 405
|
||||
assert client.post(endpoint, {'request_key': key}).status_code == 302
|
||||
retry = PaymentNotification.objects.filter(notice=case['notice']).exclude(pk=original.pk).get()
|
||||
assert retry.job.status == 'PENDING'
|
||||
client.post(endpoint, {'request_key': key})
|
||||
assert PaymentNotification.objects.filter(notice=case['notice']).count() == 2
|
||||
client.post(endpoint, {'request_key': str(uuid.uuid4())})
|
||||
assert PaymentNotification.objects.filter(notice=case['notice']).count() == 2
|
||||
call_command('process_notifications', once=True, job_id=retry.job_id)
|
||||
retry.refresh_from_db(); retry.job.refresh_from_db()
|
||||
assert retry.job.status == 'SENT' and retry.job.delivery_status == 'PENDING'
|
||||
assert original.access_link_id != retry.access_link_id
|
||||
assert retry.context['payment_link'] in sent[-1][1]
|
||||
awaiting = client.get(detail).content.decode()
|
||||
assert 'پذیرفتهشده توسط سرویس پیامک' in awaiting and 'در انتظار استعلام دریافت توسط گوشی' in awaiting
|
||||
NotificationJob.objects.filter(pk=retry.job_id).update(delivery_next_check_at=timezone.now())
|
||||
call_command('process_notifications', once=True, job_id=retry.job_id)
|
||||
completed = client.get(detail).content.decode()
|
||||
assert all(value in completed for value in ['رسیده به گوشی', 'پیامک به گوشی رسید', 'new-sms-id', 'اعتبار پنل کافی نیست', 'آخرین استعلام'])
|
||||
assert original.attempt_log.count() == 1 and retry.attempt_log.count() == 1 and len(sent) == 2
|
||||
assert PaymentAuditEvent.objects.filter(action='NOTICE_SMS_RESEND_REQUESTED').count() == 1
|
||||
# Replaying the same form after completion must still not issue another message.
|
||||
client.post(endpoint, {'request_key': key})
|
||||
assert PaymentNotification.objects.filter(notice=case['notice']).count() == 2
|
||||
|
||||
|
||||
@pytest.mark.parametrize('status,final,next_check,text', [
|
||||
('UNDELIVERED', False, True, 'نتیجه غیرقطعی'),
|
||||
('UNDELIVERED', True, False, 'نرسیده به گوشی'),
|
||||
('UNKNOWN', False, False, 'دریافت توسط گوشی تأیید نشده است'),
|
||||
('CHECK_FAILED', False, True, 'خطای موقت استعلام'),
|
||||
('EXPIRED', True, False, 'منقضیشده'),
|
||||
])
|
||||
def test_sms_history_distinguishes_final_and_provisional_delivery(case, client, status, final, next_check, text):
|
||||
notification = PaymentNotification.objects.get(notice=case['notice'])
|
||||
NotificationJob.objects.filter(pk=notification.job_id).update(status='SENT', provider_message_id='tracked-id',
|
||||
delivery_status=status, delivery_is_final=final, delivery_status_code='6',
|
||||
delivery_next_check_at=timezone.now() if next_check else None)
|
||||
client.force_login(case['creator'])
|
||||
html = client.get(reverse('staff_payments:notice_detail', args=[case['notice'].public_id])).content.decode()
|
||||
assert text in html
|
||||
assert ('نتیجه غیرقطعی' in html) is not final
|
||||
|
||||
|
||||
def test_sms_resend_permission_invalid_key_and_csrf(case, client):
|
||||
notice = case['notice']
|
||||
notification = PaymentNotification.objects.get(notice=notice)
|
||||
NotificationJob.objects.filter(pk=notification.job_id).update(status='FAILED')
|
||||
endpoint = reverse('staff_payments:notice_resend_sms', args=[notice.public_id])
|
||||
secure = Client(enforce_csrf_checks=True)
|
||||
secure.force_login(case['creator'])
|
||||
assert secure.post(endpoint, {'request_key': str(uuid.uuid4())}).status_code == 403
|
||||
client.force_login(case['creator'])
|
||||
client.post(endpoint, {'request_key': 'invalid'})
|
||||
assert PaymentNotification.objects.filter(notice=notice).count() == 1
|
||||
PaymentAccessConfiguration.objects.update_or_create(pk=1, defaults={'open_create': False, 'open_view': False})
|
||||
with pytest.raises(PermissionDenied):
|
||||
services.resend_notice_sms(case['creator'], notice, str(uuid.uuid4()))
|
||||
grant(case['outsider'], 'add_paymentnotice')
|
||||
with pytest.raises(PermissionDenied):
|
||||
services.resend_notice_sms(case['outsider'], notice, str(uuid.uuid4()))
|
||||
with pytest.raises(PermissionDenied):
|
||||
services.resend_notice_sms(case['customer'], notice, str(uuid.uuid4()))
|
||||
|
||||
|
||||
@pytest.mark.parametrize('reason', ['cancelled', 'inactive', 'deleted'])
|
||||
def test_invalid_notice_cannot_be_resent(case, reason):
|
||||
notice = case['notice']
|
||||
if reason == 'inactive':
|
||||
case['customer'].is_active = False; case['customer'].save(update_fields=['is_active'])
|
||||
else:
|
||||
PaymentNotice.objects.filter(pk=notice.pk).update(**{'cancelled_at' if reason == 'cancelled' else 'deleted_at': timezone.now()})
|
||||
with pytest.raises((ValidationError, PermissionDenied)):
|
||||
services.resend_notice_sms(case['creator'], notice, str(uuid.uuid4()))
|
||||
assert PaymentNotification.objects.filter(notice=notice).count() == 1
|
||||
Reference in new issue
Block a user