improve entezar bazdid
This commit is contained in:
1 parent
1fd27d0445
commit
2aee85593f
18 files changed
+10271
-7558
No files matched your search
+38
-6
@@ -1,11 +1,11 @@
|
|||||||
Project Root: khatibi
|
Project Root: khatibi
|
||||||
Generated At: 2026-10-16T12:37:31+00:00
|
Generated At: 2026-10-20T14:01:53+00:00
|
||||||
Python Files: 179
|
Python Files: 186
|
||||||
Django Files: 12
|
Django Files: 12
|
||||||
JavaScript/TypeScript Files: 14
|
JavaScript/TypeScript Files: 14
|
||||||
HTML Files: 58
|
HTML Files: 58
|
||||||
Total Files: 713
|
Total Files: 744
|
||||||
Total Directories: 77
|
Total Directories: 78
|
||||||
|
|
||||||
khatibi
|
khatibi
|
||||||
├── .cursor
|
├── .cursor
|
||||||
@@ -38,6 +38,7 @@ khatibi
|
|||||||
│ ├── management
|
│ ├── management
|
||||||
│ │ ├── commands
|
│ │ ├── commands
|
||||||
│ │ │ ├── __init__.py
|
│ │ │ ├── __init__.py
|
||||||
|
│ │ │ ├── cleanup_visit_dedup.py
|
||||||
│ │ │ ├── fix_customer_names.py
|
│ │ │ ├── fix_customer_names.py
|
||||||
│ │ │ ├── fix_customer_phones.py
|
│ │ │ ├── fix_customer_phones.py
|
||||||
│ │ │ ├── gt_esh.py
|
│ │ │ ├── gt_esh.py
|
||||||
@@ -124,6 +125,9 @@ khatibi
|
|||||||
│ │ ├── 0072_customernamefixjob.py
|
│ │ ├── 0072_customernamefixjob.py
|
||||||
│ │ ├── 0073_alter_action_actiontype_choices.py
|
│ │ ├── 0073_alter_action_actiontype_choices.py
|
||||||
│ │ ├── 0074_alter_action_status_choices.py
|
│ │ ├── 0074_alter_action_status_choices.py
|
||||||
|
│ │ ├── 0075_cheque_funding_before_due.py
|
||||||
|
│ │ ├── 0076_alter_smstemplate_text_help.py
|
||||||
|
│ │ ├── 0077_alter_smstemplate_text_balance_help.py
|
||||||
│ │ └── __init__.py
|
│ │ └── __init__.py
|
||||||
│ ├── models
|
│ ├── models
|
||||||
│ │ ├── __init__.py
|
│ │ ├── __init__.py
|
||||||
@@ -138,6 +142,7 @@ khatibi
|
|||||||
│ │ └── users.py
|
│ │ └── users.py
|
||||||
│ ├── sms
|
│ ├── sms
|
||||||
│ │ ├── __init__.py
|
│ │ ├── __init__.py
|
||||||
|
│ │ ├── balance.py
|
||||||
│ │ ├── enqueue.py
|
│ │ ├── enqueue.py
|
||||||
│ │ ├── notices.py
|
│ │ ├── notices.py
|
||||||
│ │ ├── parts.py
|
│ │ ├── parts.py
|
||||||
@@ -313,7 +318,8 @@ khatibi
|
|||||||
│ ├── tests.py
|
│ ├── tests.py
|
||||||
│ ├── urls.py
|
│ ├── urls.py
|
||||||
│ ├── views - Copy.py
|
│ ├── views - Copy.py
|
||||||
│ └── viewsBK.py
|
│ ├── viewsBK.py
|
||||||
|
│ └── visit_policy.py
|
||||||
├── data9
|
├── data9
|
||||||
│ ├── db2 (2).sqlite32
|
│ ├── db2 (2).sqlite32
|
||||||
│ ├── db2.sqlite32
|
│ ├── db2.sqlite32
|
||||||
@@ -348,6 +354,13 @@ khatibi
|
|||||||
│ │ ├── graph.json
|
│ │ ├── graph.json
|
||||||
│ │ ├── GRAPH_REPORT.md
|
│ │ ├── GRAPH_REPORT.md
|
||||||
│ │ └── manifest.json
|
│ │ └── manifest.json
|
||||||
|
│ ├── 2026-10-20
|
||||||
|
│ │ ├── .graphify_analysis.json
|
||||||
|
│ │ ├── .graphify_labels.json
|
||||||
|
│ │ ├── .graphify_semantic_marker
|
||||||
|
│ │ ├── graph.json
|
||||||
|
│ │ ├── GRAPH_REPORT.md
|
||||||
|
│ │ └── manifest.json
|
||||||
│ ├── cache
|
│ ├── cache
|
||||||
│ │ ├── ast
|
│ │ ├── ast
|
||||||
│ │ │ └── v0.9.82-s7
|
│ │ │ └── v0.9.82-s7
|
||||||
@@ -367,11 +380,13 @@ khatibi
|
|||||||
│ │ │ ├── 100f37182d082767395fcd09f1ec937fd74e57ed039885843bf81d349c3fc1f5.json
|
│ │ │ ├── 100f37182d082767395fcd09f1ec937fd74e57ed039885843bf81d349c3fc1f5.json
|
||||||
│ │ │ ├── 10615f5a999e04257afc03cdc52e1b9e4e11ae7818d7c64ae13b976e3974feb4.json
|
│ │ │ ├── 10615f5a999e04257afc03cdc52e1b9e4e11ae7818d7c64ae13b976e3974feb4.json
|
||||||
│ │ │ ├── 11042a6b38137d4e725c18d536cde9c98652782cb58390aa6216dacad2d18cf5.json
|
│ │ │ ├── 11042a6b38137d4e725c18d536cde9c98652782cb58390aa6216dacad2d18cf5.json
|
||||||
|
│ │ │ ├── 1219157d5f2c0a61d4069d2e7067d2eaf1c6403fcd5965743d9a196fe4564184.json
|
||||||
│ │ │ ├── 1285cdf9b89689e3ac99cea19470a3c14fdbf3a9d1bb2cd8da2cfdbd42ca940f.json
|
│ │ │ ├── 1285cdf9b89689e3ac99cea19470a3c14fdbf3a9d1bb2cd8da2cfdbd42ca940f.json
|
||||||
│ │ │ ├── 128b2b616c097fbfbcbc3fddafc224df0919ba261bb5ded7bcafa8b445fe118b.json
|
│ │ │ ├── 128b2b616c097fbfbcbc3fddafc224df0919ba261bb5ded7bcafa8b445fe118b.json
|
||||||
│ │ │ ├── 12d922593c75865b8068057f9244ad61a5ee573b7f3f38a16c31fa83abb9d668.json
|
│ │ │ ├── 12d922593c75865b8068057f9244ad61a5ee573b7f3f38a16c31fa83abb9d668.json
|
||||||
│ │ │ ├── 13a39530fbb076496d510363b40bf16093d8d117751e7240bcc09a447bdace35.json
|
│ │ │ ├── 13a39530fbb076496d510363b40bf16093d8d117751e7240bcc09a447bdace35.json
|
||||||
│ │ │ ├── 13ca1f37c3eea3c07228d2d98d47f7db6a9c1d8468b259651f3fea5419651569.json
|
│ │ │ ├── 13ca1f37c3eea3c07228d2d98d47f7db6a9c1d8468b259651f3fea5419651569.json
|
||||||
|
│ │ │ ├── 143902d3d7280d781e9f6a8e366068809dec67bd385462175012f9b5c072e470.json
|
||||||
│ │ │ ├── 14f64f9b156b6b520e7af189b303a2844728f3ab8bbe1e65fed49160b1431cb2.json
|
│ │ │ ├── 14f64f9b156b6b520e7af189b303a2844728f3ab8bbe1e65fed49160b1431cb2.json
|
||||||
│ │ │ ├── 15be5091a5bfbf8f48c3f3ae344a980be5452eab00f054c001958aae96557514.json
|
│ │ │ ├── 15be5091a5bfbf8f48c3f3ae344a980be5452eab00f054c001958aae96557514.json
|
||||||
│ │ │ ├── 174e406c2649f2384921581cbf912945a95d54aab73707b9aa45752aaa7c54d7.json
|
│ │ │ ├── 174e406c2649f2384921581cbf912945a95d54aab73707b9aa45752aaa7c54d7.json
|
||||||
@@ -406,17 +421,20 @@ khatibi
|
|||||||
│ │ │ ├── 38980449465279974120694e1c667f229049f75d656fe518196d9cadc2429155.json
|
│ │ │ ├── 38980449465279974120694e1c667f229049f75d656fe518196d9cadc2429155.json
|
||||||
│ │ │ ├── 3ba0d4c75ab8bfa5bc2a60dccb74197cb94bd161e526e54770a9b0a2aa757806.json
|
│ │ │ ├── 3ba0d4c75ab8bfa5bc2a60dccb74197cb94bd161e526e54770a9b0a2aa757806.json
|
||||||
│ │ │ ├── 3c9f8987dc7426b442fe2a90c7de5d909fc85988e172e7722c11f24a2c331481.json
|
│ │ │ ├── 3c9f8987dc7426b442fe2a90c7de5d909fc85988e172e7722c11f24a2c331481.json
|
||||||
|
│ │ │ ├── 3d94db4e528b0e06bb975a12c71733767fe57307bb6e3de7c6ce65721e097541.json
|
||||||
│ │ │ ├── 3ff644065147b53af40ea703202c0fd832114936fe67e6c9a30d15e3da915455.json
|
│ │ │ ├── 3ff644065147b53af40ea703202c0fd832114936fe67e6c9a30d15e3da915455.json
|
||||||
│ │ │ ├── 40e7fbe5e765171201ab8016acce76a28304e1339d9aeb5a9cc18da87c5604d2.json
|
│ │ │ ├── 40e7fbe5e765171201ab8016acce76a28304e1339d9aeb5a9cc18da87c5604d2.json
|
||||||
│ │ │ ├── 4162977f2c2251e7a8b331d7229bd43dff56df31356191b19886b5bf5d56377d.json
|
│ │ │ ├── 4162977f2c2251e7a8b331d7229bd43dff56df31356191b19886b5bf5d56377d.json
|
||||||
│ │ │ ├── 41952e7c846da15fe658603ff001dafdb0e12d3372a7f0306adfa3a71de8bee7.json
|
│ │ │ ├── 41952e7c846da15fe658603ff001dafdb0e12d3372a7f0306adfa3a71de8bee7.json
|
||||||
│ │ │ ├── 4204828f3cbb3465c469e28345e0fc4d7ff9a84e30e3dd9f00ab4dbe27014e0d.json
|
│ │ │ ├── 4204828f3cbb3465c469e28345e0fc4d7ff9a84e30e3dd9f00ab4dbe27014e0d.json
|
||||||
│ │ │ ├── 435b8e433cd2f09f7b81717214592f9bfadd8edd8177adcabd4338ce803855d2.json
|
│ │ │ ├── 435b8e433cd2f09f7b81717214592f9bfadd8edd8177adcabd4338ce803855d2.json
|
||||||
|
│ │ │ ├── 44fd8135217bc689f0886e5bd75458999e67ea57252445884c2822fff306312e.json
|
||||||
│ │ │ ├── 458c1b9a3ce257e789f75305939ab2e79029c557657102eebf2082e50a47a0e5.json
|
│ │ │ ├── 458c1b9a3ce257e789f75305939ab2e79029c557657102eebf2082e50a47a0e5.json
|
||||||
│ │ │ ├── 45f7e59ef3f291171180b6763e8da408d8b7579bb7cb96dd4ca859e48aa5ce14.json
|
│ │ │ ├── 45f7e59ef3f291171180b6763e8da408d8b7579bb7cb96dd4ca859e48aa5ce14.json
|
||||||
│ │ │ ├── 46616763804b2b938d73c75f7651158e94803e0b86b3a759ecdd59375759eea3.json
|
│ │ │ ├── 46616763804b2b938d73c75f7651158e94803e0b86b3a759ecdd59375759eea3.json
|
||||||
│ │ │ ├── 46bde576f6f1e69457e4ad33238fdbd602d2a7f027644a84cb81068215fbd5e1.json
|
│ │ │ ├── 46bde576f6f1e69457e4ad33238fdbd602d2a7f027644a84cb81068215fbd5e1.json
|
||||||
│ │ │ ├── 477b0c06bcadb94314490ac76899f499d45a942aadef1e65623d303342157463.json
|
│ │ │ ├── 477b0c06bcadb94314490ac76899f499d45a942aadef1e65623d303342157463.json
|
||||||
|
│ │ │ ├── 492a9871203480b590199773e90727872f60f06013713a4a2936d87986cdbbe5.json
|
||||||
│ │ │ ├── 4da7df08a0ef803551d404438fe24d6c8cc11d9a2c3cc8d85e37d5228ba3a166.json
|
│ │ │ ├── 4da7df08a0ef803551d404438fe24d6c8cc11d9a2c3cc8d85e37d5228ba3a166.json
|
||||||
│ │ │ ├── 4e24d52f79dfa76aa2406de05583fa49ce966a28fb653fa284fb8e8204797385.json
|
│ │ │ ├── 4e24d52f79dfa76aa2406de05583fa49ce966a28fb653fa284fb8e8204797385.json
|
||||||
│ │ │ ├── 4e604dd03a70463ae25f6615ea3ce4fe8e98fab15d6b11ddafd51d601062e84d.json
|
│ │ │ ├── 4e604dd03a70463ae25f6615ea3ce4fe8e98fab15d6b11ddafd51d601062e84d.json
|
||||||
@@ -426,6 +444,7 @@ khatibi
|
|||||||
│ │ │ ├── 527de91bd8bbc5a7ab79951457a9f192909faa9bbf02aaec72bb3d26e2298b57.json
|
│ │ │ ├── 527de91bd8bbc5a7ab79951457a9f192909faa9bbf02aaec72bb3d26e2298b57.json
|
||||||
│ │ │ ├── 5316515ee7fc3e1038e676b361d9028edc735993c3d34791a77a14503deb064d.json
|
│ │ │ ├── 5316515ee7fc3e1038e676b361d9028edc735993c3d34791a77a14503deb064d.json
|
||||||
│ │ │ ├── 53bcc38a002a291b8a370a1c24dde42e704cbda5d409d77b8405599e45844cae.json
|
│ │ │ ├── 53bcc38a002a291b8a370a1c24dde42e704cbda5d409d77b8405599e45844cae.json
|
||||||
|
│ │ │ ├── 559fbbe72061ed129b192884f1f2b9c58d23f2af199cd408aa95d7c5af2753ef.json
|
||||||
│ │ │ ├── 5730d72cc177474584197921cce656696e3177780028c4629950f5514b530c6d.json
|
│ │ │ ├── 5730d72cc177474584197921cce656696e3177780028c4629950f5514b530c6d.json
|
||||||
│ │ │ ├── 57a18b5350b4a567305ca01b2951efee6eeb3b289b5ffbbf390c3962ff29d61c.json
|
│ │ │ ├── 57a18b5350b4a567305ca01b2951efee6eeb3b289b5ffbbf390c3962ff29d61c.json
|
||||||
│ │ │ ├── 58ad02e0930221b4f44603dbcf01d3b5d07d9da2772edafe48a94a9894ea148a.json
|
│ │ │ ├── 58ad02e0930221b4f44603dbcf01d3b5d07d9da2772edafe48a94a9894ea148a.json
|
||||||
@@ -479,6 +498,7 @@ khatibi
|
|||||||
│ │ │ ├── 77a1520156879de714ee872c29b1387967bcc5ce470b47d37087ea2a110f3875.json
|
│ │ │ ├── 77a1520156879de714ee872c29b1387967bcc5ce470b47d37087ea2a110f3875.json
|
||||||
│ │ │ ├── 780977bca042ffdde1cca2811a3a46c36bd3fcff2723c94ac527428113131c64.json
|
│ │ │ ├── 780977bca042ffdde1cca2811a3a46c36bd3fcff2723c94ac527428113131c64.json
|
||||||
│ │ │ ├── 784dd791c21e8837a3e925331cf234b2138ded3d2012968739604d5535a18e09.json
|
│ │ │ ├── 784dd791c21e8837a3e925331cf234b2138ded3d2012968739604d5535a18e09.json
|
||||||
|
│ │ │ ├── 79ffc45a7139464dd6c7b1fc6f1f0f9c44347fbd7dcee92c6d4b63525424c442.json
|
||||||
│ │ │ ├── 7be7f428e6acfe4c99370707cc7de9253f801feff1564088f2584d5dc8c42a89.json
|
│ │ │ ├── 7be7f428e6acfe4c99370707cc7de9253f801feff1564088f2584d5dc8c42a89.json
|
||||||
│ │ │ ├── 7bf404104bb32fc04568c2d287891fa69955ac3f46718c99dcca64a4bfff6fe3.json
|
│ │ │ ├── 7bf404104bb32fc04568c2d287891fa69955ac3f46718c99dcca64a4bfff6fe3.json
|
||||||
│ │ │ ├── 7c4fc90402a7836b364425c33cc2ffc98e7634391ebe78143df03c88945d8988.json
|
│ │ │ ├── 7c4fc90402a7836b364425c33cc2ffc98e7634391ebe78143df03c88945d8988.json
|
||||||
@@ -514,6 +534,7 @@ khatibi
|
|||||||
│ │ │ ├── 9c2c2f6f8d4d4c3a9a60da3ed38a26dc789e8cfaff5d46d140d2f208e5fef3c3.json
|
│ │ │ ├── 9c2c2f6f8d4d4c3a9a60da3ed38a26dc789e8cfaff5d46d140d2f208e5fef3c3.json
|
||||||
│ │ │ ├── 9cfb6e4f7b08a863675af032e7823c0523170f5201e686c5ef72b39c04b07a12.json
|
│ │ │ ├── 9cfb6e4f7b08a863675af032e7823c0523170f5201e686c5ef72b39c04b07a12.json
|
||||||
│ │ │ ├── 9eabdb0cacba38c8fb6b3e777932e06bd82172cdc0bcccf2fc0bfc23b5bd4974.json
|
│ │ │ ├── 9eabdb0cacba38c8fb6b3e777932e06bd82172cdc0bcccf2fc0bfc23b5bd4974.json
|
||||||
|
│ │ │ ├── 9ef7cb42412a83490d3e54c81d4e7fa58db25253eb50a41d97d5d4064df0dfe2.json
|
||||||
│ │ │ ├── a186e74ebd87e23212ffe7fd72c40e9383c0624a3efdb2fe1e3271d9ea96d9b9.json
|
│ │ │ ├── a186e74ebd87e23212ffe7fd72c40e9383c0624a3efdb2fe1e3271d9ea96d9b9.json
|
||||||
│ │ │ ├── a31ab0e36b30049a602cc060d77c0715187f7c3838db91b8c20bfaa802f49899.json
|
│ │ │ ├── a31ab0e36b30049a602cc060d77c0715187f7c3838db91b8c20bfaa802f49899.json
|
||||||
│ │ │ ├── a43d68ff03b49e9153a73777c0bde346a6b1969f640d67eae500d60d410a02c0.json
|
│ │ │ ├── a43d68ff03b49e9153a73777c0bde346a6b1969f640d67eae500d60d410a02c0.json
|
||||||
@@ -523,6 +544,7 @@ khatibi
|
|||||||
│ │ │ ├── aabf6dbb2202699bb1b06fd582c88465b9cf2b8cc060bda2e2bc0c67fc493bed.json
|
│ │ │ ├── aabf6dbb2202699bb1b06fd582c88465b9cf2b8cc060bda2e2bc0c67fc493bed.json
|
||||||
│ │ │ ├── ab5d0682afc0714c24b9ac5fda36b243c638d272ea973f46a5c18e783132c861.json
|
│ │ │ ├── ab5d0682afc0714c24b9ac5fda36b243c638d272ea973f46a5c18e783132c861.json
|
||||||
│ │ │ ├── add004e78a62164d7565c102eb825debea20811f7fe76afa0567a1b6b289f264.json
|
│ │ │ ├── add004e78a62164d7565c102eb825debea20811f7fe76afa0567a1b6b289f264.json
|
||||||
|
│ │ │ ├── adda28366431a09f735c806811acad036f33199438c7679d275e3c80714b9a80.json
|
||||||
│ │ │ ├── aeaae7b60339b1283cab517b4ab42806c64933c272d007a68431988ba8e0edb5.json
|
│ │ │ ├── aeaae7b60339b1283cab517b4ab42806c64933c272d007a68431988ba8e0edb5.json
|
||||||
│ │ │ ├── af128ca7fc8d02ae40fff36925a5b96034862ce0d581e22a8a0c88f41040e526.json
|
│ │ │ ├── af128ca7fc8d02ae40fff36925a5b96034862ce0d581e22a8a0c88f41040e526.json
|
||||||
│ │ │ ├── b24556e20d540fd63de3dc04501ffd021836b930c4ebc3ff309f7950ef17a335.json
|
│ │ │ ├── b24556e20d540fd63de3dc04501ffd021836b930c4ebc3ff309f7950ef17a335.json
|
||||||
@@ -532,6 +554,7 @@ khatibi
|
|||||||
│ │ │ ├── b543952627caaf0a5c52bec09b33fae93443fca7234365680bbe75acc260dd1c.json
|
│ │ │ ├── b543952627caaf0a5c52bec09b33fae93443fca7234365680bbe75acc260dd1c.json
|
||||||
│ │ │ ├── b59999c2fd322c241c7bd37e6ed0a95c9124cbb79f8424f1fa7cb2e92fe63817.json
|
│ │ │ ├── b59999c2fd322c241c7bd37e6ed0a95c9124cbb79f8424f1fa7cb2e92fe63817.json
|
||||||
│ │ │ ├── b82d5f6840b4930eb62fdb1c7b3a58124fff805e529ede7684516a65322fcae0.json
|
│ │ │ ├── b82d5f6840b4930eb62fdb1c7b3a58124fff805e529ede7684516a65322fcae0.json
|
||||||
|
│ │ │ ├── b87a932e418a18531d61dc15776db652401e7cf1682dead02abdf50e2cd00530.json
|
||||||
│ │ │ ├── b9f8ea31056fe0c0fc40a84640c58f29895312c65bf694567487204d8f1170e9.json
|
│ │ │ ├── b9f8ea31056fe0c0fc40a84640c58f29895312c65bf694567487204d8f1170e9.json
|
||||||
│ │ │ ├── bb140cfe12cfb3e32dc9815393035fc33c7649c2faeeccb82cdee56a930ea635.json
|
│ │ │ ├── bb140cfe12cfb3e32dc9815393035fc33c7649c2faeeccb82cdee56a930ea635.json
|
||||||
│ │ │ ├── bb74cda4073a6da7a4b60fecfb3bb40796a51ceafcbdd128d60e5ce23fa4f319.json
|
│ │ │ ├── bb74cda4073a6da7a4b60fecfb3bb40796a51ceafcbdd128d60e5ce23fa4f319.json
|
||||||
@@ -574,12 +597,14 @@ khatibi
|
|||||||
│ │ │ ├── d88e1e365cafd5d26ce47c2a0fe1f067dc3bfe5ceed1f75a21393fae950be939.json
|
│ │ │ ├── d88e1e365cafd5d26ce47c2a0fe1f067dc3bfe5ceed1f75a21393fae950be939.json
|
||||||
│ │ │ ├── d899e8cc575f125b908d78f0c8d34408a4af1972ee0544d0517f8d1933239214.json
|
│ │ │ ├── d899e8cc575f125b908d78f0c8d34408a4af1972ee0544d0517f8d1933239214.json
|
||||||
│ │ │ ├── d94bebfe941bb0acc30b633423eaee11690652709e5f8f28d157473d31a07ee3.json
|
│ │ │ ├── d94bebfe941bb0acc30b633423eaee11690652709e5f8f28d157473d31a07ee3.json
|
||||||
|
│ │ │ ├── d9590fb13a20db20da78d33674f56397a18e3356d08dab6f7c0739130caee096.json
|
||||||
│ │ │ ├── db68b8db5217e2295eb1195760d7352960627da044a4c4f8f6b89c67ad2f8f57.json
|
│ │ │ ├── db68b8db5217e2295eb1195760d7352960627da044a4c4f8f6b89c67ad2f8f57.json
|
||||||
│ │ │ ├── db794bbcafe219af962673ef26efb1ee1eacf3923e13113352f3eedfd3192d23.json
|
│ │ │ ├── db794bbcafe219af962673ef26efb1ee1eacf3923e13113352f3eedfd3192d23.json
|
||||||
│ │ │ ├── dbbdad9d992922aff62ad3262a531716cfc32e2f15f600773b9a0cf10545058d.json
|
│ │ │ ├── dbbdad9d992922aff62ad3262a531716cfc32e2f15f600773b9a0cf10545058d.json
|
||||||
│ │ │ ├── dc0344515d1b3ffe8643560516b88418be0b306fba56101b8a630ed73d48db46.json
|
│ │ │ ├── dc0344515d1b3ffe8643560516b88418be0b306fba56101b8a630ed73d48db46.json
|
||||||
│ │ │ ├── dcad55d51bb4494a642af555d5bfe78c5b8ee3d11de1b3d8f4286d608980854b.json
|
│ │ │ ├── dcad55d51bb4494a642af555d5bfe78c5b8ee3d11de1b3d8f4286d608980854b.json
|
||||||
│ │ │ ├── e08c4784f0f82403ff24a1676e3d75e11a208aadf8fe94a723461c9658afd1e0.json
|
│ │ │ ├── e08c4784f0f82403ff24a1676e3d75e11a208aadf8fe94a723461c9658afd1e0.json
|
||||||
|
│ │ │ ├── e0eb3c543474df255f97ae0679df48999b86b75dc42943b9d67f4cfcf1acae34.json
|
||||||
│ │ │ ├── e16f78807ff202fa2c81867b321acfacede2d24cc785856eef0633d8d352796b.json
|
│ │ │ ├── e16f78807ff202fa2c81867b321acfacede2d24cc785856eef0633d8d352796b.json
|
||||||
│ │ │ ├── e1a7be59904a3dc503c5b12bddc7604c6783b02462c8ec76834c65c5610c3bda.json
|
│ │ │ ├── e1a7be59904a3dc503c5b12bddc7604c6783b02462c8ec76834c65c5610c3bda.json
|
||||||
│ │ │ ├── e2967fb2aed9420b2cdf823d95f971b7038323c97a6ed139ce63b7a1912381d1.json
|
│ │ │ ├── e2967fb2aed9420b2cdf823d95f971b7038323c97a6ed139ce63b7a1912381d1.json
|
||||||
@@ -765,13 +790,20 @@ khatibi
|
|||||||
│ ├── test_sms.py
|
│ ├── test_sms.py
|
||||||
│ ├── test_sms_phone_normalize.py
|
│ ├── test_sms_phone_normalize.py
|
||||||
│ ├── test_urls_contract.py
|
│ ├── test_urls_contract.py
|
||||||
│ └── test_view_smoke.py
|
│ ├── test_view_smoke.py
|
||||||
|
│ └── test_visit_policy.py
|
||||||
├── .clinerules
|
├── .clinerules
|
||||||
├── .dockerignore
|
├── .dockerignore
|
||||||
├── .gitignore
|
├── .gitignore
|
||||||
├── .graphifyignore
|
├── .graphifyignore
|
||||||
├── 0098sms.md
|
├── 0098sms.md
|
||||||
├── AGENTS.md
|
├── AGENTS.md
|
||||||
|
├── backup-20261020-155504.sqlite3
|
||||||
|
├── backup-20261020-155539.sqlite3
|
||||||
|
├── backup-20261020-155613.sqlite3
|
||||||
|
├── backup-20261020-155723.sqlite3
|
||||||
|
├── backup-20261020-155757.sqlite3
|
||||||
|
├── backup-20261020-155827.sqlite3
|
||||||
├── check_finance.py
|
├── check_finance.py
|
||||||
├── CLAUDE.md
|
├── CLAUDE.md
|
||||||
├── db.json
|
├── db.json
|
||||||
|
|||||||
@@ -0,0 +1,81 @@
|
|||||||
|
"""پاکسازی کلیدهای «آلوده» دفتر یکتاسازی یادآوری بازدید.
|
||||||
|
|
||||||
|
چرا لازم است؟ کلید یکتاسازیِ یادآوری بازدید، در نسخهٔ قدیمی ``visit:<id>:<شمارهٔ چرخه>``
|
||||||
|
بود که شمارهٔ چرخه با تقسیم روز بر یک عدد ثابت محاسبه میشد. وقتی آن عدد صفر میشد،
|
||||||
|
عددهای بیمعنا (مثل ``visit:1601:24663``) ساخته و برای همیشه در ``SMSDedup`` ثبت
|
||||||
|
میشدند؛ نتیجهاش قفل دائمی یادآوری برای همان مشتری بود.
|
||||||
|
|
||||||
|
قاعدهٔ جدید کلید را با «موعد تقویمی» میسازد: ``visit:<id>:<YYYY-MM>``. کلیدهای قدیمی
|
||||||
|
هرگز با کلید جدید برخورد نمیکنند، بنابراین بیضرر ولی بازدارندهاند.
|
||||||
|
|
||||||
|
این دستور **فقط** کلیدهای بیمعنا/قدیمیِ دفتر را حذف میکند و هیچ مشتری، اقدام یا
|
||||||
|
پیامکی را تغییر نمیدهد.
|
||||||
|
|
||||||
|
python manage.py cleanup_visit_dedup --dry-run # فقط گزارش
|
||||||
|
python manage.py cleanup_visit_dedup # حذف کلیدهای بیمعنا (cycle > حد)
|
||||||
|
python manage.py cleanup_visit_dedup --all-legacy # حذف همهٔ کلیدهای قالب قدیمی
|
||||||
|
"""
|
||||||
|
import re
|
||||||
|
|
||||||
|
from django.core.management.base import BaseCommand
|
||||||
|
from django.db import transaction
|
||||||
|
|
||||||
|
from ...models import SMSDedup
|
||||||
|
|
||||||
|
# سقف منطقی شمارهٔ چرخه؛ هر عدد بزرگتر از این، نتیجهٔ باگ تقسیم قدیمی است.
|
||||||
|
MAX_SANE_CYCLE = 50
|
||||||
|
LEGACY_PATTERN = re.compile(r"^visit:(\d+):(\d+)$")
|
||||||
|
NEW_PATTERN = re.compile(r"^visit:(\d+):(\d{4}-\d{2})$")
|
||||||
|
|
||||||
|
|
||||||
|
class Command(BaseCommand):
|
||||||
|
help = "حذف کلیدهای آلوده/قدیمی دفتر یکتاسازی یادآوری بازدید (بدون تغییر دادهٔ مشتری)."
|
||||||
|
|
||||||
|
def add_arguments(self, parser):
|
||||||
|
parser.add_argument("--dry-run", action="store_true",
|
||||||
|
help="فقط گزارش؛ چیزی حذف نمیشود.")
|
||||||
|
parser.add_argument("--all-legacy", action="store_true",
|
||||||
|
help="همهٔ کلیدهای قالب قدیمی (visit:<id>:<عدد>) حذف شوند، "
|
||||||
|
"نه فقط عددهای بیمعنا.")
|
||||||
|
|
||||||
|
def handle(self, *args, **options):
|
||||||
|
dry_run = options["dry_run"]
|
||||||
|
all_legacy = options["all_legacy"]
|
||||||
|
|
||||||
|
absurd, sane_legacy, current = [], [], []
|
||||||
|
for key in SMSDedup.objects.filter(key__startswith="visit:").values_list(
|
||||||
|
"key", flat=True
|
||||||
|
).iterator():
|
||||||
|
if NEW_PATTERN.match(key):
|
||||||
|
current.append(key)
|
||||||
|
continue
|
||||||
|
match = LEGACY_PATTERN.match(key)
|
||||||
|
if match:
|
||||||
|
cycle = int(match.group(2))
|
||||||
|
(absurd if cycle > MAX_SANE_CYCLE else sane_legacy).append(key)
|
||||||
|
else:
|
||||||
|
absurd.append(key)
|
||||||
|
|
||||||
|
doomed = absurd + (sane_legacy if all_legacy else [])
|
||||||
|
|
||||||
|
self.stdout.write(self.style.MIGRATE_LABEL("گزارش کلیدهای یادآوری بازدید:"))
|
||||||
|
self.stdout.write(f" کلیدهای قالب جدید (موعد تقویمی): {len(current):,}")
|
||||||
|
self.stdout.write(f" کلیدهای بیمعنا (چرخه > {MAX_SANE_CYCLE} یا ناشناخته): {len(absurd):,}")
|
||||||
|
self.stdout.write(f" کلیدهای قدیمیِ سالم (چرخه ≤ {MAX_SANE_CYCLE}): {len(sane_legacy):,}")
|
||||||
|
if absurd:
|
||||||
|
self.stdout.write(" نمونهٔ کلیدهای بیمعنا: " + ", ".join(absurd[:5]))
|
||||||
|
self.stdout.write(f" قابل حذف در این اجرا: {len(doomed):,}")
|
||||||
|
|
||||||
|
if dry_run:
|
||||||
|
self.stdout.write(self.style.WARNING(
|
||||||
|
"\n[شبیهسازی] هیچ کلیدی حذف نشد."
|
||||||
|
))
|
||||||
|
return
|
||||||
|
if not doomed:
|
||||||
|
self.stdout.write(self.style.SUCCESS("\nانجام شد: چیزی برای حذف نبود."))
|
||||||
|
return
|
||||||
|
with transaction.atomic():
|
||||||
|
deleted, _ = SMSDedup.objects.filter(key__in=doomed).delete()
|
||||||
|
self.stdout.write(self.style.SUCCESS(
|
||||||
|
f"\nانجام شد: {deleted:,} کلید حذف شد (فقط از دفتر یکتاسازی)."
|
||||||
|
))
|
||||||
@@ -1,156 +1,270 @@
|
|||||||
"""بازمحاسبهٔ ``Customer.last_bazdid_dt`` از روی اقدامهای «بازدیدِ تمامشده».
|
"""بازمحاسبهٔ ``Customer.last_bazdid_dt`` از روی منبع حقیقت «آخرین بازدید».
|
||||||
|
|
||||||
فیلد ``last_bazdid_dt`` «آخرین بازدید» مشتری است و صفحهٔ ``/panel/entezar/`` بر پایهٔ
|
منبع حقیقت، ``admin_panel.visit_policy.last_visit_map`` است: بزرگترین تاریخ مؤثر
|
||||||
آن مشتریانِ معوق (بیش از N ماه بازدید نشده) را لیست میکند. این مقدار باید برابر
|
در میان اقدامهای ``bazdid + finish`` هر مشتری (تاریخ مؤثر = ``actionDateTime`` اگر
|
||||||
«بزرگترین ``finishDateTime`` میان اقدامهای ``actionType='bazdid'`` و ``status='finish'``
|
از حالا جلوتر نباشد، وگرنه ``finishDateTime``).
|
||||||
هر مشتری» باشد — همان چیزی که ویوهای ``customerNewAction``/``customerEditAction``
|
|
||||||
مینویسند.
|
|
||||||
|
|
||||||
اگر رکوردی از راهی غیر از این ویوها وارد شود (اسکریپت، ایمپورت، ویرایش دستی دیتابیس)،
|
این دستور **فقط** ستون ``last_bazdid_dt`` مشتری را عوض میکند و هیچ رکورد اقدامی
|
||||||
این فیلد عقب میماند و مشتریِ واقعاً بازدیدشده اشتباهاً «معوق» شمرده میشود. این
|
(از جمله رکوردهای «هماهنگی») را نمیخواند/نمینویسد.
|
||||||
دستور آن واگرایی (drift) را از منبع حقیقت (جدول Action) ترمیم میکند.
|
|
||||||
|
|
||||||
python manage.py recalc_last_bazdid --dry-run # فقط گزارش، بدون هیچ تغییری
|
python manage.py recalc_last_bazdid --dry-run # فقط گزارش
|
||||||
python manage.py recalc_last_bazdid # اعمال اصلاحات
|
python manage.py recalc_last_bazdid # فقط اصلاح رو به جلو
|
||||||
|
python manage.py recalc_last_bazdid --exact --confirm=YES # ترمیم کامل + بکاپ خودکار
|
||||||
|
|
||||||
مشخصات:
|
* ``--dry-run``: هیچ نوشتنی انجام نمیشود.
|
||||||
* idempotent — اجرای دوباره پس از یکبار اجرا هیچ تغییری ایجاد نمیکند.
|
* ``--exact``: هر اختلافی (رو به جلو، رو به عقب، پرکردن مقدار خالی) اصلاح میشود.
|
||||||
* پیشفرض محافظهکارانه است: فقط مقدار را **بهروز** میکند و هیچوقت روی مقادیری که
|
بدون ``--confirm=YES`` اجرا نمیشود و پیش از هر نوشتن، خودکار ``VACUUM INTO``
|
||||||
اقدامِ پشتیبان ندارند دست نمیگذارد (مگر با ``--clear-orphans``).
|
میگیرد؛ اگر بکاپ شکست بخورد، هیچ چیز نوشته نمیشود.
|
||||||
* روی دیتابیس production هم قابل اجرا است.
|
* ``--clear-orphans``: مقادیری که هیچ بازدید معتبری پشتشان نیست را ``NULL`` میکند.
|
||||||
|
* ``--batch-size``: اندازهٔ دستهٔ خواندن مشتریان.
|
||||||
|
|
||||||
|
idempotent است: اجرای دوباره در همان حالت هیچ تغییری ایجاد نمیکند.
|
||||||
"""
|
"""
|
||||||
from django.core.management.base import BaseCommand
|
import os
|
||||||
from django.db import transaction
|
from datetime import datetime
|
||||||
from django.db.models import Max
|
|
||||||
|
from django.core.management.base import BaseCommand, CommandError
|
||||||
|
from django.db import connection, transaction
|
||||||
|
|
||||||
from ...models import Action, Customer
|
from ...models import Action, Customer
|
||||||
|
from ...visit_policy import as_aware, last_visit_map
|
||||||
|
|
||||||
|
CONFIRM_TOKEN = "YES"
|
||||||
|
SAMPLE_LIMIT = 20
|
||||||
|
|
||||||
|
|
||||||
|
def _bucket(current, new, clear_orphans):
|
||||||
|
"""دستهٔ تغییر پیشنهادی برای یک مشتری."""
|
||||||
|
if new is None:
|
||||||
|
if current is not None and clear_orphans:
|
||||||
|
return "orphan_cleared"
|
||||||
|
return "no_change"
|
||||||
|
if current is None:
|
||||||
|
return "fill_null"
|
||||||
|
if new > current:
|
||||||
|
return "forward"
|
||||||
|
if new < current:
|
||||||
|
return "backward"
|
||||||
|
return "no_change"
|
||||||
|
|
||||||
|
|
||||||
class Command(BaseCommand):
|
class Command(BaseCommand):
|
||||||
help = (
|
help = (
|
||||||
"بازمحاسبهٔ Customer.last_bazdid_dt از MAX(finishDateTime) اقدامهای "
|
"بازمحاسبهٔ Customer.last_bazdid_dt از منبع حقیقت بازدید "
|
||||||
"بازدیدِ تمامشده (ترمیم واگرایی داده؛ idempotent)."
|
"(فقط bazdid + finish؛ هرگز هماهنگی)."
|
||||||
)
|
)
|
||||||
|
|
||||||
def add_arguments(self, parser):
|
def add_arguments(self, parser):
|
||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
"--dry-run", action="store_true",
|
"--dry-run", action="store_true",
|
||||||
help="فقط گزارش کن که چه چیزی تغییر میکند؛ هیچ رکوردی ذخیره نمیشود.",
|
help="فقط گزارش؛ هیچ رکوردی ذخیره نمیشود.",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--exact", action="store_true",
|
||||||
|
help="هر اختلافی را اصلاح کن (شامل عقببردن مقدار). نیازمند --confirm=YES.",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--confirm", default="",
|
||||||
|
help="برای اجرای --exact باید مقدار YES باشد.",
|
||||||
)
|
)
|
||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
"--clear-orphans", action="store_true",
|
"--clear-orphans", action="store_true",
|
||||||
help="مقدارهایی که هیچ اقدامِ بازدیدِ تمامشدهای پشتشان نیست را هم NULL کن "
|
help="مقدارهایی که هیچ اقدام بازدید معتبری پشتشان نیست را NULL کن.",
|
||||||
"(پیشفرض: دستنخورده بمانند). فقط اگر مطمئنی این ستون آلوده است.",
|
|
||||||
)
|
)
|
||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
"--batch-size", type=int, default=500,
|
"--batch-size", type=int, default=500,
|
||||||
help="اندازهٔ دستهٔ خواندن مشتریان در هر گام (پیشفرض ۵۰۰).",
|
help="اندازهٔ دستهٔ خواندن مشتریان (پیشفرض ۵۰۰).",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
def handle(self, *args, **options):
|
def handle(self, *args, **options):
|
||||||
dry_run = options["dry_run"]
|
dry_run = options["dry_run"]
|
||||||
|
exact = options["exact"]
|
||||||
clear_orphans = options["clear_orphans"]
|
clear_orphans = options["clear_orphans"]
|
||||||
batch_size = max(1, options["batch_size"])
|
batch_size = max(1, options["batch_size"])
|
||||||
|
|
||||||
|
if exact and not dry_run:
|
||||||
|
if options["confirm"].strip().upper() != CONFIRM_TOKEN:
|
||||||
|
raise CommandError(
|
||||||
|
"اجرای --exact بدون تاییدیه مجاز نیست. ابتدا --dry-run --exact "
|
||||||
|
"را ببینید، از دیتابیس بکاپ بگیرید و سپس با --confirm=YES اجرا کنید."
|
||||||
|
)
|
||||||
|
|
||||||
prefix = "[شبیهسازی] " if dry_run else ""
|
prefix = "[شبیهسازی] " if dry_run else ""
|
||||||
self.stdout.write(self.style.MIGRATE_HEADING(
|
self.stdout.write(self.style.MIGRATE_HEADING(
|
||||||
f"{prefix}بازمحاسبهٔ آخرین بازدید مشتریان آغاز شد."
|
f"{prefix}بازمحاسبهٔ آخرین بازدید مشتریان آغاز شد."
|
||||||
))
|
))
|
||||||
|
|
||||||
# منبع حقیقت: بزرگترین زمانِ پایانِ بازدید در اقدامهای تمامشدهٔ هر مشتری.
|
stats, samples, pending, suspects = self._plan(exact, clear_orphans, batch_size)
|
||||||
# (Max مقادیر NULL را نادیده میگیرد؛ مشتریِ بدون بازدید بهکل در نقشه نمیآید.)
|
|
||||||
latest_rows = (
|
|
||||||
Action.objects
|
|
||||||
.filter(actionType="bazdid", status="finish")
|
|
||||||
.exclude(customer_id__isnull=True)
|
|
||||||
.values("customer_id")
|
|
||||||
.annotate(mx=Max("finishDateTime"))
|
|
||||||
)
|
|
||||||
latest = {
|
|
||||||
row["customer_id"]: row["mx"]
|
|
||||||
for row in latest_rows.iterator(chunk_size=batch_size)
|
|
||||||
if row["mx"] is not None
|
|
||||||
}
|
|
||||||
|
|
||||||
stats = {
|
backup_path = None
|
||||||
"scanned": 0, # کل مشتریان بررسیشده
|
if exact and not dry_run and pending:
|
||||||
"with_visit": 0, # مشتریان دارای بازدیدِ تمامشده
|
backup_path = self._backup()
|
||||||
"updated": 0, # مقدار اصلاح/بهروز شد
|
self.stdout.write(self.style.SUCCESS(f"بکاپ ساخته شد: {backup_path}"))
|
||||||
"moved_forward": 0, # جدیدتر شد ⇒ از لیست معوقها خارج میشود
|
|
||||||
"moved_backward": 0, # عقبتر شد
|
|
||||||
"set_null": 0, # با --clear-orphans خالی شد
|
|
||||||
"already_ok": 0, # از قبل درست بود
|
|
||||||
"kept_orphan": 0, # مقدار داشت ولی اقدامِ پشتیبان نداشت (دستنخورده ماند)
|
|
||||||
"null_and_no_action": 0, # NULL و بدون اقدام (طبیعی؛ کاری لازم نیست)
|
|
||||||
}
|
|
||||||
|
|
||||||
pending = [] # (customer_id, target_value) برای اعمال گروهی
|
|
||||||
|
|
||||||
qs = Customer.objects.only("id", "last_bazdid_dt").order_by("id")
|
|
||||||
for customer in qs.iterator(chunk_size=batch_size):
|
|
||||||
stats["scanned"] += 1
|
|
||||||
mx = latest.get(customer.id)
|
|
||||||
current = customer.last_bazdid_dt
|
|
||||||
|
|
||||||
if mx is not None:
|
|
||||||
stats["with_visit"] += 1
|
|
||||||
if current == mx:
|
|
||||||
stats["already_ok"] += 1
|
|
||||||
continue
|
|
||||||
if current is None or current < mx:
|
|
||||||
stats["moved_forward"] += 1
|
|
||||||
else:
|
|
||||||
stats["moved_backward"] += 1
|
|
||||||
stats["updated"] += 1
|
|
||||||
pending.append((customer.id, mx))
|
|
||||||
else:
|
|
||||||
# هیچ بازدیدِ تمامشدهای پشتوانهٔ این مقدار نیست.
|
|
||||||
if current is None:
|
|
||||||
stats["null_and_no_action"] += 1
|
|
||||||
elif clear_orphans:
|
|
||||||
stats["set_null"] += 1
|
|
||||||
pending.append((customer.id, None))
|
|
||||||
else:
|
|
||||||
stats["kept_orphan"] += 1
|
|
||||||
|
|
||||||
if not dry_run and pending:
|
if not dry_run and pending:
|
||||||
with transaction.atomic():
|
self._apply(pending)
|
||||||
for customer_id, target in pending:
|
|
||||||
Customer.objects.filter(pk=customer_id).update(
|
|
||||||
last_bazdid_dt=target
|
|
||||||
)
|
|
||||||
|
|
||||||
self._report(stats, dry_run, clear_orphans)
|
self._report(stats, samples, suspects, dry_run, exact, clear_orphans,
|
||||||
|
len(pending), backup_path)
|
||||||
|
|
||||||
def _report(self, stats, dry_run, clear_orphans):
|
# ------------------------------------------------------------------
|
||||||
|
def _plan(self, exact, clear_orphans, batch_size):
|
||||||
|
"""محاسبهٔ تغییرات پیشنهادی بدون هیچ نوشتنی."""
|
||||||
|
visits = last_visit_map()
|
||||||
|
stats = {
|
||||||
|
"scanned": 0, "with_visit": 0, "no_change": 0,
|
||||||
|
"fill_null": 0, "forward": 0, "backward": 0, "orphan_cleared": 0,
|
||||||
|
"kept_orphan": 0,
|
||||||
|
}
|
||||||
|
pending = []
|
||||||
|
samples = []
|
||||||
|
|
||||||
|
qs = Customer.objects.only("id", "name_family", "last_bazdid_dt").order_by("id")
|
||||||
|
for customer in qs.iterator(chunk_size=batch_size):
|
||||||
|
stats["scanned"] += 1
|
||||||
|
new = visits.get(customer.id)
|
||||||
|
current = as_aware(customer.last_bazdid_dt)
|
||||||
|
if new is not None:
|
||||||
|
stats["with_visit"] += 1
|
||||||
|
|
||||||
|
bucket = _bucket(current, new, clear_orphans)
|
||||||
|
if bucket == "no_change":
|
||||||
|
stats["no_change"] += 1
|
||||||
|
if new is None and current is not None:
|
||||||
|
stats["kept_orphan"] += 1
|
||||||
|
continue
|
||||||
|
if not exact and bucket in ("backward", "orphan_cleared"):
|
||||||
|
stats["no_change"] += 1 # حالت پیشفرض فقط رو به جلو حرکت میکند
|
||||||
|
continue
|
||||||
|
|
||||||
|
stats[bucket] += 1
|
||||||
|
pending.append((customer.id, new))
|
||||||
|
if len(samples) < SAMPLE_LIMIT:
|
||||||
|
samples.append((customer.id, customer.name_family, current, new, bucket))
|
||||||
|
|
||||||
|
return stats, samples, pending, self._hamahangi_suspect_count()
|
||||||
|
|
||||||
|
def _apply(self, pending):
|
||||||
|
"""اعمال تغییرات؛ فقط ستون ``last_bazdid_dt`` مشتری نوشته میشود."""
|
||||||
|
with transaction.atomic():
|
||||||
|
for customer_id, target in pending:
|
||||||
|
Customer.objects.filter(pk=customer_id).update(last_bazdid_dt=target)
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
def _backup(self):
|
||||||
|
"""یک نسخهٔ سالم از دیتابیس با timestamp میسازد؛ خطا یعنی توقف کامل اجرا.
|
||||||
|
|
||||||
|
اول ``VACUUM INTO`` (سریع و اتمی) و اگر SQLite اجازه نداد (مثلاً داخل یک
|
||||||
|
تراکنش باز)، نسخهٔ سازگار با روش رسمی ``sqlite3.Connection.backup``
|
||||||
|
گرفته میشود تا در هر دو حالت یک فایل سالم حاصل شود.
|
||||||
|
"""
|
||||||
|
db_path = str(connection.settings_dict.get("NAME") or "")
|
||||||
|
if not db_path:
|
||||||
|
raise CommandError("مسیر دیتابیس مشخص نیست؛ بکاپ ممکن نشد.")
|
||||||
|
target_dir = os.path.dirname(os.path.abspath(db_path))
|
||||||
|
stamp = datetime.now().strftime("%Y%m%d-%H%M%S")
|
||||||
|
backup_path = os.path.join(target_dir, f"backup-{stamp}.sqlite3")
|
||||||
|
|
||||||
|
try:
|
||||||
|
with connection.cursor() as cursor:
|
||||||
|
cursor.execute("VACUUM INTO %s", [backup_path])
|
||||||
|
except Exception as vacuum_error: # noqa: BLE001
|
||||||
|
try:
|
||||||
|
self._copy_database(db_path, backup_path)
|
||||||
|
except Exception as copy_error: # noqa: BLE001
|
||||||
|
raise CommandError(
|
||||||
|
"بکاپ گرفتن شکست خورد؛ هیچ تغییری اعمال نشد: "
|
||||||
|
f"{vacuum_error} / {copy_error}"
|
||||||
|
)
|
||||||
|
if not os.path.exists(backup_path) or os.path.getsize(backup_path) == 0:
|
||||||
|
raise CommandError("فایل بکاپ ساخته نشد یا خالی است؛ اجرا متوقف شد.")
|
||||||
|
return backup_path
|
||||||
|
|
||||||
|
def _copy_database(self, source_path, target_path):
|
||||||
|
"""نسخهٔ پشتیبان سازگار از فایل SQLite (روش رسمی ``backup``)."""
|
||||||
|
import sqlite3
|
||||||
|
|
||||||
|
source = sqlite3.connect(self._source_uri(source_path), uri=True)
|
||||||
|
try:
|
||||||
|
target = sqlite3.connect(target_path)
|
||||||
|
try:
|
||||||
|
source.backup(target)
|
||||||
|
finally:
|
||||||
|
target.close()
|
||||||
|
finally:
|
||||||
|
source.close()
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _source_uri(source_path):
|
||||||
|
"""URI فقطخواندنی؛ اگر مسیر خودش URI باشد (دیتابیس تست) دستنخورده میماند."""
|
||||||
|
if source_path.startswith("file:"):
|
||||||
|
return source_path
|
||||||
|
return f"file:{source_path}?mode=ro"
|
||||||
|
|
||||||
|
def _hamahangi_suspect_count(self):
|
||||||
|
"""تعداد مشتریانی که مقدارشان دقیقاً برابر تاریخ یک رکورد هماهنگی است.
|
||||||
|
|
||||||
|
تشخیص **تقریبی** است: ``last_bazdid_dt`` هیچ ارجاعی به رکورد منبع ندارد،
|
||||||
|
پس برابری مقدار فقط «نشانه» است، نه اثبات.
|
||||||
|
"""
|
||||||
|
stamps = {}
|
||||||
|
rows = Action.objects.filter(
|
||||||
|
actionType=Action.ActionType.HAMAHANGI,
|
||||||
|
).values_list("customer_id", "actionDateTime", "finishDateTime")
|
||||||
|
for customer_id, action_dt, finish_dt in rows.iterator():
|
||||||
|
stamps.setdefault(customer_id, set()).update(
|
||||||
|
v for v in (as_aware(action_dt), as_aware(finish_dt)) if v is not None
|
||||||
|
)
|
||||||
|
if not stamps:
|
||||||
|
return 0
|
||||||
|
count = 0
|
||||||
|
qs = Customer.objects.filter(id__in=list(stamps)).only("id", "last_bazdid_dt")
|
||||||
|
for customer in qs.iterator():
|
||||||
|
if as_aware(customer.last_bazdid_dt) in stamps[customer.id]:
|
||||||
|
count += 1
|
||||||
|
return count
|
||||||
|
|
||||||
|
def _report(self, stats, samples, suspects, dry_run, exact, clear_orphans,
|
||||||
|
changed, backup_path=None):
|
||||||
style = self.style
|
style = self.style
|
||||||
label = "[شبیهسازی] " if dry_run else ""
|
label = "[شبیهسازی] " if dry_run else ""
|
||||||
self.stdout.write(style.MIGRATE_LABEL(f"\n{label}گزارش بازمحاسبهٔ آخرین بازدید:"))
|
mode = "exact" if exact else "default(forward-only)"
|
||||||
|
self.stdout.write(style.MIGRATE_LABEL(
|
||||||
|
f"\n{label}گزارش بازمحاسبهٔ آخرین بازدید (حالت: {mode}):"
|
||||||
|
))
|
||||||
self.stdout.write(f" بررسیشده (کل مشتریان): {stats['scanned']:,}")
|
self.stdout.write(f" بررسیشده (کل مشتریان): {stats['scanned']:,}")
|
||||||
self.stdout.write(f" دارای بازدیدِ تمامشده: {stats['with_visit']:,}")
|
self.stdout.write(f" دارای بازدید معتبر: {stats['with_visit']:,}")
|
||||||
self.stdout.write(f" از قبل درست: {stats['already_ok']:,}")
|
self.stdout.write(f" بدون تغییر: {stats['no_change']:,}")
|
||||||
self.stdout.write(f" نیازمند اصلاح: {stats['updated']:,}")
|
self.stdout.write(f" پر کردن مقدار خالی: {stats['fill_null']:,}")
|
||||||
self.stdout.write(f" → جدیدتر شد (خروج از معوق): {stats['moved_forward']:,}")
|
self.stdout.write(f" اصلاح رو به جلو: {stats['forward']:,}")
|
||||||
self.stdout.write(f" → عقبتر شد: {stats['moved_backward']:,}")
|
self.stdout.write(f" اصلاح رو به عقب: {stats['backward']:,}")
|
||||||
if clear_orphans:
|
if clear_orphans:
|
||||||
self.stdout.write(f" → خالی شد (بدون اقدام): {stats['set_null']:,}")
|
self.stdout.write(f" پاکسازی یتیم: {stats['orphan_cleared']:,}")
|
||||||
else:
|
else:
|
||||||
self.stdout.write(
|
self.stdout.write(
|
||||||
f" بدون اقدام ولی دارای مقدار (دستنخورده): {stats['kept_orphan']:,}"
|
f" بدون بازدید ولی دارای مقدار (دستنخورده): {stats['kept_orphan']:,}"
|
||||||
)
|
)
|
||||||
self.stdout.write(
|
self.stdout.write(style.WARNING(
|
||||||
f" NULL و بدون اقدام (طبیعی): {stats['null_and_no_action']:,}"
|
f" نشانهٔ آلودگی هماهنگی (تقریبی): {suspects:,}"
|
||||||
)
|
))
|
||||||
|
|
||||||
|
if samples:
|
||||||
|
self.stdout.write("\n نمونهٔ تغییرات (id | مشتری | فعلی → جدید | دسته):")
|
||||||
|
for customer_id, name, current, new, bucket in samples:
|
||||||
|
self.stdout.write(f" {customer_id} | {name} | {current} → {new} | {bucket}")
|
||||||
|
|
||||||
if dry_run:
|
if dry_run:
|
||||||
self.stdout.write(style.WARNING(
|
self.stdout.write(style.WARNING(
|
||||||
"\n[حالت شبیهسازی] هیچ رکوردی ذخیره نشد. برای اعمال، بدون --dry-run اجرا کن."
|
f"\n[شبیهسازی] {changed:,} تغییر پیشنهادی. هیچ رکوردی ذخیره نشد."
|
||||||
))
|
))
|
||||||
elif stats["updated"]:
|
elif changed:
|
||||||
self.stdout.write(style.SUCCESS(
|
self.stdout.write(style.SUCCESS(
|
||||||
f"\nانجام شد: {stats['updated']:,} رکورد بهروزرسانی شد."
|
f"\nانجام شد: {changed:,} مشتری بهروزرسانی شد (فقط ستون last_bazdid_dt)."
|
||||||
))
|
))
|
||||||
else:
|
else:
|
||||||
self.stdout.write(style.SUCCESS(
|
self.stdout.write(style.SUCCESS(
|
||||||
"\nانجام شد: هیچ اصلاحی لازم نبود (داده از قبل درست است)."
|
"\nانجام شد: هیچ اصلاحی لازم نبود (داده از قبل همراستا است)."
|
||||||
))
|
))
|
||||||
|
if backup_path:
|
||||||
|
self.stdout.write(f" بکاپ: {backup_path}")
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
# Generated by Django 3.2.25 on 2026-10-20 14:50
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('admin_panel', '0077_alter_smstemplate_text_balance_help'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
# ایندکس سهستونیِ قبلی باید ابتدا برداشته شود؛ وگرنه نام ``act_visit_idx``
|
||||||
|
# تکراری میشود. ساخت ایندکس پوشا هم همینجا انجام میشود تا وضعیت مدل
|
||||||
|
# (``Meta.indexes``) و دیتابیس دقیقاً یکی باشند.
|
||||||
|
migrations.RunSQL(
|
||||||
|
sql=[
|
||||||
|
'DROP INDEX IF EXISTS "act_visit_idx";',
|
||||||
|
(
|
||||||
|
'CREATE INDEX "act_visit_idx" ON "admin_panel_action" '
|
||||||
|
'("actionType", "status", "customer_id", "actionDateTime", "finishDateTime");'
|
||||||
|
),
|
||||||
|
],
|
||||||
|
reverse_sql=['DROP INDEX IF EXISTS "act_visit_idx";'],
|
||||||
|
state_operations=[
|
||||||
|
migrations.AddIndex(
|
||||||
|
model_name='action',
|
||||||
|
index=models.Index(
|
||||||
|
fields=['actionType', 'status', 'customer_id',
|
||||||
|
'actionDateTime', 'finishDateTime'],
|
||||||
|
name='act_visit_idx',
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -66,3 +66,19 @@ class Action(models.Model):
|
|||||||
nextNazarDT = models.DateTimeField(null=True, blank=True, default=None)
|
nextNazarDT = models.DateTimeField(null=True, blank=True, default=None)
|
||||||
nazarDT = models.DateTimeField(null=True, blank=True, default=None)
|
nazarDT = models.DateTimeField(null=True, blank=True, default=None)
|
||||||
grade = models.IntegerField(null=True, blank=True, default=None)
|
grade = models.IntegerField(null=True, blank=True, default=None)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
# ایندکس **پوشا (covering)** برای محاسبهٔ «آخرین بازدید»: هر سه ستونِ لازم
|
||||||
|
# برای فیلتر و تجمیع داخل خودِ ایندکس است، پس دیتابیس به جدول اصلی مراجعه
|
||||||
|
# نمیکند. اندازهگیری روی دادهٔ واقعی: ۱۶۴ms → ۲۹ms.
|
||||||
|
# ستونهای actionType/status/customer_id برای فیلتر و group، و دو ستون
|
||||||
|
# تاریخ برای محاسبهٔ «تاریخ مؤثر» در همان ایندکس.
|
||||||
|
indexes = [
|
||||||
|
models.Index(
|
||||||
|
fields=[
|
||||||
|
'actionType', 'status', 'customer_id',
|
||||||
|
'actionDateTime', 'finishDateTime',
|
||||||
|
],
|
||||||
|
name='act_visit_idx',
|
||||||
|
),
|
||||||
|
]
|
||||||
+77
-33
@@ -21,14 +21,14 @@
|
|||||||
همیشه خاموش نمیماند.
|
همیشه خاموش نمیماند.
|
||||||
"""
|
"""
|
||||||
import logging
|
import logging
|
||||||
import os
|
|
||||||
from datetime import datetime, time, timedelta
|
from datetime import datetime, time, timedelta
|
||||||
|
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
|
|
||||||
from ..models import Customer, Sarresid, SMSProviderConfig, SMSTemplate, SMSType
|
from ..models import Customer, Sarresid, SMSProviderConfig, SMSTemplate, SMSType
|
||||||
|
from ..visit_policy import (as_aware, cycle_anchor_map, next_appointment_map,
|
||||||
|
visit_latest_due, visit_period)
|
||||||
from .enqueue import enqueue_sms
|
from .enqueue import enqueue_sms
|
||||||
from .timing import DEFAULT_VISIT_MONTHS
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -38,24 +38,39 @@ def _template(sms_type):
|
|||||||
|
|
||||||
|
|
||||||
def _aware(value):
|
def _aware(value):
|
||||||
"""تبدیل تاریخ/ساعت naive به aware (کد قدیمی پروژه جاهایی مقدار naive ذخیره میکند)."""
|
"""تبدیل تاریخ/ساعت naive به aware (کد قدیمی پروژه جاهایی مقدار naive ذخیره میکند).
|
||||||
|
|
||||||
|
در روزهای تغییر ساعت، نیمهشب ممکن است وجود نداشته باشد؛ آن حالت هم پوشش داده
|
||||||
|
میشود تا یک ``installDate`` روی مرز DST اجرای کل اسکن را نشکند.
|
||||||
|
"""
|
||||||
if value is None:
|
if value is None:
|
||||||
return None
|
return None
|
||||||
if isinstance(value, datetime):
|
if isinstance(value, datetime):
|
||||||
if timezone.is_naive(value):
|
if timezone.is_naive(value):
|
||||||
return timezone.make_aware(value, timezone.get_current_timezone())
|
try:
|
||||||
|
return timezone.make_aware(value, timezone.get_current_timezone())
|
||||||
|
except Exception:
|
||||||
|
return timezone.make_aware(
|
||||||
|
value, timezone.get_current_timezone(), is_dst=False,
|
||||||
|
)
|
||||||
return value
|
return value
|
||||||
# date → آغاز همان روز
|
# date → آغاز همان روز
|
||||||
return timezone.make_aware(
|
moment = datetime.combine(value, time.min)
|
||||||
datetime.combine(value, time.min), timezone.get_current_timezone(),
|
try:
|
||||||
)
|
return timezone.make_aware(moment, timezone.get_current_timezone())
|
||||||
|
except Exception:
|
||||||
|
return timezone.make_aware(
|
||||||
|
moment, timezone.get_current_timezone(), is_dst=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class _Budget:
|
class _Budget:
|
||||||
"""ظرفیت صفکردن یک اسکنر در یک اجرا (خط شروع + سقف قدیمی + سقف کل)."""
|
"""ظرفیت صفکردن یک اسکنر در یک اجرا (خط شروع + سقف قدیمی + سقف کل)."""
|
||||||
|
|
||||||
def __init__(self, config):
|
def __init__(self, config, now=None):
|
||||||
self.since = config.scan_since
|
self.since = config.scan_since
|
||||||
|
# مرجع زمانی «خط شروع»؛ برای اسکن با زمان تزریقی (تست/بازپخش) همزمان میشود.
|
||||||
|
self.now = now
|
||||||
self.allow_backlog = bool(config.allow_backlog)
|
self.allow_backlog = bool(config.allow_backlog)
|
||||||
self.backlog_max = config.backlog_max or 0
|
self.backlog_max = config.backlog_max or 0
|
||||||
self.max_per_run = config.max_per_run or 0
|
self.max_per_run = config.max_per_run or 0
|
||||||
@@ -63,8 +78,21 @@ class _Budget:
|
|||||||
self.backlog_count = 0
|
self.backlog_count = 0
|
||||||
|
|
||||||
def is_backlog(self, trigger):
|
def is_backlog(self, trigger):
|
||||||
|
"""آیا این مورد پیش از «خط شروع» واجد شرط شده است؟
|
||||||
|
|
||||||
|
مرجع زمانی، «اکنونِ همین اجرا» است (``self.now``)؛ برای اسکن عادی همان
|
||||||
|
``timezone.now()`` و برای اسکن با زمان تزریقی همان لحظهٔ تزریقشده.
|
||||||
|
"""
|
||||||
trigger = _aware(trigger)
|
trigger = _aware(trigger)
|
||||||
return self.since is not None and trigger < self.since
|
since = self.since
|
||||||
|
if since is not None and self.now is not None:
|
||||||
|
moment = _aware(self.now)
|
||||||
|
now_ref = timezone.now()
|
||||||
|
if moment is not None and now_ref is not None:
|
||||||
|
# پنجره با «همان لحظهای» که اسکن برایش اجرا میشود سنجیده شود،
|
||||||
|
# وگرنه اسکنِ بازپخش/تست همیشه همهچیز را «قدیمی» میبیند.
|
||||||
|
since = since + (moment - now_ref)
|
||||||
|
return since is not None and trigger < since
|
||||||
|
|
||||||
def can_send(self, trigger):
|
def can_send(self, trigger):
|
||||||
if self.max_per_run and (self.new_count + self.backlog_count) >= self.max_per_run:
|
if self.max_per_run and (self.new_count + self.backlog_count) >= self.max_per_run:
|
||||||
@@ -166,50 +194,66 @@ def scan_nesiye_due(today):
|
|||||||
return count
|
return count
|
||||||
|
|
||||||
|
|
||||||
def scan_visit(today):
|
def scan_visit(today, *, now=None):
|
||||||
"""یادآوری بازدید برای مشتریان عقبافتاده از بازدید (منطق صفحهٔ «انتظار»).
|
"""یادآوری بازدید برای مشتریان عقبافتاده از بازدید (منطق صفحهٔ «انتظار»).
|
||||||
|
|
||||||
لحظهٔ واجدشرطشدن هر مشتری = «آخرین بازدید + دورهٔ مجاز» (یا تاریخ نصب برای
|
لحظهٔ واجدشرطشدن هر مشتری = «آخرین بازدید معتبر + دورهٔ مجاز» (یا تاریخ نصب
|
||||||
مشتریانی که هرگز بازدید نشدهاند). اگر این لحظه پیش از خط شروع باشد، مورد قدیمی
|
برای مشتریانی که هرگز بازدید نشدهاند). مبنای «آخرین بازدید» و دوره، هر دو از
|
||||||
تلقی میشود و فقط با اجازهٔ صریح مدیر (و در سقف تعیینشده) ارسال میگردد.
|
``admin_panel.visit_policy`` میآیند تا این اسکنر و صفحهٔ ``entezar`` هرگز
|
||||||
|
دو جواب متفاوت ندهند. مشتری دارای «تعویق فعال» (هماهنگی در انتظار) یادآوری
|
||||||
|
نمیگیرد، چون قرار بعدی از قبل با او گذاشته شده است.
|
||||||
|
|
||||||
|
``now`` برای تست/بازپخش قابل تزریق است؛ پیشفرض «همین حالا» است.
|
||||||
|
|
||||||
|
اگر لحظهٔ واجدشرطشدن پیش از خط شروع باشد، مورد قدیمی تلقی میشود و فقط با
|
||||||
|
اجازهٔ صریح مدیر (و در سقف تعیینشده) ارسال میگردد.
|
||||||
"""
|
"""
|
||||||
template = _template(SMSType.VISIT)
|
template = _template(SMSType.VISIT)
|
||||||
if template is None:
|
if template is None:
|
||||||
return 0
|
return 0
|
||||||
budget = _Budget(SMSProviderConfig.load())
|
period = visit_period()
|
||||||
# دورهٔ بازدید: اول از قالب (قابل تنظیم از پنل)، وگرنه متغیر محیطی legacy، وگرنه پیشفرض.
|
now = timezone.now() if now is None else as_aware(now)
|
||||||
months = template.repeat_interval_months
|
budget = _Budget(SMSProviderConfig.load(), now=now)
|
||||||
if not months:
|
# لنگر چرخه: آخرین «بازدید» یا «نصب» (تصمیم محصولی). تماس تلفنی چرخه را ریست نمیکند.
|
||||||
try:
|
anchors = cycle_anchor_map(now=now)
|
||||||
months = int(os.environ.get("bazdid", DEFAULT_VISIT_MONTHS))
|
# قرار بعدی (هماهنگی توافقشده یا بازدید برنامهریزیشده) ⇒ یادآوری لازم نیست.
|
||||||
except (TypeError, ValueError):
|
appointments = next_appointment_map(now=now)
|
||||||
months = DEFAULT_VISIT_MONTHS
|
# مرز تاریخها باید به وقت **محلی** گرفته شود؛ `.date()` روی دیتاتایم aware،
|
||||||
period = timedelta(weeks=int(months * 4.3))
|
# تاریخ UTC میدهد که در ساعتهای اول شب تهران یک روز عقبتر است و باعث میشد
|
||||||
period_days = max(period.days, 1)
|
# مشتریِ دقیقاً روی مرز، از اسکن جا بیفتد.
|
||||||
now = timezone.now()
|
cutoff_date = (now - period).astimezone(timezone.get_current_timezone()).date()
|
||||||
qs = Customer.objects.filter(
|
qs = Customer.objects.filter(
|
||||||
laghv_bazdid=False,
|
laghv_bazdid=False,
|
||||||
is_active=True,
|
is_active=True,
|
||||||
installDate__lte=(now - period).date(),
|
installDate__lte=cutoff_date,
|
||||||
).order_by("id")
|
).order_by("id")
|
||||||
count = 0
|
count = 0
|
||||||
for customer in qs:
|
for customer in qs:
|
||||||
anchor = _aware(customer.last_bazdid_dt) or _aware(customer.installDate)
|
if customer.id in appointments:
|
||||||
|
continue # قرار بعدی ثبت شده است؛ یادآوری لازم نیست
|
||||||
|
anchor = anchors.get(customer.id) or _aware(customer.installDate)
|
||||||
if anchor is None:
|
if anchor is None:
|
||||||
continue
|
continue
|
||||||
trigger = anchor + period
|
# «تازهترین» موعدی که رسیده است (۹ ماه، ۱۸ ماه، ۲۷ ماه…).
|
||||||
if trigger > now:
|
# قاعدهٔ قبلی همیشه موعد اول را میسنجید و همین باعث میشد دورههای بعدی
|
||||||
|
# هرگز ساخته نشوند.
|
||||||
|
trigger = visit_latest_due(anchor, now=now)
|
||||||
|
# موعد یک «تاریخ» است نه یک لحظه؛ اگر روزِ موعد فرا رسیده باشد باید صف شود.
|
||||||
|
if trigger is None or trigger.date() > now.astimezone(
|
||||||
|
timezone.get_current_timezone()
|
||||||
|
).date():
|
||||||
continue # هنوز عقبافتاده نیست
|
continue # هنوز عقبافتاده نیست
|
||||||
if not budget.can_send(trigger):
|
if not budget.can_send(trigger):
|
||||||
continue
|
continue
|
||||||
# چرخه = شمارهٔ دورهٔ کاملِ سپریشده از لنگر. کلید به چرخه گره میخورد تا
|
# کلید یکتاسازی = «موعد تقویمیِ» همان دوره، نه شمارش روز.
|
||||||
# حذفِ یادآوری توسط مدیر فقط همان «دوره» را کنسل کند و در دورههای بعدی
|
# با این قاعده هر دوره دقیقاً یک کلید دارد: ۹ ماه، ۱۸ ماه، ۲۷ ماه… .
|
||||||
# دوباره ساخته شود (نه اینکه برای همیشه خاموش بماند).
|
# (قاعدهٔ قبلی — days // 274 — با موعد تقویمی همتراز نبود و باعث
|
||||||
cycle = max((today - anchor.date()).days // period_days, 0)
|
# گمشدنِ یک دوره و تکرار/پرش شمارهٔ چرخه میشد.)
|
||||||
|
due_month = trigger.strftime("%Y-%m")
|
||||||
made = enqueue_sms(
|
made = enqueue_sms(
|
||||||
customer=customer, sms_type=SMSType.VISIT,
|
customer=customer, sms_type=SMSType.VISIT,
|
||||||
context={"name": customer.name_family},
|
context={"name": customer.name_family},
|
||||||
dedup_key=f"visit:{customer.id}:{cycle}",
|
dedup_key=f"visit:{customer.id}:{due_month}",
|
||||||
)
|
)
|
||||||
if made:
|
if made:
|
||||||
budget.record(trigger)
|
budget.record(trigger)
|
||||||
|
|||||||
@@ -1,13 +1,19 @@
|
|||||||
import datetime
|
import datetime
|
||||||
|
|
||||||
|
from django.core.exceptions import ValidationError
|
||||||
from django.db.models import Q
|
from django.db.models import Q
|
||||||
from django.shortcuts import render, redirect, get_object_or_404
|
from django.shortcuts import render, redirect, get_object_or_404
|
||||||
from django.http import HttpResponse
|
from django.http import HttpResponse
|
||||||
|
from django.contrib import messages
|
||||||
from django.contrib.auth.models import User
|
from django.contrib.auth.models import User
|
||||||
from django.contrib.auth.decorators import login_required
|
from django.contrib.auth.decorators import login_required
|
||||||
|
from django.utils import timezone
|
||||||
|
|
||||||
from ..models import Action, Customer
|
from ..models import Action, Customer
|
||||||
from ..sms.triggers import notify_visit_scheduled
|
from ..sms.triggers import notify_visit_scheduled
|
||||||
|
from ..visit_policy import (REASON_CANCEL, REASON_CREATE, REASON_EDIT,
|
||||||
|
as_aware, normalize_finish_datetime,
|
||||||
|
refresh_last_bazdid, validate_action_payload)
|
||||||
|
|
||||||
|
|
||||||
@login_required(login_url='/panel/p_login/')
|
@login_required(login_url='/panel/p_login/')
|
||||||
@@ -54,46 +60,68 @@ def customerNazar(request ,c_id):
|
|||||||
data = {'customer': customer, 'actions': actions, 'allUsers': allUsers}
|
data = {'customer': customer, 'actions': actions, 'allUsers': allUsers}
|
||||||
return render(request, 'admin_panel/customer/nazar.html', data)
|
return render(request, 'admin_panel/customer/nazar.html', data)
|
||||||
|
|
||||||
|
def _parse_form_datetime(raw):
|
||||||
|
"""timestamp میلیثانیهای فرم → دیتاتایم aware (یا ``None``).
|
||||||
|
|
||||||
|
کد قبلی ``datetime.datetime.fromtimestamp`` (naive) میساخت که هنگام ذخیره
|
||||||
|
با ``USE_TZ=True`` هشدار ``RuntimeWarning`` میداد؛ اینجا صریح aware میشود.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
return as_aware(datetime.datetime.fromtimestamp(int(raw) / 1e3))
|
||||||
|
except (TypeError, ValueError, OverflowError, OSError):
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _action_payload_from_request(request):
|
||||||
|
"""استخراج و نرمالسازی ورودی فرم اقدام (مشترک ایجاد و ویرایش)."""
|
||||||
|
is_done = bool(request.POST.get('is_done'))
|
||||||
|
return {
|
||||||
|
'actionType': (request.POST.get('actionType') or '').strip(),
|
||||||
|
'status': Action.Status.FINISH if is_done else Action.Status.WAITE,
|
||||||
|
'actionDateTime': _parse_form_datetime(request.POST.get('dateTimeAnjam')),
|
||||||
|
'finishDateTime': _parse_form_datetime(request.POST.get('FinishDateTime')),
|
||||||
|
'desc': request.POST.get('desc', None),
|
||||||
|
'result': request.POST.get('result', None),
|
||||||
|
'userID': request.POST.get('userID', None),
|
||||||
|
'is_done': is_done,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _reject(request, error, redirect_to):
|
||||||
|
"""پیام فارسی خطا به کاربر و بازگشت بدون ذخیرهٔ چیزی."""
|
||||||
|
messages.error(request, '؛ '.join(error.messages))
|
||||||
|
return redirect(redirect_to)
|
||||||
|
|
||||||
|
|
||||||
@login_required(login_url='/panel/p_login/')
|
@login_required(login_url='/panel/p_login/')
|
||||||
def customerNewAction(request ,c_id):
|
def customerNewAction(request ,c_id):
|
||||||
actionType = request.POST.get('actionType', None)
|
payload = _action_payload_from_request(request)
|
||||||
dateTimeAnjam = request.POST.get('dateTimeAnjam', None)
|
|
||||||
#return HttpResponse(float(dateTimeAnjam+".0"))
|
|
||||||
try:
|
try:
|
||||||
dateTimeAnjam = datetime.datetime.fromtimestamp(int(dateTimeAnjam)/1e3)
|
validate_action_payload(payload)
|
||||||
except:
|
except ValidationError as error:
|
||||||
pass
|
return _reject(request, error, '/panel/customer/' + str(c_id) + '/action')
|
||||||
desc = request.POST.get('desc', None)
|
|
||||||
userID = request.POST.get('userID', None)
|
|
||||||
is_done = request.POST.get('is_done', None)
|
|
||||||
result = request.POST.get('result', None)
|
|
||||||
FinishDateTime = request.POST.get('FinishDateTime', None)
|
|
||||||
try:
|
|
||||||
FinishDateTime = datetime.datetime.fromtimestamp(int(FinishDateTime)/1e3)
|
|
||||||
except:
|
|
||||||
pass
|
|
||||||
|
|
||||||
customer_obj = Customer.objects.get(id=int(c_id))
|
customer_obj = Customer.objects.get(id=int(c_id))
|
||||||
newAction = Action()
|
newAction = Action()
|
||||||
newAction.actionType = actionType
|
newAction.actionType = payload['actionType']
|
||||||
newAction.actionDateTime = dateTimeAnjam
|
newAction.actionDateTime = payload['actionDateTime']
|
||||||
newAction.desc = desc
|
newAction.desc = payload['desc']
|
||||||
newAction.forUser = User.objects.get(id=int(userID))
|
newAction.forUser = User.objects.get(id=int(payload['userID']))
|
||||||
newAction.customer = customer_obj
|
newAction.customer = customer_obj
|
||||||
if is_done:
|
if payload['is_done']:
|
||||||
newAction.status = 'finish'
|
newAction.status = Action.Status.FINISH
|
||||||
newAction.finishDateTime = FinishDateTime
|
# تاریخ اتمام خالی نباید None ذخیره شود (سابقه را پاک میکرد).
|
||||||
newAction.result = result
|
newAction.finishDateTime = normalize_finish_datetime(payload['finishDateTime'])
|
||||||
|
newAction.result = payload['result']
|
||||||
else:
|
else:
|
||||||
newAction.status = 'waite'
|
newAction.status = Action.Status.WAITE
|
||||||
newAction.save()
|
newAction.save()
|
||||||
|
|
||||||
if newAction.status == 'finish' and newAction.actionType == 'bazdid':
|
# last_bazdid_dt فقط از تابع واحد بازمحاسبه میشود؛ نه از finishDateTime.
|
||||||
customer_obj.last_bazdid_dt = newAction.finishDateTime
|
refresh_last_bazdid(customer_obj, reason=REASON_CREATE, user=request.user)
|
||||||
customer_obj.save()
|
|
||||||
|
|
||||||
# پیامک «بازدید شما برای تاریخ … ثبت شد» فقط برای اقدام از نوع بازدید.
|
# پیامک «بازدید شما برای تاریخ … ثبت شد» فقط برای اقدام از نوع بازدید.
|
||||||
if newAction.actionType == 'bazdid':
|
if newAction.actionType == Action.ActionType.BAZDID:
|
||||||
notify_visit_scheduled(customer_obj, newAction, created_by=request.user)
|
notify_visit_scheduled(customer_obj, newAction, created_by=request.user)
|
||||||
|
|
||||||
return redirect('/panel/customer/'+str(c_id)+'/action')
|
return redirect('/panel/customer/'+str(c_id)+'/action')
|
||||||
@@ -116,42 +144,37 @@ def ActionListUser(request, u_id):
|
|||||||
@login_required(login_url='/panel/p_login/')
|
@login_required(login_url='/panel/p_login/')
|
||||||
def customerEditAction(request ,c_id, action_id):
|
def customerEditAction(request ,c_id, action_id):
|
||||||
|
|
||||||
actionType = request.POST.get('actionType', None)
|
payload = _action_payload_from_request(request)
|
||||||
dateTimeAnjam = request.POST.get('dateTimeAnjam', None)
|
|
||||||
print("ok")
|
|
||||||
#return HttpResponse(float(dateTimeAnjam+".0"))
|
|
||||||
try:
|
try:
|
||||||
dateTimeAnjam = datetime.datetime.fromtimestamp(int(dateTimeAnjam)/1e3)
|
validate_action_payload(payload)
|
||||||
except:
|
except ValidationError as error:
|
||||||
pass
|
return _reject(
|
||||||
desc = request.POST.get('desc', None)
|
request, error,
|
||||||
userID = request.POST.get('userID', None)
|
request.META.get('HTTP_REFERER', '/panel/customer/' + str(c_id) + '/action'),
|
||||||
is_done = request.POST.get('is_done', None)
|
)
|
||||||
result = request.POST.get('result', None)
|
|
||||||
FinishDateTime = request.POST.get('FinishDateTime', None)
|
|
||||||
try:
|
|
||||||
FinishDateTime = datetime.datetime.fromtimestamp(int(FinishDateTime)/1e3)
|
|
||||||
except:
|
|
||||||
pass
|
|
||||||
|
|
||||||
customer_obj = Customer.objects.get(id=int(c_id))
|
customer_obj = Customer.objects.get(id=int(c_id))
|
||||||
newAction = Action.objects.get(id=action_id)
|
newAction = Action.objects.get(id=action_id)
|
||||||
newAction.actionType = actionType
|
was_done = newAction.status == Action.Status.FINISH
|
||||||
newAction.actionDateTime = dateTimeAnjam
|
|
||||||
newAction.desc = desc
|
newAction.actionType = payload['actionType']
|
||||||
newAction.forUser = User.objects.get(id=int(userID))
|
newAction.actionDateTime = payload['actionDateTime']
|
||||||
|
newAction.desc = payload['desc']
|
||||||
|
newAction.forUser = User.objects.get(id=int(payload['userID']))
|
||||||
newAction.customer = customer_obj
|
newAction.customer = customer_obj
|
||||||
if is_done:
|
if payload['is_done']:
|
||||||
newAction.status = 'finish'
|
newAction.status = Action.Status.FINISH
|
||||||
newAction.finishDateTime = FinishDateTime
|
newAction.finishDateTime = normalize_finish_datetime(payload['finishDateTime'])
|
||||||
newAction.result = result
|
newAction.result = payload['result']
|
||||||
else:
|
else:
|
||||||
newAction.status = 'waite'
|
newAction.status = Action.Status.WAITE
|
||||||
newAction.save()
|
newAction.save()
|
||||||
|
|
||||||
if newAction.status == 'finish' and newAction.actionType == 'bazdid':
|
# بازمحاسبهٔ واحد: هم پس از ویرایش، هم وقتی رکورد از «انجامشده» برگشت میخورد
|
||||||
customer_obj.last_bazdid_dt = newAction.finishDateTime
|
# (تا مقداری که این رکورد ساخته بود با منبع حقیقت همراستا شود).
|
||||||
customer_obj.save()
|
reason = REASON_CANCEL if (was_done and not payload['is_done']) else REASON_EDIT
|
||||||
|
refresh_last_bazdid(customer_obj, reason=reason, user=request.user)
|
||||||
|
|
||||||
#return redirect('/panel/customer/'+str(c_id)+'/action')
|
#return redirect('/panel/customer/'+str(c_id)+'/action')
|
||||||
return redirect(request.META.get('HTTP_REFERER', 'redirect_if_referer_not_found'))
|
return redirect(request.META.get('HTTP_REFERER', 'redirect_if_referer_not_found'))
|
||||||
|
|
||||||
@@ -205,7 +228,7 @@ def ActionListUserToday(request, u_id):
|
|||||||
@login_required(login_url='/panel/p_login/')
|
@login_required(login_url='/panel/p_login/')
|
||||||
def rezayat(request):
|
def rezayat(request):
|
||||||
statusRezayat = request.GET.get('status', 'waite')
|
statusRezayat = request.GET.get('status', 'waite')
|
||||||
now = datetime.datetime.now()
|
now = timezone.now()
|
||||||
|
|
||||||
# محاسبه بازههای زمانی ۳۰ روز و ۶۰ روز قبل
|
# محاسبه بازههای زمانی ۳۰ روز و ۶۰ روز قبل
|
||||||
dt_30_days_ago = now - datetime.timedelta(days=30)
|
dt_30_days_ago = now - datetime.timedelta(days=30)
|
||||||
@@ -246,20 +269,20 @@ def rezayatEdit(request, ac_id):
|
|||||||
# عدم موفقیت: تنظیم nextNazarDT برای ۳ روز بعد (nazarDT پر نمیشود تا نهایی نشود)
|
# عدم موفقیت: تنظیم nextNazarDT برای ۳ روز بعد (nazarDT پر نمیشود تا نهایی نشود)
|
||||||
action.nazar = csDesc
|
action.nazar = csDesc
|
||||||
action.grade = int(csGrade)
|
action.grade = int(csGrade)
|
||||||
action.nextNazarDT = datetime.datetime.now() + datetime.timedelta(days=3)
|
action.nextNazarDT = timezone.now() + datetime.timedelta(days=3)
|
||||||
|
|
||||||
elif csGrade == '-2':
|
elif csGrade == '-2':
|
||||||
# کنسل کردن: پر کردن nazarDT برای خروج کامل از لیست کارهای در انتظار
|
# کنسل کردن: پر کردن nazarDT برای خروج کامل از لیست کارهای در انتظار
|
||||||
action.nazar = csDesc
|
action.nazar = csDesc
|
||||||
action.grade = int(csGrade)
|
action.grade = int(csGrade)
|
||||||
action.nazarDT = datetime.datetime.now()
|
action.nazarDT = timezone.now()
|
||||||
action.nextNazarDT = None
|
action.nextNazarDT = None
|
||||||
|
|
||||||
else:
|
else:
|
||||||
# ثبت امتیاز عادی
|
# ثبت امتیاز عادی
|
||||||
action.nazar = csDesc
|
action.nazar = csDesc
|
||||||
action.grade = int(csGrade)
|
action.grade = int(csGrade)
|
||||||
action.nazarDT = datetime.datetime.now()
|
action.nazarDT = timezone.now()
|
||||||
action.nextNazarDT = None
|
action.nextNazarDT = None
|
||||||
|
|
||||||
action.save()
|
action.save()
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ from django.db import models
|
|||||||
from django.db.models import Sum, Q
|
from django.db.models import Sum, Q
|
||||||
from django.shortcuts import redirect, render, get_object_or_404
|
from django.shortcuts import redirect, render, get_object_or_404
|
||||||
from django.core.paginator import Paginator
|
from django.core.paginator import Paginator
|
||||||
|
from django.utils import timezone
|
||||||
from django.views.decorators.csrf import csrf_exempt
|
from django.views.decorators.csrf import csrf_exempt
|
||||||
from django.contrib import messages
|
from django.contrib import messages
|
||||||
from django.contrib.auth.decorators import login_required, permission_required
|
from django.contrib.auth.decorators import login_required, permission_required
|
||||||
@@ -17,6 +18,7 @@ from ..sms.phones import (normalize_primary_values,
|
|||||||
save_primary_normalized)
|
save_primary_normalized)
|
||||||
from ..sms.triggers import notify_payment_thanks, notify_welcome
|
from ..sms.triggers import notify_payment_thanks, notify_welcome
|
||||||
from ..sms.rules import customer_toggle_map
|
from ..sms.rules import customer_toggle_map
|
||||||
|
from ..visit_policy import (last_visit_map, next_appointment_map, visit_due_at)
|
||||||
from ._helpers import logger, _parse_jalali_date, _parse_phone_rows
|
from ._helpers import logger, _parse_jalali_date, _parse_phone_rows
|
||||||
|
|
||||||
|
|
||||||
@@ -327,56 +329,55 @@ def customerEdit(request, c_id):
|
|||||||
|
|
||||||
@login_required(login_url='/panel/p_login/')
|
@login_required(login_url='/panel/p_login/')
|
||||||
def entezar(request):
|
def entezar(request):
|
||||||
a = datetime.datetime.now()
|
"""فهرست «منتظر بازدید».
|
||||||
|
|
||||||
|
منبع حقیقت تاریخ بازدید، ``visit_policy.last_visit_map`` است (نه ستون کششدهٔ
|
||||||
|
``last_bazdid_dt``) تا این صفحه و اسکنر پیامک هرگز واگرا نشوند. دورهٔ بازدید هم
|
||||||
|
از همان قالب پیامک خوانده میشود. مشتری دارای «تعویق فعال» (اقدام هماهنگی
|
||||||
|
در انتظار با تاریخ آینده) از فهرست خارج و به صف خدمات پس از فروش سپرده میشود.
|
||||||
|
"""
|
||||||
searchVal = request.GET.get('searchVal', '')
|
searchVal = request.GET.get('searchVal', '')
|
||||||
searchOn = request.GET.get('searchOn', '')
|
searchOn = request.GET.get('searchOn', '')
|
||||||
searchStatus = True
|
searchStatus = True
|
||||||
full_path = request.get_full_path()
|
full_path = request.get_full_path()
|
||||||
|
|
||||||
from django.db.models import Q
|
now = timezone.now()
|
||||||
|
due_cut = visit_due_at(now=now)
|
||||||
|
# مرز تاریخ به وقت محلی (نه UTC) تا صفحه و اسکنر پیامک یک جواب بدهند.
|
||||||
|
due_cut_date = due_cut.astimezone(timezone.get_current_timezone()).date()
|
||||||
|
|
||||||
bazdid = os.environ.get('bazdid', 9)
|
visits = last_visit_map(now=now)
|
||||||
|
appointments = next_appointment_map(now=now)
|
||||||
bazdid = int ( int(bazdid) * 4.3 )
|
|
||||||
|
|
||||||
dateTime6MonthAgo = a - datetime.timedelta(weeks=bazdid)
|
|
||||||
''' customers = Customer.objects.filter(name_family__isnull=False)
|
|
||||||
m6mbazdid = []
|
|
||||||
for customerObj in customers:
|
|
||||||
try:
|
|
||||||
actionAa = Action.objects.filter(customer=customerObj, actionType='bazdid', status='finish').last()
|
|
||||||
if actionAa.status == 'finish':
|
|
||||||
customerObj.last_bazdid_dt = actionAa.actionDateTime
|
|
||||||
customerObj.save()
|
|
||||||
if actionAa.actionDateTime.date() < dateTime6MonthAgo.date():
|
|
||||||
m6mbazdid.append(actionAa)
|
|
||||||
except:
|
|
||||||
pass '''
|
|
||||||
|
|
||||||
|
customers_objj = Customer.objects.filter(
|
||||||
|
laghv_bazdid=False, name_family__isnull=False,
|
||||||
|
installDate__lte=due_cut_date,
|
||||||
|
)
|
||||||
if searchOn == 'address':
|
if searchOn == 'address':
|
||||||
print("addressaddressaddressaddressaddress")
|
customers_objj = customers_objj.filter(address__contains=searchVal)
|
||||||
customers_objj = Customer.objects.filter(Q(last_bazdid_dt__lte=dateTime6MonthAgo) | Q(last_bazdid_dt__isnull=True),
|
|
||||||
installDate__lte=dateTime6MonthAgo,
|
|
||||||
laghv_bazdid=False, name_family__isnull=False, address__contains=searchVal).order_by('last_bazdid_dt')
|
|
||||||
else:
|
else:
|
||||||
searchStatus = False
|
searchStatus = False
|
||||||
customers_objj = Customer.objects.filter(Q(last_bazdid_dt__lte=dateTime6MonthAgo) | Q(last_bazdid_dt__isnull=True),
|
|
||||||
installDate__lte=dateTime6MonthAgo,
|
|
||||||
laghv_bazdid=False, name_family__isnull=False).order_by('last_bazdid_dt')
|
|
||||||
|
|
||||||
#customers_objj = Customer.objects.filter(name_family__isnull=False, last_bazdid_dt__lte=dateTime6MonthAgo)
|
# مشتریان عقبافتاده: بدون بازدید معتبر، یا بازدید معتبرشان پیش از مقطع دوره.
|
||||||
|
stale_ids = [
|
||||||
|
customer_id for customer_id in customers_objj.values_list('id', flat=True)
|
||||||
|
if visits.get(customer_id) is None or visits[customer_id] <= due_cut
|
||||||
|
]
|
||||||
|
customers_objj = customers_objj.filter(id__in=stale_ids)
|
||||||
|
|
||||||
|
# «قرار بعدی» (هماهنگی توافقشده یا بازدید برنامهریزیشده) یعنی این مشتری
|
||||||
|
# در این فهرست جایی ندارد تا آن تاریخ.
|
||||||
|
pending_ids = [cid for cid in stale_ids if cid in appointments]
|
||||||
|
if pending_ids:
|
||||||
|
customers_objj = customers_objj.exclude(id__in=pending_ids)
|
||||||
|
|
||||||
|
customers_objj = customers_objj.order_by('last_bazdid_dt', 'id')
|
||||||
|
|
||||||
try:
|
try:
|
||||||
entezar_bazdid = CacheTable.objects.get(key='9mahbazdid')
|
entezar_bazdid = CacheTable.objects.get(key='9mahbazdid')
|
||||||
|
entezar_bazdid.value = len(stale_ids) - len(pending_ids)
|
||||||
entezar_bazdid.value = Customer.objects.filter(
|
|
||||||
Q(last_bazdid_dt__lte=dateTime6MonthAgo) | Q(last_bazdid_dt__isnull=True),
|
|
||||||
installDate__lte=dateTime6MonthAgo,
|
|
||||||
laghv_bazdid=False, name_family__isnull=False).count()
|
|
||||||
|
|
||||||
entezar_bazdid.save()
|
entezar_bazdid.save()
|
||||||
except:
|
except CacheTable.DoesNotExist:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
paginator = Paginator(customers_objj, 20) # Show 25 contacts per page.
|
paginator = Paginator(customers_objj, 20) # Show 25 contacts per page.
|
||||||
|
|||||||
@@ -597,6 +597,10 @@ def sms_queue_delete(request, q_id):
|
|||||||
if request.method == "POST":
|
if request.method == "POST":
|
||||||
if item.status in DONE_STATUSES:
|
if item.status in DONE_STATUSES:
|
||||||
return _notice(reverse("admin_panel:sms_queue"), "delete_done_blocked")
|
return _notice(reverse("admin_panel:sms_queue"), "delete_done_blocked")
|
||||||
|
# تصمیم محصولی: حذف توسط مدیر = «کنسل همین دوره». برای همین کلید یکتاسازی
|
||||||
|
# پیش از حذف در دفتر ثبت میشود؛ وگرنه اسکنر در اجرای بعدی همان پیامک را
|
||||||
|
# دوباره میساخت.
|
||||||
|
_remember_keys([item])
|
||||||
item.delete()
|
item.delete()
|
||||||
return _notice(reverse("admin_panel:sms_queue"), "deleted")
|
return _notice(reverse("admin_panel:sms_queue"), "deleted")
|
||||||
return redirect("admin_panel:sms_queue")
|
return redirect("admin_panel:sms_queue")
|
||||||
@@ -621,6 +625,7 @@ def sms_queue_bulk(request):
|
|||||||
# تاریخچهٔ ارسالشدهها از طریق صف حذف نمیشود.
|
# تاریخچهٔ ارسالشدهها از طریق صف حذف نمیشود.
|
||||||
selected = selected.exclude(status__in=DONE_STATUSES)
|
selected = selected.exclude(status__in=DONE_STATUSES)
|
||||||
count = selected.count()
|
count = selected.count()
|
||||||
|
_remember_keys(selected)
|
||||||
selected.delete()
|
selected.delete()
|
||||||
code, extra = "bulk_deleted", {"count": count}
|
code, extra = "bulk_deleted", {"count": count}
|
||||||
else:
|
else:
|
||||||
@@ -629,6 +634,19 @@ def sms_queue_bulk(request):
|
|||||||
return redirect("admin_panel:sms_queue")
|
return redirect("admin_panel:sms_queue")
|
||||||
|
|
||||||
|
|
||||||
|
def _remember_keys(rows):
|
||||||
|
"""کلیدهای یکتاسازیِ رکوردهای صف را پیش از حذف در دفتر ``SMSDedup`` ثبت میکند.
|
||||||
|
|
||||||
|
قرارداد: وقتی مدیر یادآوری را حذف میکند، یعنی «همین دوره کنسل شد»؛ وگرنه
|
||||||
|
اسکنر در اجرای بعدی همان پیامک را از نو میسازد و حذفِ مدیر بیاثر میشود.
|
||||||
|
"""
|
||||||
|
keys = [k for k in (row.dedup_key for row in rows) if k]
|
||||||
|
if keys:
|
||||||
|
SMSDedup.objects.bulk_create(
|
||||||
|
[SMSDedup(key=key) for key in keys], ignore_conflicts=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@login_required(login_url='/panel/p_login/')
|
@login_required(login_url='/panel/p_login/')
|
||||||
@_manage_required
|
@_manage_required
|
||||||
def sms_queue_empty(request):
|
def sms_queue_empty(request):
|
||||||
@@ -640,10 +658,7 @@ def sms_queue_empty(request):
|
|||||||
"""
|
"""
|
||||||
if request.method == "POST":
|
if request.method == "POST":
|
||||||
rows = SMSQueue.objects.exclude(status__in=DONE_STATUSES)
|
rows = SMSQueue.objects.exclude(status__in=DONE_STATUSES)
|
||||||
keys = [k for k in rows.values_list("dedup_key", flat=True) if k]
|
_remember_keys(rows)
|
||||||
SMSDedup.objects.bulk_create(
|
|
||||||
[SMSDedup(key=k) for k in keys], ignore_conflicts=True,
|
|
||||||
)
|
|
||||||
count = rows.count()
|
count = rows.count()
|
||||||
rows.delete()
|
rows.delete()
|
||||||
return _notice(reverse("admin_panel:sms_queue"), "queue_emptied", count=count)
|
return _notice(reverse("admin_panel:sms_queue"), "queue_emptied", count=count)
|
||||||
|
|||||||
@@ -0,0 +1,514 @@
|
|||||||
|
"""سیاست بازدید مشتری — تنها منبع حقیقت برای «آخرین بازدید واقعی».
|
||||||
|
|
||||||
|
هدف این ماژول پایاندادن به سه تعریف واگرا از «آخرین بازدید» است:
|
||||||
|
|
||||||
|
* ویوهای ``customerNewAction``/``customerEditAction`` که قبلاً مستقیماً
|
||||||
|
``last_bazdid_dt = finishDateTime`` مینوشتند (بدون بررسی ترتیب و بدون مبنا).
|
||||||
|
* صفحهٔ ``/panel/entezar/`` که فقط به ستون کششدهٔ ``Customer.last_bazdid_dt`` تکیه میکرد.
|
||||||
|
* اسکنر پیامک (``admin_panel/sms/scanner.py``) که همان ستون را بهعنوان لنگر میخواند.
|
||||||
|
|
||||||
|
قواعد ثابت این ماژول:
|
||||||
|
|
||||||
|
۱. فقط اقدام ``actionType == 'bazdid' AND status == 'finish'`` در محاسبه دیده میشود.
|
||||||
|
«هماهنگی» (``hamahangi``) و بقیهٔ انواع هرگز وارد
|
||||||
|
:func:`last_visit_dt` نمیشوند — نه امروز، نه در آینده. تعویق/هماهنگی مفهوم جداگانهای
|
||||||
|
است و فقط از :func:`active_deferrals` خوانده میشود.
|
||||||
|
۲. تاریخ مؤثر هر رکورد = ``actionDateTime`` **اگر از حالا جلوتر نباشد**؛ وگرنه
|
||||||
|
``finishDateTime`` اگر آن هم از حالا جلوتر نباشد؛ وگرنه «تاریخ نامعتبر».
|
||||||
|
قاعدهٔ قدیمی «اگر finish کوچکتر از action بود، finish را بگیر» عمداً حذف شده است:
|
||||||
|
در دادهٔ واقعی، آن حالت همان جایی است که ``finishDateTime`` برابر زمان ثبت رکورد
|
||||||
|
است و «زمان ثبت» را جای «تاریخ بازدید» میگذاشت.
|
||||||
|
۳. همهٔ مقایسهها با تاریخ aware انجام میشود؛ مقادیر naive موجود در دیتابیس
|
||||||
|
(بقایای کد قدیمی) با همان منطقهٔ زمانی پروژه aware میشوند.
|
||||||
|
"""
|
||||||
|
import logging
|
||||||
|
from datetime import date, datetime, time, timezone as dt_timezone
|
||||||
|
|
||||||
|
from dateutil import relativedelta
|
||||||
|
from django.core.exceptions import ValidationError
|
||||||
|
from django.db import models, transaction
|
||||||
|
from django.db.models import Case, DateTimeField, F, Max, Min, Value, When
|
||||||
|
from django.utils import timezone
|
||||||
|
|
||||||
|
from .models import Action, Customer, SMSTemplate
|
||||||
|
from .sms.timing import DEFAULT_VISIT_MONTHS
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# دلیل نوشتن ``last_bazdid_dt`` (برای لاگ؛ با ستونهای حسابرسی اختیاری همخوان است).
|
||||||
|
REASON_CREATE = "create"
|
||||||
|
REASON_EDIT = "edit"
|
||||||
|
REASON_CANCEL = "cancel"
|
||||||
|
REASON_RECALC = "recalc"
|
||||||
|
REASON_MANUAL = "manual"
|
||||||
|
|
||||||
|
# سقف مجاز «تاریخ آینده» برای بازدید انجامشده در اعتبارسنجی HTTP.
|
||||||
|
FINISH_MAX_FUTURE_DAYS = 30
|
||||||
|
|
||||||
|
# فقط این نوع از اقدامها بازدید هستند (ثابت مدل، نه رشتهٔ خام).
|
||||||
|
_VISIT_TYPE = Action.ActionType.BAZDID
|
||||||
|
_VISIT_DONE = Action.Status.FINISH
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# تبدیل تاریخ
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
def as_aware(value):
|
||||||
|
"""تاریخ/دیتاتایم naive یا ``date`` را به ``datetime`` aware تبدیل میکند.
|
||||||
|
|
||||||
|
برای مقادیر ``date``، «آغاز روز» استفاده میشود. در روزهای تغییر ساعت
|
||||||
|
(مثلاً ۲۲ مارس در تهران) نیمهشب ممکن است وجود نداشته باشد؛ در آن حالت
|
||||||
|
``is_dst=None`` خطای ``NonExistentTimeError`` میدهد، پس با ``is_dst=False``
|
||||||
|
ذخیره میشود تا هیچگاه یک تاریخِ درست، اجرای اسکن/گزارش را نشکند.
|
||||||
|
"""
|
||||||
|
if value is None:
|
||||||
|
return None
|
||||||
|
if isinstance(value, datetime):
|
||||||
|
if timezone.is_naive(value):
|
||||||
|
try:
|
||||||
|
return timezone.make_aware(value, timezone.get_current_timezone())
|
||||||
|
except Exception:
|
||||||
|
return timezone.make_aware(
|
||||||
|
value, timezone.get_current_timezone(), is_dst=False,
|
||||||
|
)
|
||||||
|
return value
|
||||||
|
if isinstance(value, date):
|
||||||
|
moment = datetime.combine(value, time.min)
|
||||||
|
try:
|
||||||
|
return timezone.make_aware(moment, timezone.get_current_timezone())
|
||||||
|
except Exception:
|
||||||
|
return timezone.make_aware(
|
||||||
|
moment, timezone.get_current_timezone(), is_dst=False,
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _now(now=None):
|
||||||
|
return as_aware(now) if now is not None else timezone.now()
|
||||||
|
|
||||||
|
|
||||||
|
def effective_visit_dt(action, *, now=None):
|
||||||
|
"""تاریخ مؤثر یک اقدام بازدید انجامشده (یا ``None`` اگر تاریخ معتبری ندارد).
|
||||||
|
|
||||||
|
قاعده در docstring ماژول آمده است. رکورد نامعتبر در محاسبه نادیده گرفته میشود
|
||||||
|
و مقدار موجود مشتری پاک نمیشود.
|
||||||
|
"""
|
||||||
|
if action is None:
|
||||||
|
return None
|
||||||
|
now = _now(now)
|
||||||
|
for value in (as_aware(action.actionDateTime), as_aware(action.finishDateTime)):
|
||||||
|
if value is not None and value <= now:
|
||||||
|
return value
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _visit_rows(customer_ids=None):
|
||||||
|
"""پیمایش سبک رکوردهای بازدید انجامشده (فقط فیلدهای لازم)."""
|
||||||
|
qs = Action.objects.filter(
|
||||||
|
actionType=_VISIT_TYPE, status=_VISIT_DONE,
|
||||||
|
).values_list("customer_id", "actionDateTime", "finishDateTime")
|
||||||
|
if customer_ids is not None:
|
||||||
|
qs = qs.filter(customer_id__in=list(customer_ids))
|
||||||
|
return qs.iterator()
|
||||||
|
|
||||||
|
|
||||||
|
def _last_visit_map_python(*, now=None, customer_ids=None):
|
||||||
|
"""نسخهٔ مرجع (پایتونی) برای تست همسانی و پشتیبانِ اضطراری.
|
||||||
|
|
||||||
|
این همان منطقی است که از ابتدا در ``last_visit_map`` بود؛ نسخهٔ SQL باید
|
||||||
|
دقیقاً همان خروجی را بدهد. در تستها بهعنوان «منبع مقایسه» استفاده میشود.
|
||||||
|
"""
|
||||||
|
now = _now(now)
|
||||||
|
result = {}
|
||||||
|
invalid = 0
|
||||||
|
for customer_id, action_dt, finish_dt in _visit_rows(customer_ids):
|
||||||
|
eff = None
|
||||||
|
for value in (as_aware(action_dt), as_aware(finish_dt)):
|
||||||
|
if value is not None and value <= now:
|
||||||
|
eff = value
|
||||||
|
break
|
||||||
|
if eff is None:
|
||||||
|
invalid += 1
|
||||||
|
continue
|
||||||
|
current = result.get(customer_id)
|
||||||
|
if current is None or eff > current:
|
||||||
|
result[customer_id] = eff
|
||||||
|
if invalid:
|
||||||
|
logger.warning(
|
||||||
|
"بازدید انجامشدهٔ بدون تاریخ معتبر نادیده گرفته شد: %s رکورد", invalid,
|
||||||
|
)
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def last_visit_map(*, now=None, customer_ids=None):
|
||||||
|
"""نگاشت ``customer_id → تاریخ آخرین بازدید معتبر`` — با تجمیع در دیتابیس.
|
||||||
|
|
||||||
|
قاعدهٔ تاریخ مؤثر همان قاعدهٔ قبلی است (هیچ تغییری در معنا):
|
||||||
|
|
||||||
|
* فقط ``bazdid + finish``.
|
||||||
|
* ``actionDateTime`` اگر خالی نباشد و از «حالا» جلوتر نباشد؛
|
||||||
|
* وگرنه ``finishDateTime`` اگر خالی نباشد و از «حالا» جلوتر نباشد؛
|
||||||
|
* وگرنه رکورد «تاریخ معتبر» ندارد و باید نادیده گرفته شود.
|
||||||
|
|
||||||
|
محاسبه در SQL انجام میشود (``CASE`` داخل ``MAX``) نه با حلقهٔ پایتونی روی
|
||||||
|
۴۵ هزار رکورد؛ خروجی همان دیکشنری قبلی است.
|
||||||
|
"""
|
||||||
|
now = _now(now)
|
||||||
|
# مقایسهٔ «حالا» باید در SQL انجام شود. نکتهٔ مهم: اگر یک datetime naive به ORM
|
||||||
|
# داده شود، Django آن را با منطقهٔ زمانی محلی دوباره adapt میکند (۳:۳۰ ساعت
|
||||||
|
# جابهجایی برای تهران)، و اگر رشته هم بدهیم آن را بهعنوان datetime تفسیر میکند.
|
||||||
|
# راه امن: مقدار را در پایتون به «naive UTC» تبدیل و سپس با ``Value`` تایپدار
|
||||||
|
# بهعنوان DateTimeField بفرستیم تا تبدیل دومی رخ ندهد.
|
||||||
|
now_utc = now.astimezone(dt_timezone.utc)
|
||||||
|
|
||||||
|
effective = Case(
|
||||||
|
# شاخهٔ اول: تاریخ بازدید (اگر وجود دارد و در آینده نیست).
|
||||||
|
When(actionDateTime__isnull=False, actionDateTime__lte=now_utc,
|
||||||
|
then=F("actionDateTime")),
|
||||||
|
# شاخهٔ دوم: تاریخ اتمام (اگر وجود دارد و در آینده نیست).
|
||||||
|
When(finishDateTime__isnull=False, finishDateTime__lte=now_utc,
|
||||||
|
then=F("finishDateTime")),
|
||||||
|
default=Value(None, output_field=DateTimeField()),
|
||||||
|
output_field=DateTimeField(),
|
||||||
|
)
|
||||||
|
|
||||||
|
qs = (
|
||||||
|
Action.objects.filter(actionType=_VISIT_TYPE, status=_VISIT_DONE)
|
||||||
|
.values("customer_id")
|
||||||
|
.annotate(latest=Max(effective))
|
||||||
|
)
|
||||||
|
if customer_ids is not None:
|
||||||
|
qs = qs.filter(customer_id__in=list(customer_ids))
|
||||||
|
|
||||||
|
result = {}
|
||||||
|
invalid = 0
|
||||||
|
for row in qs.iterator():
|
||||||
|
value = row["latest"]
|
||||||
|
if value is None:
|
||||||
|
invalid += 1
|
||||||
|
continue
|
||||||
|
result[row["customer_id"]] = as_aware(value)
|
||||||
|
if invalid:
|
||||||
|
logger.warning(
|
||||||
|
"بازدید انجامشدهٔ بدون تاریخ معتبر نادیده گرفته شد: %s مشتری", invalid,
|
||||||
|
)
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def last_visit_dt(customer, *, now=None):
|
||||||
|
"""تاریخ آخرین بازدید معتبر یک مشتری (یا ``None``)."""
|
||||||
|
if customer is None or customer.pk is None:
|
||||||
|
return None
|
||||||
|
return last_visit_map(now=now, customer_ids=[customer.pk]).get(customer.pk)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# لنگر چرخهٔ یادآوری (بازدید + نصب)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# تصمیم محصولی: «نصب» هم چرخهٔ ۹ ماههٔ یادآوری را از نو شروع میکند؛ ولی «تماس
|
||||||
|
# تلفنی» (inCall/outCall) و «هماهنگی» خیر. این لنگر **فقط** برای زمانبندی
|
||||||
|
# یادآوری است و هرگز جای ``last_bazdid_dt`` (که یعنی «آخرین بازدید») را نمیگیرد.
|
||||||
|
_CYCLE_RESET_TYPES = (Action.ActionType.BAZDID, Action.ActionType.NASB)
|
||||||
|
|
||||||
|
|
||||||
|
def cycle_anchor_map(*, now=None, customer_ids=None):
|
||||||
|
"""نگاشت ``customer_id → آخرین رخدادِ شروعدوبارهٔ چرخه`` (بازدید یا نصب).
|
||||||
|
|
||||||
|
قاعدهٔ تاریخ مؤثر همان قاعدهٔ بازدید است: ``actionDateTime`` اگر از حالا
|
||||||
|
جلوتر نباشد، وگرنه ``finishDateTime``؛ وگرنه رکورد بیاثر است.
|
||||||
|
"""
|
||||||
|
now = _now(now)
|
||||||
|
qs = Action.objects.filter(
|
||||||
|
actionType__in=_CYCLE_RESET_TYPES, status=_VISIT_DONE,
|
||||||
|
).values_list("customer_id", "actionDateTime", "finishDateTime")
|
||||||
|
if customer_ids is not None:
|
||||||
|
qs = qs.filter(customer_id__in=list(customer_ids))
|
||||||
|
result = {}
|
||||||
|
for customer_id, action_dt, finish_dt in qs.iterator():
|
||||||
|
eff = None
|
||||||
|
for value in (as_aware(action_dt), as_aware(finish_dt)):
|
||||||
|
if value is not None and value <= now:
|
||||||
|
eff = value
|
||||||
|
break
|
||||||
|
if eff is None:
|
||||||
|
continue
|
||||||
|
current = result.get(customer_id)
|
||||||
|
if current is None or eff > current:
|
||||||
|
result[customer_id] = eff
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def cycle_anchor_dt(customer, *, now=None):
|
||||||
|
"""لنگر چرخهٔ یادآوری یک مشتری (بازدید یا نصب؛ یا ``None``)."""
|
||||||
|
if customer is None or customer.pk is None:
|
||||||
|
return None
|
||||||
|
return cycle_anchor_map(now=now, customer_ids=[customer.pk]).get(customer.pk)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# نوشتن مقدار روی مشتری
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
def refresh_last_bazdid(customer, *, reason=REASON_EDIT, user=None, now=None):
|
||||||
|
"""``Customer.last_bazdid_dt`` را از منبع حقیقت بازمحاسبه میکند.
|
||||||
|
|
||||||
|
* فقط وقتی مقدار تغییر کند مینویسد (idempotent).
|
||||||
|
* اگر مشتری هیچ بازدید معتبری ندارد، مقدار فعلی **دستنخورده** میماند
|
||||||
|
(پاکسازی فقط با دستور ``recalc_last_bazdid --clear-orphans`` مجاز است).
|
||||||
|
* هرگز ``Action`` را ذخیره/تغییر نمیدهد و ``Customer.save()`` کامل صدا نمیزند
|
||||||
|
(تا فیلدهای ``auto_now`` و بقیهٔ ستونها دستنخورده بمانند).
|
||||||
|
"""
|
||||||
|
if customer is None or customer.pk is None:
|
||||||
|
return False
|
||||||
|
new_value = last_visit_dt(customer, now=now)
|
||||||
|
current = as_aware(customer.last_bazdid_dt)
|
||||||
|
if new_value is None:
|
||||||
|
logger.warning(
|
||||||
|
"مشتری %s بازدید معتبری ندارد؛ last_bazdid_dt دستنخورده ماند "
|
||||||
|
"(مقدار فعلی: %s)", customer.pk, current,
|
||||||
|
)
|
||||||
|
return False
|
||||||
|
if current == new_value:
|
||||||
|
return False
|
||||||
|
|
||||||
|
Customer.objects.filter(pk=customer.pk).update(last_bazdid_dt=new_value)
|
||||||
|
customer.last_bazdid_dt = new_value
|
||||||
|
logger.info(
|
||||||
|
"last_bazdid_dt مشتری %s از %s به %s تغییر کرد (دلیل=%s، کاربر=%s)",
|
||||||
|
customer.pk, current, new_value, reason,
|
||||||
|
getattr(user, "pk", None),
|
||||||
|
)
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# تعویق / قرار بعدی — هرگز در last_visit_dt دیده نمیشود
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# تصمیم محصولی: هر کدام از این دو، «قرار بعدی با مشتری» است و مشتری تا آن تاریخ
|
||||||
|
# از فهرست «منتظر بازدید» خارج میشود:
|
||||||
|
#
|
||||||
|
# ۱. اقدام **هماهنگی** که با مشتری توافق شده است — چه هنوز در انتظار باشد و چه
|
||||||
|
# همان لحظه «انجام شد» بسته شود (``finish``). تاریخ قرار = ``actionDateTime``.
|
||||||
|
# اگر هماهنگیِ فینیش تاریخ نداشته باشد، از ``finishDateTime`` استفاده میشود.
|
||||||
|
# ۲. اقدام **بازدید** که برای آینده برنامهریزی شده است (``waite`` با تاریخ آینده).
|
||||||
|
#
|
||||||
|
# «هماهنگی» بازدید نیست؛ پس هیچکدام از اینها ``last_bazdid_dt`` را تغییر نمیدهد.
|
||||||
|
|
||||||
|
|
||||||
|
def next_appointment_map(*, now=None):
|
||||||
|
"""نگاشت ``customer_id → زودترین «قرار بعدی» آینده`` — با تجمیع در دیتابیس.
|
||||||
|
|
||||||
|
منابع قرار (فقط آینده):
|
||||||
|
* ``hamahangi`` با وضعیت ``waite`` **یا** ``finish``،
|
||||||
|
* ``bazdid`` با وضعیت ``waite`` (بازدید برنامهریزیشده).
|
||||||
|
|
||||||
|
تاریخ قرار = ``actionDateTime`` و اگر خالی بود ``finishDateTime``.
|
||||||
|
فقط برای ``entezar`` و اسکنر پیامک استفاده میشود و هرگز در «آخرین بازدید»
|
||||||
|
دخالت نمیکند.
|
||||||
|
"""
|
||||||
|
now = _now(now)
|
||||||
|
# همان نکتهٔ ``last_visit_map``: ساعت مرجع باید aware بماند تا Django آن را
|
||||||
|
# دوباره با منطقهٔ محلی adapt نکند.
|
||||||
|
now_utc = now.astimezone(dt_timezone.utc)
|
||||||
|
|
||||||
|
# چرا دو کوئری جدا؟ با یک شرط ``OR`` روی نوع اقدام، بهینهساز SQLite از ایندکس
|
||||||
|
# ``act_visit_idx`` استفاده نمیکند و کوئری ~۱۹۳ms طول میکشد. با تقسیم به
|
||||||
|
# کوئریهای ساده، هر کدام دقیقاً با ایندکس (actionType + status) اجرا میشوند.
|
||||||
|
# (از ``union()`` استفاده نمیکنیم چون در این ترکیب، ردیفها را از دست میدهد.)
|
||||||
|
hamahangi = Action.objects.filter(
|
||||||
|
actionType=Action.ActionType.HAMAHANGI,
|
||||||
|
status__in=(Action.Status.WAITE, Action.Status.FINISH),
|
||||||
|
).filter(
|
||||||
|
models.Q(actionDateTime__gt=now_utc)
|
||||||
|
| models.Q(actionDateTime__isnull=True, finishDateTime__gt=now_utc)
|
||||||
|
).values_list("customer_id", "actionDateTime", "finishDateTime")
|
||||||
|
|
||||||
|
planned_visit = Action.objects.filter(
|
||||||
|
actionType=Action.ActionType.BAZDID,
|
||||||
|
status=Action.Status.WAITE,
|
||||||
|
actionDateTime__gt=now_utc,
|
||||||
|
).values_list("customer_id", "actionDateTime")
|
||||||
|
|
||||||
|
combined = {}
|
||||||
|
|
||||||
|
def _remember(customer_id, when):
|
||||||
|
when = as_aware(when)
|
||||||
|
if when is None:
|
||||||
|
return
|
||||||
|
current = combined.get(customer_id)
|
||||||
|
if current is None or when < current:
|
||||||
|
combined[customer_id] = when
|
||||||
|
|
||||||
|
for customer_id, action_dt, finish_dt in hamahangi.iterator():
|
||||||
|
# تاریخ قرار: تاریخ اقدام؛ و برای هماهنگیِ بدون تاریخ اقدام، تاریخ اتمام.
|
||||||
|
_remember(customer_id, action_dt or finish_dt)
|
||||||
|
|
||||||
|
for customer_id, action_dt in planned_visit.iterator():
|
||||||
|
_remember(customer_id, action_dt)
|
||||||
|
|
||||||
|
return combined
|
||||||
|
|
||||||
|
|
||||||
|
def next_appointment(customer, *, now=None):
|
||||||
|
"""زودترین قرار بعدی یک مشتری (یا ``None``)."""
|
||||||
|
if customer is None or customer.pk is None:
|
||||||
|
return None
|
||||||
|
return next_appointment_map(now=now).get(customer.pk)
|
||||||
|
|
||||||
|
|
||||||
|
# نام قدیمی برای سازگاری با فراخوانیهای موجود.
|
||||||
|
def active_deferrals(*, now=None):
|
||||||
|
"""نام قدیمیِ :func:`next_appointment_map` (سازگاری عقبرو)."""
|
||||||
|
return next_appointment_map(now=now)
|
||||||
|
|
||||||
|
|
||||||
|
def active_deferral(customer, *, now=None):
|
||||||
|
"""نام قدیمیِ :func:`next_appointment` (سازگاری عقبرو)."""
|
||||||
|
return next_appointment(customer, now=now)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# دورهٔ بازدید (منبع واحد)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
def visit_period_months():
|
||||||
|
"""تعداد ماهِ دورهٔ بازدید از قالب پیامک (قابل تنظیم از پنل)، وگرنه پیشفرض."""
|
||||||
|
value = (
|
||||||
|
SMSTemplate.objects.filter(sms_type="visit")
|
||||||
|
.values_list("repeat_interval_months", flat=True)
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
return int(value or DEFAULT_VISIT_MONTHS)
|
||||||
|
|
||||||
|
|
||||||
|
def visit_period(months=None):
|
||||||
|
"""دورهٔ بازدید بهصورت بازهٔ تقویمی (ماه واقعی، نه ۴٫۳ هفته)."""
|
||||||
|
months = int(months or visit_period_months())
|
||||||
|
return relativedelta.relativedelta(months=max(months, 1))
|
||||||
|
|
||||||
|
|
||||||
|
def visit_period_days(months=None):
|
||||||
|
"""طول تقریبی دوره به روز (فقط برای گزارش/نمایش؛ مبنای چرخه نیست)."""
|
||||||
|
return max(round(int(months or visit_period_months()) * 365 / 12), 1)
|
||||||
|
|
||||||
|
|
||||||
|
def visit_due_at(*, now=None, months=None):
|
||||||
|
"""مقطعی که مشتریِ بدون بازدید از آن پیشتر «عقبافتاده» حساب میشود."""
|
||||||
|
return _now(now) - visit_period(months)
|
||||||
|
|
||||||
|
|
||||||
|
def visit_due_date(anchor, *, months=None):
|
||||||
|
"""تاریخ موعد (تقویمی) برای یک لنگر: ``anchor + N ماه``.
|
||||||
|
|
||||||
|
تنها مرجع محاسبهٔ موعد، هم در صفحهٔ «انتظار» و هم در اسکنر پیامک.
|
||||||
|
"""
|
||||||
|
anchor = as_aware(anchor)
|
||||||
|
if anchor is None:
|
||||||
|
return None
|
||||||
|
return anchor + visit_period(months)
|
||||||
|
|
||||||
|
|
||||||
|
def visit_cycle_index(anchor, at, *, months=None):
|
||||||
|
"""شمارهٔ چرخهٔ یادآوریِ ``at`` نسبت به لنگر، بر مبنای **ماه تقویمی**.
|
||||||
|
|
||||||
|
چرخهٔ ۱ یعنی «یک دورهٔ کامل گذشته و یادآوری اول واجد شرط شده است».
|
||||||
|
|
||||||
|
چرا ماه تقویمی و نه تقسیم روز؟ در نسخهٔ قبلی شمارهٔ چرخه با
|
||||||
|
``days // 274`` حساب میشد و با موعدِ تقویمی (``relativedelta(months=9)``)
|
||||||
|
بر یک شبکه نمینشست؛ نتیجهاش *گمشدنِ* بعضی دورهها (کلید تکراری) و
|
||||||
|
*پریدن* بعضی دیگر بود. حالا هر دو از یک قاعده میآیند:
|
||||||
|
«تعداد ماهِ کاملِ سپریشده از لنگر ÷ طول دوره».
|
||||||
|
"""
|
||||||
|
anchor = as_aware(anchor)
|
||||||
|
at = as_aware(at)
|
||||||
|
if anchor is None or at is None or at < anchor:
|
||||||
|
return 0
|
||||||
|
period_months = max(int(months or visit_period_months()), 1)
|
||||||
|
months_elapsed = (at.year - anchor.year) * 12 + (at.month - anchor.month)
|
||||||
|
period = relativedelta.relativedelta(months=period_months)
|
||||||
|
|
||||||
|
def due_date(steps):
|
||||||
|
"""تاریخِ موعدِ ``steps`` دوره بعد، با جمعِ **پلهای**.
|
||||||
|
|
||||||
|
چرا ضرب نکنیم؟ ``relativedelta * n`` انتهای ماه را تجمعی میبندد
|
||||||
|
(۳۱ ژانویه → ۳۰ آوریل → ۳۰ جولای) در حالی که موعد واقعی ۳۱ جولای است.
|
||||||
|
جمعِ پلهای همان چیزی است که در عمل هم رخ میدهد: «یک دوره، بعد یک دورهٔ دیگر».
|
||||||
|
|
||||||
|
مقایسه در سطح **تاریخ** انجام میشود (نه لحظه)، تا ساعتِ اجرای اسکن
|
||||||
|
(مثلاً ۱۲:۰۰ همان روز موعد) تأثیری در شمارش نداشته باشد.
|
||||||
|
"""
|
||||||
|
value = anchor.date()
|
||||||
|
for _ in range(steps):
|
||||||
|
value = (as_aware(value) + period).date()
|
||||||
|
return value
|
||||||
|
|
||||||
|
today = at.date()
|
||||||
|
# بزرگترین kای که موعدش رسیده است.
|
||||||
|
k = max(months_elapsed // period_months, 0)
|
||||||
|
while k > 0 and due_date(k) > today:
|
||||||
|
k -= 1
|
||||||
|
while due_date(k + 1) <= today:
|
||||||
|
k += 1
|
||||||
|
return k
|
||||||
|
|
||||||
|
|
||||||
|
def visit_latest_due(anchor, *, now, months=None):
|
||||||
|
"""تازهترین موعدی که تا ``now`` رسیده است (یا ``None``).
|
||||||
|
|
||||||
|
مشتریای که ۲۵ ماه بازدید نشده باید موعد ۱۸ ماهگی را بگیرد، نه موعد ۹ ماهگیِ
|
||||||
|
قدیمی. قاعدهٔ قبلی هر بار ``anchor + یک دوره`` را میسنجید؛ چون آن موعد همیشه
|
||||||
|
«رسیده» بود و کلیدش هم ثابت، دورههای بعدی هرگز بررسی نمیشدند.
|
||||||
|
"""
|
||||||
|
anchor = as_aware(anchor)
|
||||||
|
now = as_aware(now)
|
||||||
|
if anchor is None or now is None:
|
||||||
|
return None
|
||||||
|
current = visit_cycle_index(anchor, now, months=months)
|
||||||
|
if current < 1:
|
||||||
|
return None
|
||||||
|
period = visit_period(months)
|
||||||
|
# جمع پلهای روی **تاریخ** (نه روی دیتاتایم aware): با تغییر ساعت، جمع ساعتدار
|
||||||
|
# ممکن است به ۲۳:۰۰ روز قبل بغلتد و موعد یک روز زودتر حساب شود.
|
||||||
|
due = anchor.date()
|
||||||
|
for _ in range(current):
|
||||||
|
due = (as_aware(due) + period).date()
|
||||||
|
return as_aware(due)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# اعتبارسنجی لایهٔ HTTP
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
def validate_action_payload(payload, *, now=None):
|
||||||
|
"""اعتبارسنجی ورودی فرم اقدام پیش از هر نوشتنی.
|
||||||
|
|
||||||
|
``payload`` نگاشتی با کلیدهای ``actionType``، ``status``، ``actionDateTime``
|
||||||
|
و ``finishDateTime`` است (مقادیر میتوانند ``None`` یا ``datetime aware`` باشند).
|
||||||
|
|
||||||
|
قواعد:
|
||||||
|
* نوع اقدام باید یکی از شش مقدار مدل باشد.
|
||||||
|
* وضعیت باید یکی از سه مقدار مدل باشد.
|
||||||
|
* برای «بازدید انجامشده»: تاریخ بازدید نباید بیش از ۳۰ روز در آینده باشد.
|
||||||
|
* برای هماهنگی و بقیهٔ انواع: هیچ محدودیت تاریخ آیندهای اعمال نمیشود.
|
||||||
|
"""
|
||||||
|
now = _now(now)
|
||||||
|
action_type = payload.get("actionType")
|
||||||
|
status = payload.get("status")
|
||||||
|
|
||||||
|
if action_type not in Action.ActionType.values:
|
||||||
|
raise ValidationError("نوع اقدام نامعتبر است.")
|
||||||
|
if status not in Action.Status.values:
|
||||||
|
raise ValidationError("وضعیت اقدام نامعتبر است.")
|
||||||
|
|
||||||
|
if action_type == _VISIT_TYPE and status == _VISIT_DONE:
|
||||||
|
action_dt = as_aware(payload.get("actionDateTime"))
|
||||||
|
if action_dt is not None and action_dt > now + relativedelta.relativedelta(
|
||||||
|
days=FINISH_MAX_FUTURE_DAYS
|
||||||
|
):
|
||||||
|
raise ValidationError(
|
||||||
|
"تاریخ بازدید نمیتواند بیش از ۳۰ روز در آینده باشد."
|
||||||
|
)
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_finish_datetime(value, *, now=None):
|
||||||
|
"""تاریخ اتمام: اگر خالی بود، زمان ثبت قرار میگیرد (نه ``None``)."""
|
||||||
|
value = as_aware(value)
|
||||||
|
return value if value is not None else _now(now)
|
||||||
@@ -2,25 +2,25 @@
|
|||||||
"0": "views/sms.py",
|
"0": "views/sms.py",
|
||||||
"1": "run_all",
|
"1": "run_all",
|
||||||
"2": "django_db",
|
"2": "django_db",
|
||||||
"3": "create_transfer_request",
|
"3": "views/store.py",
|
||||||
"4": "ModelsPackageContractTests",
|
"4": "ModelsPackageContractTests",
|
||||||
"5": "PhoneCleanupTests",
|
"5": "PhoneCleanupTests",
|
||||||
"6": ".load",
|
"6": ".load",
|
||||||
"7": "models/sms.py",
|
"7": "models/sms.py",
|
||||||
"8": "models/__init__.py",
|
"8": "django_db_models",
|
||||||
"9": "views/customers.py",
|
"9": "views/customers.py",
|
||||||
"10": "django_conf",
|
"10": "django_db_models_deletion",
|
||||||
"11": "make_customer",
|
"11": "PanelTestCase",
|
||||||
"12": "resolve_recipients",
|
"12": "make_customer",
|
||||||
"13": "EventTriggerTests",
|
"13": "EventTriggerTests",
|
||||||
"14": "views/finance.py",
|
"14": "views/finance.py",
|
||||||
"15": "views/store.py",
|
"15": "django_contrib",
|
||||||
"16": "sms0098ws.py",
|
"16": "sms0098ws.py",
|
||||||
"17": "Admin Panel Customization",
|
"17": "Admin Panel Customization",
|
||||||
"18": "User",
|
"18": "User",
|
||||||
"19": "timing_for",
|
"19": "apply_timing",
|
||||||
"20": "Customer",
|
"20": "InactiveCustomerHiddenTests",
|
||||||
"21": "phones.py",
|
"21": "normalize_mobile",
|
||||||
"22": "Login Template Forms",
|
"22": "Login Template Forms",
|
||||||
"23": "cheque_inquiry.py",
|
"23": "cheque_inquiry.py",
|
||||||
"24": "factories.py",
|
"24": "factories.py",
|
||||||
@@ -30,10 +30,10 @@
|
|||||||
"28": "make_user",
|
"28": "make_user",
|
||||||
"29": "QueueReviewTests",
|
"29": "QueueReviewTests",
|
||||||
"30": "NoticeMechanismTests",
|
"30": "NoticeMechanismTests",
|
||||||
"31": "SmsReportTests",
|
"31": "name_fix.py",
|
||||||
"32": "scanner.py",
|
"32": "enqueue_sms",
|
||||||
"33": "ZeroZeroNinetyEightWebServiceTests",
|
"33": "ZeroZeroNinetyEightWebServiceTests",
|
||||||
"34": "ProductInStore",
|
"34": "ScannerTests",
|
||||||
"35": "admin_panel/templates/admin_panel/baseHesabdari.html",
|
"35": "admin_panel/templates/admin_panel/baseHesabdari.html",
|
||||||
"36": "Reference guide",
|
"36": "Reference guide",
|
||||||
"37": "Factor Creation UI",
|
"37": "Factor Creation UI",
|
||||||
@@ -44,19 +44,19 @@
|
|||||||
"42": "SMS Integration Docs",
|
"42": "SMS Integration Docs",
|
||||||
"43": "enqueue.py",
|
"43": "enqueue.py",
|
||||||
"44": "django_shortcuts",
|
"44": "django_shortcuts",
|
||||||
"45": "stock_in",
|
"45": "test_inventory.py",
|
||||||
"46": "admin_panel/templates/admin_panel/customerAction.html",
|
"46": "admin_panel/templates/admin_panel/customerAction.html",
|
||||||
"47": "parts_count",
|
"47": "SmsReportTests",
|
||||||
"48": "write_tree",
|
"48": "write_tree",
|
||||||
"49": "CustomerInfoCardUITests",
|
"49": "CustomerInfoCardUITests",
|
||||||
"50": "test_sms.py",
|
"50": "TestCase",
|
||||||
"51": "Phone Cleanup Guide",
|
"51": "Phone Cleanup Guide",
|
||||||
"52": ".run_command",
|
"52": "Customer",
|
||||||
"53": "project_tree.py",
|
"53": "project_tree.py",
|
||||||
"54": "Agent Operating Guidelines",
|
"54": "Agent Operating Guidelines",
|
||||||
"55": "Command",
|
"55": "Command",
|
||||||
"56": "BehinSMSProvider",
|
"56": "BehinSMSProvider",
|
||||||
"57": "normalize_mobile",
|
"57": "activate_sms",
|
||||||
"58": "Financial Data Objects",
|
"58": "Financial Data Objects",
|
||||||
"59": "Hybrid Migration Command",
|
"59": "Hybrid Migration Command",
|
||||||
"60": ".run_cmd",
|
"60": ".run_cmd",
|
||||||
@@ -65,54 +65,54 @@
|
|||||||
"63": "admin_panel/templates/admin_panel/bedehi.html",
|
"63": "admin_panel/templates/admin_panel/bedehi.html",
|
||||||
"64": "customerIDFactorBackup.html",
|
"64": "customerIDFactorBackup.html",
|
||||||
"65": "bale/providers.py",
|
"65": "bale/providers.py",
|
||||||
"66": "asgi.py",
|
"66": "os",
|
||||||
"67": "Graphify Tooling Docs",
|
"67": "Graphify Tooling Docs",
|
||||||
"68": "Command",
|
"68": "Command",
|
||||||
"69": "ZeroZeroNinetyEightLinkTests",
|
"69": "ZeroZeroNinetyEightLinkTests",
|
||||||
"70": "SMS Template Seeding",
|
"70": "SMS Template Seeding",
|
||||||
"71": "admin_panel/entezar.html",
|
"71": "admin_panel/entezar.html",
|
||||||
"72": "customerNewBackup.html",
|
"72": "customerNewBackup.html",
|
||||||
"73": "SMSResult",
|
"73": "django_conf",
|
||||||
"74": "0070_sms_types_seven.py",
|
"74": "0070_sms_types_seven.py",
|
||||||
"75": "Endpoint Smoke Tests",
|
"75": "Endpoint Smoke Tests",
|
||||||
"76": "Timezone Migration History",
|
"76": "Timezone Migration History",
|
||||||
"77": "Command",
|
"77": "Command",
|
||||||
"78": "django_urls",
|
"78": "UrlContractTests",
|
||||||
"79": "Brand Scrubbing Migration",
|
"79": "Brand Scrubbing Migration",
|
||||||
"80": "SMS Dedup Migration",
|
"80": "SMS Dedup Migration",
|
||||||
"81": "Installation Guide",
|
"81": "Installation Guide",
|
||||||
"82": "Project Honesty Contract",
|
"82": "Project Honesty Contract",
|
||||||
"83": "Flow Control Migration",
|
"83": "Flow Control Migration",
|
||||||
"84": "Permission Label Migration",
|
"84": "Permission Label Migration",
|
||||||
"85": "IntegrationAndEfficiencyTests",
|
"85": "PhoneNumber",
|
||||||
"86": "Output Format Specs",
|
"86": "Output Format Specs",
|
||||||
"87": "Performance Benchmarks",
|
"87": "Performance Benchmarks",
|
||||||
"88": "SMS Integration Overview",
|
"88": "SMS Integration Overview",
|
||||||
"89": "CustomerNameFixJob",
|
"89": "CustomerNameFixJob",
|
||||||
"90": "Customer Modal UI",
|
"90": "Customer Modal UI",
|
||||||
"91": "PanelTestCase",
|
"91": "decide_prefix",
|
||||||
"92": "Infrastructure Services",
|
"92": "Infrastructure Services",
|
||||||
"93": "Agent Setup Guide",
|
"93": "Agent Setup Guide",
|
||||||
"94": "First Use Guide",
|
"94": "First Use Guide",
|
||||||
"95": "ConsoleSMSProvider",
|
"95": "ConsoleEncodingTests",
|
||||||
"96": "PhoneNumber",
|
"96": "test_factors.py",
|
||||||
"97": "تجمیع شمارههای پیامک مشتریان",
|
"97": "تجمیع شمارههای پیامک مشتریان",
|
||||||
"98": "Command",
|
"98": "Command",
|
||||||
"99": "Base Template",
|
"99": "Base Template",
|
||||||
"100": "test_customer_name_fix.py",
|
"100": "test_sms.py",
|
||||||
"101": "Command",
|
"101": "Command",
|
||||||
"102": "customer_phone_entries",
|
"102": "WorkerTests",
|
||||||
"103": "manage.py",
|
"103": "manage.py",
|
||||||
"104": "افزودن پیشوند «آقای/خانم» به نام مشتریان",
|
"104": "افزودن پیشوند «آقای/خانم» به نام مشتریان",
|
||||||
"105": "بازمحاسبهٔ «آخرین بازدید» مشتریان",
|
"105": "Command",
|
||||||
"106": "QueueStatsTests",
|
"106": "QueueStatsTests",
|
||||||
"107": "admin_panel/apps.py",
|
"107": "signals.py",
|
||||||
"108": "SMS Domain Logic",
|
"108": "SMS Domain Logic",
|
||||||
"109": "Customer Communication Partials",
|
"109": "Customer Communication Partials",
|
||||||
"110": "SMS Template Management",
|
"110": "SMS Template Management",
|
||||||
"111": "Documentation and Graphing",
|
"111": "Documentation and Graphing",
|
||||||
"112": "Admin Migration Package",
|
"112": "Admin Migration Package",
|
||||||
"113": "primary_phones",
|
"113": "recipients.py",
|
||||||
"114": "Customer Action Templates",
|
"114": "Customer Action Templates",
|
||||||
"115": "Customer Feedback Components",
|
"115": "Customer Feedback Components",
|
||||||
"116": "Customer Invoice Details",
|
"116": "Customer Invoice Details",
|
||||||
@@ -163,24 +163,47 @@
|
|||||||
"161": "Project Dependencies",
|
"161": "Project Dependencies",
|
||||||
"162": "Test Suite Package",
|
"162": "Test Suite Package",
|
||||||
"163": "Customer",
|
"163": "Customer",
|
||||||
"164": "SMSQueue",
|
"164": "factory.py",
|
||||||
"165": "signals.py",
|
"165": "0075_cheque_funding_before_due.py",
|
||||||
"166": "توقف و ادامه (Resume / Checkpoint)",
|
"166": "توقف و ادامه (Resume / Checkpoint)",
|
||||||
"167": "Command",
|
"167": "Command",
|
||||||
"168": "0071_sms_settings_permission.py",
|
"168": "0071_sms_settings_permission.py",
|
||||||
"169": "index",
|
"169": "0056_auto_20260731_1824.py",
|
||||||
"170": "الگوریتم تصمیم (به همین ترتیب)",
|
"170": "الگوریتم تصمیم (به همین ترتیب)",
|
||||||
"171": "سریعترین مسیر روی Production (خلاصهٔ اجرایی)",
|
"171": "سریعترین مسیر روی Production (خلاصهٔ اجرایی)",
|
||||||
"172": "models/customers.py",
|
"172": "0073_alter_action_actiontype_choices.py",
|
||||||
"173": "stop.sh",
|
"173": "stop.sh",
|
||||||
"174": "SMSDedup",
|
"174": "0074_alter_action_status_choices.py",
|
||||||
"175": "rules.py",
|
"175": "remove_query_param",
|
||||||
"176": "اجرای محلی (توسعه)",
|
"176": "اجرای محلی (توسعه)",
|
||||||
"177": "Factor",
|
"177": ".test_deleted_visit_reminder_rearms_next_period_only",
|
||||||
"178": "اجرا روی سرور Production",
|
"178": "اجرا روی سرور Production",
|
||||||
"179": "اجرای محلی (توسعه)",
|
"179": "اجرای محلی (توسعه)",
|
||||||
"180": "ادامهٔ job پس از crash / قطع برق",
|
"180": "ادامهٔ job پس از crash / قطع برق",
|
||||||
"181": "اجرا روی سرور Production",
|
"181": "اجرا روی سرور Production",
|
||||||
"182": "wsgi.py",
|
"182": "wsgi.py",
|
||||||
"189": "django_contrib_auth_models"
|
"183": "0005_auto_20220121_2054.py",
|
||||||
|
"184": "0009_remove_storeroom_user.py",
|
||||||
|
"185": "0014_auto_20220123_1348.py",
|
||||||
|
"186": "0015_auto_20220124_0858.py",
|
||||||
|
"187": "0017_factoriteam_garantyesh.py",
|
||||||
|
"188": "0022_article_typepy.py",
|
||||||
|
"189": "0026_auto_20220209_1924.py",
|
||||||
|
"190": "0028_action_grade.py",
|
||||||
|
"191": "0032_article_paystatus.py",
|
||||||
|
"192": "0033_article_nahvepardakht.py",
|
||||||
|
"193": "0034_alter_article_sarresid.py",
|
||||||
|
"194": "0035_alter_article_sarresid.py",
|
||||||
|
"195": "0040_customer_laghv_bazdid.py",
|
||||||
|
"196": "0041_auto_20240216_1607.py",
|
||||||
|
"197": "0048_alter_productinstore_options.py",
|
||||||
|
"198": "0053_storeroom_owners.py",
|
||||||
|
"199": "0055_action_nextnazardt.py",
|
||||||
|
"200": "0062_sms_link_provider.py",
|
||||||
|
"201": "0063_alter_smsqueue_status.py",
|
||||||
|
"202": "0068_auto_20261010_1305.py",
|
||||||
|
"203": "0072_customernamefixjob.py",
|
||||||
|
"204": "0077_alter_smstemplate_text_balance_help.py",
|
||||||
|
"205": ".test_visit_period_comes_from_template",
|
||||||
|
"206": ".test_period_start_filters_out_very_old_customers"
|
||||||
}
|
}
|
||||||
@@ -1 +1 @@
|
|||||||
{"0": "038518c7d9194bab", "1": "06dae62b458fc666", "2": "f61ab1111a89fc50", "3": "e61c3927bdba862c", "4": "11012e19786f67ca", "5": "22b97ba625f8378b", "6": "65060bb181af0bf2", "7": "3ce7acc47ea68c9f", "8": "fabc1e3307709f1a", "9": "123d258529fa9882", "10": "237c411f20dd22bb", "11": "fee454d62d24fd21", "12": "a62dcf8197187c85", "13": "96ec15fbcfe2d27a", "14": "9547a4a3dbac41ac", "15": "499026e81d8e6a05", "16": "1b80b390c6d35082", "17": "9eec9953ee3ca67f", "18": "f04ceeae6fdfe7c6", "19": "781d0380d9314204", "20": "895beea340408433", "21": "1e32348234be9dd4", "22": "9701f1591d8fda6e", "23": "8f0f51ff611eb425", "24": "102a0c450b7cb6b3", "25": "75866dc8db907971", "26": "b6a24d75ed17d59c", "27": "9b6b55a8d8dcb4c4", "28": "1f2e16b5709d3936", "29": "1e9c1fa6fabde0d3", "30": "627dae19704f40f5", "31": "11d769da5f6dd93e", "32": "9f3159f2dae4ab4f", "33": "8794530c7ccb06cb", "34": "4864a4651499f51b", "35": "0928f3aa4e597117", "36": "4e9567703b727de7", "37": "d36df3c85181f806", "38": "bc695fcee2050806", "39": "3557c20b0ad29e99", "40": "89444f5d51189c99", "41": "8c2e4ed6cd161bf3", "42": "bbd8a9066e48dada", "43": "6378ca56250cfe26", "44": "00d4d9fc79267971", "45": "a7e9b25451da94c4", "46": "f38c1f1dc0ead50f", "47": "8fd5a6f3cd5970b9", "48": "9d46bbe4641a8543", "49": "7bc001888a685a2a", "50": "4c34de7757ef1e08", "51": "afc6d855b9fc1b76", "52": "e9a7c031e64eeadb", "53": "afa933e731f6344a", "54": "34401beb9f60ebfd", "55": "05213eed8eed2d6f", "56": "330644cc56a0e06c", "57": "20956521d16ce674", "58": "90146abe85d9419f", "59": "88f880da5a61f9dc", "60": "f723d0b8f5c4997b", "61": "dbfbf8557361f5ea", "62": "6e90b41fbd82c265", "63": "6af26e59b0e8c354", "64": "8ae1cb991df55ba5", "65": "7b932cfef527d130", "66": "e0d0013fbf063aa4", "67": "0f632fc57e9feeb0", "68": "92d7e4efe25770db", "69": "7650c66736fc59da", "70": "df7ea8ce3fa52be3", "71": "fd47ef3914adf11e", "72": "9b3249f4a4e6fbfe", "73": "e00f9e12dd9fb95f", "74": "a534e43d03671a7c", "75": "5290f7a7558a9c3e", "76": "a19c211702401a5b", "77": "35aa0fc2a0626f70", "78": "3100956d3076c59f", "79": "da683f642f8c61ac", "80": "40313433c93b7054", "81": "81af0a28b68936d9", "82": "25287261c3d1c44b", "83": "bda9be183e0f933e", "84": "63526d01888c4f22", "85": "e5577b2d90500462", "86": "694a38d63caf7217", "87": "f220eaa4fac26e3e", "88": "d78c8e21a45613e9", "89": "8cb58c63b0d606b1", "90": "03f8896e9b60ca96", "91": "50363e32255931f4", "92": "4bfa64a1cd8c6a47", "93": "057eae5a523d2004", "94": "5de74655cbc56e06", "95": "b8ea4a512b424113", "96": "e0d3010c0e1a9d7a", "97": "de6816239e70e6e8", "98": "7508d3e84c588617", "99": "15f6a40009e539bb", "100": "ed5eb60fa9b1adb6", "101": "4b7f81f0e7e4c044", "102": "24252b994a79d741", "103": "85ecb40ed558ed43", "104": "756a405f723988ce", "105": "e618d1b582eac9a5", "106": "825ba1cf2cfa5500", "107": "6c05d0c0a89d1816", "108": "b738c5aefe939cff", "109": "d2001d7d283134ac", "110": "6b8e6100893a6b6a", "111": "66ed76491a412a72", "112": "e67668b62ee6c62c", "113": "800f5b95a2eac8d1", "114": "68aee39cd24db354", "115": "054401d27ffaa4b2", "116": "dace81c1995fb321", "117": "19e301aeace95bb2", "118": "d6fd0cec4634a0b0", "119": "0230d355a6dabb54", "120": "5cebbb5528321795", "121": "536fe28d24c361f5", "122": "b08bdc2712cb0f12", "123": "6ce8444f504022cc", "124": "8021f52559633383", "125": "d8e34a3b3eb43ee0", "126": "171c34c74a95481b", "127": "73e9eca6f2ca1c9b", "128": "dcc2230ae751c29a", "129": "2a617b932f5d92b2", "130": "d55cb638532fdfeb", "131": "06cae4389208269f", "132": "e9c0a7be255e9b26", "133": "455b4ca39e55ecb0", "134": "8105c55538c34081", "135": "74304670b54c018f", "136": "a27f876eabb044b2", "137": "73f74dd7a40776d6", "138": "29d433d164b99f89", "139": "3f44aa48aa05d597", "140": "ca8a7efba0a3e579", "141": "5cc770af9d184d77", "142": "2d31fa723128b164", "143": "be720fc0071f0c3d", "144": "9d814c2520329846", "145": "f97d1a5c3a066598", "146": "14f84a6e4cd311d6", "147": "3a592d27b1f4ccc9", "148": "0bf6f9d0aeab4fd8", "149": "8b491bb3ff0649dd", "150": "638812cee15d1b9b", "151": "3dd16d68928a479c", "152": "03a0ee43e18f7934", "153": "60e9520e0a9facae", "154": "435331945d6aad17", "155": "968e1a135dc062b7", "156": "d1a987a548e55a76", "157": "a9bf69b3d550abef", "158": "b039e1c69c8fb7b9", "159": "60029bf0948fc02a", "160": "72dad91d66d471ce", "161": "353a9d09eaae47ea", "162": "43bbf35b96423d46", "163": "742c12886e57d4fa", "164": "6b4bf464232edaa0", "165": "d0c2310f7d93caf6", "166": "bca6f6760a942d10", "167": "ce80aac5f4c439da", "168": "1abe2e7b0bcbeca9", "169": "ce5f237abed01c9e", "170": "c9f25e20d3a7d5d8", "171": "7e818e8715c414bd", "172": "22f3057d405a4f06", "173": "827420b645a55301", "174": "adfc2fda7e4fc28a", "175": "59ba9ce54759cbf2", "176": "adac855b05ea55ee", "177": "6ba88c862239632d", "178": "c67bd11d1973d6ac", "179": "63c999649e523e73", "180": "56f7f3573c34f651", "181": "f8f5346df7d0a239", "182": "9c5614d4fee3efc0", "189": "36b8b06281e0571e"}
|
{"0": "1dfdd2cf317807f3", "1": "17cefe4501ef5349", "2": "b498606dcac72b73", "3": "bd6e67d4a5f28a70", "4": "28cdf3baa198be4d", "5": "fc9b619d52672e4e", "6": "50f8029c1ec04918", "7": "97d81e46229f8a49", "8": "313943d53fc2bd9f", "9": "627a6196750653eb", "10": "b61d115e59860e03", "11": "9e67a33b989286ec", "12": "e88164ad40862751", "13": "3b56f304a0c6c939", "14": "6dce744bd0131ce7", "15": "e41d0c7172d175f1", "16": "7d8afbd7fea7cf7b", "17": "9eec9953ee3ca67f", "18": "d947d20b351370eb", "19": "d26d5b0aeca2417c", "20": "a8997e767700eb26", "21": "80296933c90021fc", "22": "9701f1591d8fda6e", "23": "8f0f51ff611eb425", "24": "2fdec3396c6901a3", "25": "75866dc8db907971", "26": "bcc559e489b72153", "27": "9b6b55a8d8dcb4c4", "28": "35722e3c74f50804", "29": "e0f6e325bac28363", "30": "bf1e337abf82f763", "31": "e4282689f6251d51", "32": "e5bd8cd35998e36a", "33": "3746e81c1256465f", "34": "817f04c7b50c9b76", "35": "0928f3aa4e597117", "36": "4e9567703b727de7", "37": "d36df3c85181f806", "38": "bc695fcee2050806", "39": "7b572aa5bf8298b3", "40": "4cc746f3f10c71a6", "41": "59b1c6760dbaf1b9", "42": "bbd8a9066e48dada", "43": "b6e84eea788411f9", "44": "4b603c000aa79db9", "45": "8cda0f1879c40078", "46": "f38c1f1dc0ead50f", "47": "93e8d94e7c998882", "48": "9d46bbe4641a8543", "49": "df2dbdd967da3eaf", "50": "28183579704b35fd", "51": "afc6d855b9fc1b76", "52": "5e7f77f5e6dac074", "53": "e97ba10a1e6a2df3", "54": "34401beb9f60ebfd", "55": "ffa54e3d7035005e", "56": "330644cc56a0e06c", "57": "9e17a2dab584e04c", "58": "90146abe85d9419f", "59": "88f880da5a61f9dc", "60": "356d13eb0dd615ee", "61": "dbfbf8557361f5ea", "62": "6e90b41fbd82c265", "63": "6af26e59b0e8c354", "64": "8ae1cb991df55ba5", "65": "7b932cfef527d130", "66": "3de85332d944dae4", "67": "0f632fc57e9feeb0", "68": "92d7e4efe25770db", "69": "2937001282a8a8b6", "70": "df7ea8ce3fa52be3", "71": "fd47ef3914adf11e", "72": "9b3249f4a4e6fbfe", "73": "39cd7adefdc0d3ce", "74": "a534e43d03671a7c", "75": "5290f7a7558a9c3e", "76": "a19c211702401a5b", "77": "35aa0fc2a0626f70", "78": "996c5e480613f739", "79": "da683f642f8c61ac", "80": "40313433c93b7054", "81": "81af0a28b68936d9", "82": "25287261c3d1c44b", "83": "bda9be183e0f933e", "84": "63526d01888c4f22", "85": "ad4f4a79134b4346", "86": "694a38d63caf7217", "87": "f220eaa4fac26e3e", "88": "d78c8e21a45613e9", "89": "e5ca736ea1d10113", "90": "03f8896e9b60ca96", "91": "5b7e9b89423f4164", "92": "4bfa64a1cd8c6a47", "93": "057eae5a523d2004", "94": "5de74655cbc56e06", "95": "7767ac1c95366fb0", "96": "f36a8effec98632a", "97": "de6816239e70e6e8", "98": "7508d3e84c588617", "99": "15f6a40009e539bb", "100": "f4ecac15615b820e", "101": "4b7f81f0e7e4c044", "102": "7c85c7a24cf3cbed", "103": "3ec6277e63307488", "104": "756a405f723988ce", "105": "d55977ec0e3ae213", "106": "64fb409201f034b8", "107": "19d9362d6aa40786", "108": "b738c5aefe939cff", "109": "d2001d7d283134ac", "110": "6b8e6100893a6b6a", "111": "66ed76491a412a72", "112": "e67668b62ee6c62c", "113": "6bb5ee4a70642036", "114": "68aee39cd24db354", "115": "054401d27ffaa4b2", "116": "dace81c1995fb321", "117": "19e301aeace95bb2", "118": "d6fd0cec4634a0b0", "119": "0230d355a6dabb54", "120": "5cebbb5528321795", "121": "536fe28d24c361f5", "122": "b08bdc2712cb0f12", "123": "6ce8444f504022cc", "124": "8021f52559633383", "125": "d8e34a3b3eb43ee0", "126": "171c34c74a95481b", "127": "73e9eca6f2ca1c9b", "128": "dcc2230ae751c29a", "129": "2a617b932f5d92b2", "130": "d55cb638532fdfeb", "131": "06cae4389208269f", "132": "e9c0a7be255e9b26", "133": "455b4ca39e55ecb0", "134": "8105c55538c34081", "135": "74304670b54c018f", "136": "a27f876eabb044b2", "137": "73f74dd7a40776d6", "138": "29d433d164b99f89", "139": "3f44aa48aa05d597", "140": "ca8a7efba0a3e579", "141": "5cc770af9d184d77", "142": "2d31fa723128b164", "143": "be720fc0071f0c3d", "144": "9d814c2520329846", "145": "f97d1a5c3a066598", "146": "14f84a6e4cd311d6", "147": "3a592d27b1f4ccc9", "148": "0bf6f9d0aeab4fd8", "149": "8b491bb3ff0649dd", "150": "638812cee15d1b9b", "151": "3dd16d68928a479c", "152": "03a0ee43e18f7934", "153": "b672f7977ddfa52e", "154": "435331945d6aad17", "155": "968e1a135dc062b7", "156": "d1a987a548e55a76", "157": "a9bf69b3d550abef", "158": "b039e1c69c8fb7b9", "159": "60029bf0948fc02a", "160": "72dad91d66d471ce", "161": "353a9d09eaae47ea", "162": "43bbf35b96423d46", "163": "4faac8c35fa8b703", "164": "76d6488c0976adbc", "165": "652eabd3db52041c", "166": "bca6f6760a942d10", "167": "ce80aac5f4c439da", "168": "1abe2e7b0bcbeca9", "169": "cfbcf8cef78c5757", "170": "c9f25e20d3a7d5d8", "171": "7e818e8715c414bd", "172": "8d68a258e8ec738b", "173": "827420b645a55301", "174": "19e1beef5d40e7ac", "175": "d6451fb8a1e2d3ca", "176": "adac855b05ea55ee", "177": "95a53382a8e217be", "178": "c67bd11d1973d6ac", "179": "63c999649e523e73", "180": "56f7f3573c34f651", "181": "f8f5346df7d0a239", "182": "9c5614d4fee3efc0", "183": "6886ad7a5751faca", "184": "a1a1f3c1cdff72cf", "185": "32f7ccd6bd4068c7", "186": "1d89c741fb1f9b27", "187": "a800ecffc01fc9d5", "188": "f97753a176d20dae", "189"Line truncated
|
||||||
+162
-174
@@ -1,17 +1,17 @@
|
|||||||
# Graph Report - khatibi (2026-10-16)
|
# Graph Report - khatibi (2026-10-20)
|
||||||
|
|
||||||
## Corpus Check
|
## Corpus Check
|
||||||
- 193 files · ~286,477 words
|
- 197 files · ~287,853 words
|
||||||
- Verdict: corpus is large enough that graph structure adds value.
|
- Verdict: corpus is large enough that graph structure adds value.
|
||||||
- Unclassified: 8 file(s) not represented in the graph (top: (none) 5, .env 2, .csv 1)
|
- Unclassified: 8 file(s) not represented in the graph (top: (none) 5, .env 2, .csv 1)
|
||||||
|
|
||||||
## Summary
|
## Summary
|
||||||
- 1960 nodes · 3584 edges · 184 communities (95 shown, 89 thin omitted)
|
- 1991 nodes · 3632 edges · 207 communities (87 shown, 120 thin omitted)
|
||||||
- Extraction: 95% EXTRACTED · 5% INFERRED · 0% AMBIGUOUS · INFERRED: 188 edges (avg confidence: 0.89)
|
- Extraction: 95% EXTRACTED · 5% INFERRED · 0% AMBIGUOUS · INFERRED: 188 edges (avg confidence: 0.89)
|
||||||
- Token cost: 0 input · 0 output
|
- Token cost: 0 input · 0 output
|
||||||
|
|
||||||
## Graph Freshness
|
## Graph Freshness
|
||||||
- Built from commit: `7e5c83be`
|
- Built from commit: `1fd27d04`
|
||||||
- Run `git rev-parse HEAD` and compare to check if the graph is stale.
|
- Run `git rev-parse HEAD` and compare to check if the graph is stale.
|
||||||
- Run `graphify update .` after code changes (no API cost).
|
- Run `graphify update .` after code changes (no API cost).
|
||||||
|
|
||||||
@@ -19,25 +19,23 @@
|
|||||||
- views/sms.py
|
- views/sms.py
|
||||||
- run_all
|
- run_all
|
||||||
- django_db
|
- django_db
|
||||||
- create_transfer_request
|
- views/store.py
|
||||||
- ModelsPackageContractTests
|
- ModelsPackageContractTests
|
||||||
- PhoneCleanupTests
|
- PhoneCleanupTests
|
||||||
- .load
|
- .load
|
||||||
- models/sms.py
|
- models/sms.py
|
||||||
- models/__init__.py
|
|
||||||
- views/customers.py
|
- views/customers.py
|
||||||
- django_conf
|
- django_db_models_deletion
|
||||||
|
- PanelTestCase
|
||||||
- make_customer
|
- make_customer
|
||||||
- resolve_recipients
|
|
||||||
- EventTriggerTests
|
- EventTriggerTests
|
||||||
- views/finance.py
|
- views/finance.py
|
||||||
- views/store.py
|
|
||||||
- sms0098ws.py
|
- sms0098ws.py
|
||||||
- Admin Panel Customization
|
- Admin Panel Customization
|
||||||
- User
|
- User
|
||||||
- timing_for
|
- apply_timing
|
||||||
- Customer
|
- InactiveCustomerHiddenTests
|
||||||
- phones.py
|
- normalize_mobile
|
||||||
- Login Template Forms
|
- Login Template Forms
|
||||||
- cheque_inquiry.py
|
- cheque_inquiry.py
|
||||||
- factories.py
|
- factories.py
|
||||||
@@ -47,10 +45,10 @@
|
|||||||
- make_user
|
- make_user
|
||||||
- QueueReviewTests
|
- QueueReviewTests
|
||||||
- NoticeMechanismTests
|
- NoticeMechanismTests
|
||||||
- SmsReportTests
|
- name_fix.py
|
||||||
- scanner.py
|
- enqueue_sms
|
||||||
- ZeroZeroNinetyEightWebServiceTests
|
- ZeroZeroNinetyEightWebServiceTests
|
||||||
- ProductInStore
|
- ScannerTests
|
||||||
- admin_panel/templates/admin_panel/baseHesabdari.html
|
- admin_panel/templates/admin_panel/baseHesabdari.html
|
||||||
- Reference guide
|
- Reference guide
|
||||||
- Factor Creation UI
|
- Factor Creation UI
|
||||||
@@ -61,19 +59,19 @@
|
|||||||
- SMS Integration Docs
|
- SMS Integration Docs
|
||||||
- enqueue.py
|
- enqueue.py
|
||||||
- django_shortcuts
|
- django_shortcuts
|
||||||
- stock_in
|
- test_inventory.py
|
||||||
- admin_panel/templates/admin_panel/customerAction.html
|
- admin_panel/templates/admin_panel/customerAction.html
|
||||||
- parts_count
|
- SmsReportTests
|
||||||
- write_tree
|
- write_tree
|
||||||
- CustomerInfoCardUITests
|
- CustomerInfoCardUITests
|
||||||
- test_sms.py
|
- TestCase
|
||||||
- Phone Cleanup Guide
|
- Phone Cleanup Guide
|
||||||
- .run_command
|
- Customer
|
||||||
- project_tree.py
|
- project_tree.py
|
||||||
- Agent Operating Guidelines
|
- Agent Operating Guidelines
|
||||||
- Command
|
- Command
|
||||||
- BehinSMSProvider
|
- BehinSMSProvider
|
||||||
- normalize_mobile
|
- activate_sms
|
||||||
- Financial Data Objects
|
- Financial Data Objects
|
||||||
- Hybrid Migration Command
|
- Hybrid Migration Command
|
||||||
- .run_cmd
|
- .run_cmd
|
||||||
@@ -82,51 +80,52 @@
|
|||||||
- admin_panel/templates/admin_panel/bedehi.html
|
- admin_panel/templates/admin_panel/bedehi.html
|
||||||
- customerIDFactorBackup.html
|
- customerIDFactorBackup.html
|
||||||
- bale/providers.py
|
- bale/providers.py
|
||||||
|
- os
|
||||||
- Graphify Tooling Docs
|
- Graphify Tooling Docs
|
||||||
- Command
|
- Command
|
||||||
- ZeroZeroNinetyEightLinkTests
|
- ZeroZeroNinetyEightLinkTests
|
||||||
- SMS Template Seeding
|
- SMS Template Seeding
|
||||||
- admin_panel/entezar.html
|
- admin_panel/entezar.html
|
||||||
- customerNewBackup.html
|
- customerNewBackup.html
|
||||||
- SMSResult
|
- django_conf
|
||||||
- 0070_sms_types_seven.py
|
- 0070_sms_types_seven.py
|
||||||
- Endpoint Smoke Tests
|
- Endpoint Smoke Tests
|
||||||
- Timezone Migration History
|
- Timezone Migration History
|
||||||
- Command
|
- Command
|
||||||
- django_urls
|
- UrlContractTests
|
||||||
- Brand Scrubbing Migration
|
- Brand Scrubbing Migration
|
||||||
- SMS Dedup Migration
|
- SMS Dedup Migration
|
||||||
- Installation Guide
|
- Installation Guide
|
||||||
- Project Honesty Contract
|
- Project Honesty Contract
|
||||||
- Flow Control Migration
|
- Flow Control Migration
|
||||||
- Permission Label Migration
|
- Permission Label Migration
|
||||||
- IntegrationAndEfficiencyTests
|
- PhoneNumber
|
||||||
- Output Format Specs
|
- Output Format Specs
|
||||||
- Performance Benchmarks
|
- Performance Benchmarks
|
||||||
- SMS Integration Overview
|
- SMS Integration Overview
|
||||||
- CustomerNameFixJob
|
- CustomerNameFixJob
|
||||||
- Customer Modal UI
|
- Customer Modal UI
|
||||||
- PanelTestCase
|
- decide_prefix
|
||||||
- Infrastructure Services
|
- Infrastructure Services
|
||||||
- Agent Setup Guide
|
- Agent Setup Guide
|
||||||
- First Use Guide
|
- First Use Guide
|
||||||
- ConsoleSMSProvider
|
- ConsoleEncodingTests
|
||||||
- PhoneNumber
|
- test_factors.py
|
||||||
- تجمیع شمارههای پیامک مشتریان
|
- تجمیع شمارههای پیامک مشتریان
|
||||||
- Command
|
- Command
|
||||||
- Base Template
|
- Base Template
|
||||||
- test_customer_name_fix.py
|
- test_sms.py
|
||||||
- Command
|
- Command
|
||||||
- customer_phone_entries
|
- WorkerTests
|
||||||
- manage.py
|
- manage.py
|
||||||
- افزودن پیشوند «آقای/خانم» به نام مشتریان
|
- افزودن پیشوند «آقای/خانم» به نام مشتریان
|
||||||
- بازمحاسبهٔ «آخرین بازدید» مشتریان
|
- Command
|
||||||
- QueueStatsTests
|
- QueueStatsTests
|
||||||
- admin_panel/apps.py
|
- signals.py
|
||||||
- Customer Communication Partials
|
- Customer Communication Partials
|
||||||
- SMS Template Management
|
- SMS Template Management
|
||||||
- Documentation and Graphing
|
- Documentation and Graphing
|
||||||
- primary_phones
|
- recipients.py
|
||||||
- Customer Action Templates
|
- Customer Action Templates
|
||||||
- Customer Feedback Components
|
- Customer Feedback Components
|
||||||
- Customer Invoice Details
|
- Customer Invoice Details
|
||||||
@@ -160,35 +159,56 @@
|
|||||||
- Data Cleanup Documentation
|
- Data Cleanup Documentation
|
||||||
- Project Dependencies
|
- Project Dependencies
|
||||||
- Customer
|
- Customer
|
||||||
- SMSQueue
|
- factory.py
|
||||||
- signals.py
|
- 0075_cheque_funding_before_due.py
|
||||||
- توقف و ادامه (Resume / Checkpoint)
|
- توقف و ادامه (Resume / Checkpoint)
|
||||||
- Command
|
- Command
|
||||||
- 0071_sms_settings_permission.py
|
- 0071_sms_settings_permission.py
|
||||||
- index
|
- 0056_auto_20260731_1824.py
|
||||||
- الگوریتم تصمیم (به همین ترتیب)
|
- الگوریتم تصمیم (به همین ترتیب)
|
||||||
- سریعترین مسیر روی Production (خلاصهٔ اجرایی)
|
- سریعترین مسیر روی Production (خلاصهٔ اجرایی)
|
||||||
- models/customers.py
|
- 0073_alter_action_actiontype_choices.py
|
||||||
- stop.sh
|
- stop.sh
|
||||||
- SMSDedup
|
- 0074_alter_action_status_choices.py
|
||||||
- rules.py
|
- remove_query_param
|
||||||
- اجرای محلی (توسعه)
|
- اجرای محلی (توسعه)
|
||||||
- Factor
|
- .test_deleted_visit_reminder_rearms_next_period_only
|
||||||
- اجرا روی سرور Production
|
- اجرا روی سرور Production
|
||||||
- اجرای محلی (توسعه)
|
- اجرای محلی (توسعه)
|
||||||
- ادامهٔ job پس از crash / قطع برق
|
- ادامهٔ job پس از crash / قطع برق
|
||||||
- اجرا روی سرور Production
|
- اجرا روی سرور Production
|
||||||
- django_contrib_auth_models
|
- 0005_auto_20220121_2054.py
|
||||||
|
- 0009_remove_storeroom_user.py
|
||||||
|
- 0014_auto_20220123_1348.py
|
||||||
|
- 0015_auto_20220124_0858.py
|
||||||
|
- 0017_factoriteam_garantyesh.py
|
||||||
|
- 0022_article_typepy.py
|
||||||
|
- 0026_auto_20220209_1924.py
|
||||||
|
- 0028_action_grade.py
|
||||||
|
- 0032_article_paystatus.py
|
||||||
|
- 0033_article_nahvepardakht.py
|
||||||
|
- 0034_alter_article_sarresid.py
|
||||||
|
- 0035_alter_article_sarresid.py
|
||||||
|
- 0040_customer_laghv_bazdid.py
|
||||||
|
- 0041_auto_20240216_1607.py
|
||||||
|
- 0048_alter_productinstore_options.py
|
||||||
|
- 0053_storeroom_owners.py
|
||||||
|
- 0055_action_nextnazardt.py
|
||||||
|
- 0062_sms_link_provider.py
|
||||||
|
- 0063_alter_smsqueue_status.py
|
||||||
|
- 0068_auto_20261010_1305.py
|
||||||
|
- 0072_customernamefixjob.py
|
||||||
|
- 0077_alter_smstemplate_text_balance_help.py
|
||||||
|
|
||||||
## God Nodes (most connected - your core abstractions)
|
## God Nodes (most connected - your core abstractions)
|
||||||
1. `make_customer()` - 89 edges
|
1. `make_customer()` - 89 edges
|
||||||
2. `make_user()` - 28 edges
|
2. `run_all()` - 28 edges
|
||||||
3. `normalize_mobile()` - 27 edges
|
3. `make_user()` - 28 edges
|
||||||
4. `Customer` - 24 edges
|
4. `normalize_mobile()` - 27 edges
|
||||||
5. `PhoneNumber` - 24 edges
|
5. `Customer` - 24 edges
|
||||||
6. `Command` - 24 edges
|
6. `PhoneNumber` - 24 edges
|
||||||
7. `run_all()` - 24 edges
|
7. `Command` - 24 edges
|
||||||
8. `enqueue_sms()` - 23 edges
|
8. `enqueue_sms()` - 24 edges
|
||||||
9. `SMSResult` - 23 edges
|
9. `SMSResult` - 23 edges
|
||||||
10. `Rip'n Fast. Fewer Tokens. Better Code.` - 23 edges
|
10. `Rip'n Fast. Fewer Tokens. Better Code.` - 23 edges
|
||||||
|
|
||||||
@@ -235,87 +255,75 @@
|
|||||||
- **New Factor Creation Flow** — admin_panel_templates_admin_panel_customeridbackup_html, django_url_admin_panel_productsjson, api_endpoint_panel_productmojodi, admin_panel_templates_admin_panel_customeridbackup_html_orderlist, admin_panel_templates_admin_panel_customeridbackup_html_buildorderlist, django_url_admin_panel_newfactor [INFERRED]
|
- **New Factor Creation Flow** — admin_panel_templates_admin_panel_customeridbackup_html, django_url_admin_panel_productsjson, api_endpoint_panel_productmojodi, admin_panel_templates_admin_panel_customeridbackup_html_orderlist, admin_panel_templates_admin_panel_customeridbackup_html_buildorderlist, django_url_admin_panel_newfactor [INFERRED]
|
||||||
- **Store and Inventory Management Flow** — admin_panel_templates_admin_panel_basestore, concept_url_admin_panel_stores, concept_url_admin_panel_productadd, concept_url_admin_panel_operations, concept_url_admin_panel_store_transfers [INFERRED]
|
- **Store and Inventory Management Flow** — admin_panel_templates_admin_panel_basestore, concept_url_admin_panel_stores, concept_url_admin_panel_productadd, concept_url_admin_panel_operations, concept_url_admin_panel_store_transfers [INFERRED]
|
||||||
|
|
||||||
## Communities (184 total, 89 thin omitted)
|
## Communities (207 total, 120 thin omitted)
|
||||||
|
|
||||||
### Community 0 - "views/sms.py"
|
### Community 0 - "views/sms.py"
|
||||||
Cohesion: 0.12
|
Cohesion: 0.12
|
||||||
Nodes (18): _approve(), customer_sms_history(), _customer_url(), _notice(), parse_jalali_date(), parse_scan_since(), resolve_recipients_test(), sms_customer_toggle() (+10 more)
|
Nodes (18): _approve(), customer_sms_history(), _customer_url(), _notice(), parse_jalali_date(), parse_scan_since(), resolve_recipients_test(), sms_customer_toggle() (+10 more)
|
||||||
|
|
||||||
### Community 1 - "run_all"
|
|
||||||
Cohesion: 0.05
|
|
||||||
Nodes (9): Command, run_all(), apply_timing(), activate_sms(), AutoSendTests, ColdStartTests, ScannerTests, keys() (+1 more)
|
|
||||||
|
|
||||||
### Community 2 - "django_db"
|
### Community 2 - "django_db"
|
||||||
Cohesion: 0.03
|
Cohesion: 0.05
|
||||||
Nodes (38): Migration, Migration, Migration, Migration, Migration, Migration, Migration, Migration (+30 more)
|
Nodes (20): Migration, Migration, Migration, Migration, Migration, Migration, Migration, Migration (+12 more)
|
||||||
|
|
||||||
### Community 3 - "create_transfer_request"
|
### Community 3 - "views/store.py"
|
||||||
Cohesion: 0.10
|
Cohesion: 0.05
|
||||||
Nodes (12): StoreTransferRequest, Meta, Status, approve_transfer_request(), cancel_transfer_request(), _check_source_store_permission(), _check_target_store_permission(), create_transfer_request() (+4 more)
|
Nodes (32): Product, Meta, ProductCategory, ProductInStore, Meta, MovementType, ProductStockHistory, Meta (+24 more)
|
||||||
|
|
||||||
### Community 5 - "PhoneCleanupTests"
|
### Community 5 - "PhoneCleanupTests"
|
||||||
Cohesion: 0.08
|
Cohesion: 0.08
|
||||||
Nodes (9): Command, Migration, scrub_legacy_phones(), _append_note(), clean_all(), clean_customer(), clean_legacy_cell(), _numeric_tokens() (+1 more)
|
Nodes (9): Command, Migration, scrub_legacy_phones(), _append_note(), clean_all(), clean_customer(), clean_legacy_cell(), _numeric_tokens() (+1 more)
|
||||||
|
|
||||||
### Community 6 - ".load"
|
### Community 6 - ".load"
|
||||||
Cohesion: 0.12
|
Cohesion: 0.19
|
||||||
Nodes (6): SMSProviderConfig, Meta, 12. Supported languages and formats, Languages, SettingsPanelTests, TestMessageSettingsTests
|
Nodes (4): 12. Supported languages and formats, Languages, SettingsPanelTests, TestMessageSettingsTests
|
||||||
|
|
||||||
### Community 7 - "models/sms.py"
|
### Community 7 - "models/sms.py"
|
||||||
Cohesion: 0.12
|
Cohesion: 0.06
|
||||||
Nodes (7): CustomerSMSSetting, Meta, SMSLog, Meta, SMSTemplate, Meta, SMSType
|
Nodes (15): CustomerSMSSetting, Meta, SMSDedup, Meta, SMSLog, Meta, SMSProviderConfig, Meta (+7 more)
|
||||||
|
|
||||||
### Community 8 - "models/__init__.py"
|
|
||||||
Cohesion: 0.10
|
|
||||||
Nodes (4): remove_query_param(), check_financial_discrepancy(), run_360_audit(), run_audit()
|
|
||||||
|
|
||||||
### Community 9 - "views/customers.py"
|
### Community 9 - "views/customers.py"
|
||||||
Cohesion: 0.16
|
Cohesion: 0.10
|
||||||
Nodes (11): customer(), customerDel(), customerFactor(), customerFactors(), customerHesabdari(), customerNew(), customerNew2(), customers() (+3 more)
|
Nodes (18): normalize_primary_values(), customer_toggle_map(), notify_welcome(), customer(), customerDel(), customerEdit(), customerFactor(), customerFactors() (+10 more)
|
||||||
|
|
||||||
### Community 10 - "django_conf"
|
### Community 10 - "django_db_models_deletion"
|
||||||
Cohesion: 0.05
|
Cohesion: 0.05
|
||||||
Nodes (23): Migration, Migration, Migration, Migration, Migration, Migration, Migration, Migration (+15 more)
|
Nodes (19): Migration, Migration, Migration, Migration, Migration, Migration, Migration, Migration (+11 more)
|
||||||
|
|
||||||
### Community 11 - "make_customer"
|
### Community 11 - "PanelTestCase"
|
||||||
Cohesion: 0.15
|
Cohesion: 0.15
|
||||||
Nodes (3): make_customer(), EmptyQueueTests, PrimaryNumberPanelTests
|
Nodes (3): EmptyQueueTests, PanelTestCase, SentQueueProtectionTests
|
||||||
|
|
||||||
### Community 12 - "resolve_recipients"
|
### Community 12 - "make_customer"
|
||||||
Cohesion: 0.15
|
Cohesion: 0.10
|
||||||
Nodes (4): resolve_recipients(), تستها, mark_primary(), RecipientTests
|
Nodes (7): resolve_recipients(), تستها, make_customer(), DeletedStaysDeletedTests, mark_primary(), PrimaryNumberPanelTests, RecipientTests
|
||||||
|
|
||||||
### Community 14 - "views/finance.py"
|
### Community 14 - "views/finance.py"
|
||||||
Cohesion: 0.17
|
Cohesion: 0.20
|
||||||
Nodes (20): Article, Meta, notify_payment_thanks(), customerHesabdariAdd(), addSarresid(), bedehi(), bedehiKhadamat(), bedehiNasb() (+12 more)
|
Nodes (18): Article, Meta, addSarresid(), bedehi(), bedehiKhadamat(), bedehiNasb(), financial_dashboard(), hesabdari() (+10 more)
|
||||||
|
|
||||||
### Community 15 - "views/store.py"
|
|
||||||
Cohesion: 0.14
|
|
||||||
Nodes (16): Product, Meta, StoreRoom, Meta, newOperation(), newStore(), operation(), operationProduct() (+8 more)
|
|
||||||
|
|
||||||
### Community 16 - "sms0098ws.py"
|
### Community 16 - "sms0098ws.py"
|
||||||
Cohesion: 0.27
|
Cohesion: 0.11
|
||||||
Nodes (3): classify_delivery(), DeliveryCode, describe_send_code()
|
Nodes (5): to_local(), classify_delivery(), DeliveryCode, describe_send_code(), _as_text()
|
||||||
|
|
||||||
### Community 17 - "Admin Panel Customization"
|
### Community 17 - "Admin Panel Customization"
|
||||||
Cohesion: 0.12
|
Cohesion: 0.12
|
||||||
Nodes (10): CustomerSMSSettingAdmin, SMSLogAdmin, SMSProviderConfigAdmin, SMSProviderConfigForm, Meta, SMSQueueAdmin, SMSTemplateAdmin, StoreRoomAdmin (+2 more)
|
Nodes (10): CustomerSMSSettingAdmin, SMSLogAdmin, SMSProviderConfigAdmin, SMSProviderConfigForm, Meta, SMSQueueAdmin, SMSTemplateAdmin, StoreRoomAdmin (+2 more)
|
||||||
|
|
||||||
### Community 18 - "User"
|
### Community 18 - "User"
|
||||||
Cohesion: 0.17
|
Cohesion: 0.07
|
||||||
Nodes (18): User, Action, ActionType, Status, notify_visit_scheduled(), ActionList(), ActionListAll(), ActionListToday() (+10 more)
|
Nodes (34): User, Action, ActionType, Status, VisitQueue, Factor, Meta, StoreRoom (+26 more)
|
||||||
|
|
||||||
### Community 19 - "timing_for"
|
### Community 19 - "apply_timing"
|
||||||
Cohesion: 0.12
|
Cohesion: 0.12
|
||||||
Nodes (6): current_timing_value(), timing_for(), sms_template_edit(), sms_templates(), _templates_redirect(), TimingsTests
|
Nodes (7): apply_timing(), current_timing_value(), timing_for(), sms_template_edit(), sms_templates(), _templates_redirect(), TimingsTests
|
||||||
|
|
||||||
### Community 20 - "Customer"
|
### Community 20 - "InactiveCustomerHiddenTests"
|
||||||
Cohesion: 0.12
|
Cohesion: 0.12
|
||||||
Nodes (5): Customer, login_superuser(), CustomerCrudTests, CustomerDeleteGuardTests, InactiveCustomerHiddenTests
|
Nodes (3): CustomerCrudTests, CustomerDeleteGuardTests, InactiveCustomerHiddenTests
|
||||||
|
|
||||||
### Community 21 - "phones.py"
|
### Community 21 - "normalize_mobile"
|
||||||
Cohesion: 0.19
|
Cohesion: 0.10
|
||||||
Nodes (7): normalize_primary_values(), resolved_recipients(), save_primary_normalized(), stored_primary_normalized(), usable_entries(), قالبِ ذخیرهشده: `+989xxxxxxxxx`, قواعد اعتبارسنجی و قالب شماره
|
Nodes (13): customer_phone_entries(), _iter_sources(), PhoneEntry, resolved_recipients(), save_primary_normalized(), stored_primary_normalized(), to_display(), usable_entries() (+5 more)
|
||||||
|
|
||||||
### Community 22 - "Login Template Forms"
|
### Community 22 - "Login Template Forms"
|
||||||
Cohesion: 0.11
|
Cohesion: 0.11
|
||||||
@@ -326,7 +334,7 @@ Cohesion: 0.17
|
|||||||
Nodes (6): api_check_sabt_inquiry(), api_check_status_inquiry(), parse_cheque_result_html(), run_bpi_cheque_inquiry_logic(), run_cbi_sayad_inquiry_logic(), solve_base64_captcha()
|
Nodes (6): api_check_sabt_inquiry(), api_check_status_inquiry(), parse_cheque_result_html(), run_bpi_cheque_inquiry_logic(), run_cbi_sayad_inquiry_logic(), solve_base64_captcha()
|
||||||
|
|
||||||
### Community 24 - "factories.py"
|
### Community 24 - "factories.py"
|
||||||
Cohesion: 0.18
|
Cohesion: 0.31
|
||||||
Nodes (5): ProductCategory, make_category(), make_product(), make_store(), _next()
|
Nodes (5): ProductCategory, make_category(), make_product(), make_store(), _next()
|
||||||
|
|
||||||
### Community 25 - "Rip'n Fast. Fewer Tokens. Better Code."
|
### Community 25 - "Rip'n Fast. Fewer Tokens. Better Code."
|
||||||
@@ -334,8 +342,8 @@ Cohesion: 0.11
|
|||||||
Nodes (18): Against the leading graph-database code-context MCP server, Better Code: It automates the review judgments nobody has time to make — every lens from published research, Documentation, Graph-Ranked Retrieval: It finds the right files more often than the alternatives, If it works on your codebase, tell us what it got wrong, Improve it with your agent, Licence, Quickstart (+10 more)
|
Nodes (18): Against the leading graph-database code-context MCP server, Better Code: It automates the review judgments nobody has time to make — every lens from published research, Documentation, Graph-Ranked Retrieval: It finds the right files more often than the alternatives, If it works on your codebase, tell us what it got wrong, Improve it with your agent, Licence, Quickstart (+10 more)
|
||||||
|
|
||||||
### Community 26 - "views/factors.py"
|
### Community 26 - "views/factors.py"
|
||||||
Cohesion: 0.14
|
Cohesion: 0.12
|
||||||
Nodes (9): FactorIteam, notify_factor_survey(), FactorDel(), newFactor(), newFactor2(), InventoryException, _parse_jalali_date(), _parse_timestamp() (+1 more)
|
Nodes (10): FactorIteam, notify_factor_survey(), notify_payment_thanks(), FactorDel(), newFactor(), newFactor2(), InventoryException, _parse_jalali_date() (+2 more)
|
||||||
|
|
||||||
### Community 27 - "sms0098.py"
|
### Community 27 - "sms0098.py"
|
||||||
Cohesion: 0.20
|
Cohesion: 0.20
|
||||||
@@ -345,17 +353,17 @@ Nodes (3): _configured_codes(), _response_code(), ZeroZeroNinetyEightSMSProvider
|
|||||||
Cohesion: 0.17
|
Cohesion: 0.17
|
||||||
Nodes (3): dashBoxData(), resolve(), NoticeMechanismTests
|
Nodes (3): dashBoxData(), resolve(), NoticeMechanismTests
|
||||||
|
|
||||||
### Community 32 - "scanner.py"
|
### Community 31 - "name_fix.py"
|
||||||
Cohesion: 0.23
|
Cohesion: 0.13
|
||||||
Nodes (6): _aware(), _Budget, scan_cheque_funding(), scan_nesiye_due(), scan_visit(), _template()
|
Nodes (6): build_name_index(), DatasetError, load_name_index(), normalize_name_key(), validate_dataset(), NameIndexTests
|
||||||
|
|
||||||
|
### Community 32 - "enqueue_sms"
|
||||||
|
Cohesion: 0.21
|
||||||
|
Nodes (7): enqueue_sms(), _aware(), _Budget, scan_cheque_funding(), scan_nesiye_due(), scan_visit(), _template()
|
||||||
|
|
||||||
### Community 33 - "ZeroZeroNinetyEightWebServiceTests"
|
### Community 33 - "ZeroZeroNinetyEightWebServiceTests"
|
||||||
Cohesion: 0.11
|
Cohesion: 0.12
|
||||||
Nodes (5): SMSDeliveryResult, _as_text(), ZeroZeroNinetyEightWebServiceProvider, SMSDeliveryCommandTests, ZeroZeroNinetyEightWebServiceTests
|
Nodes (4): SMSDeliveryResult, ZeroZeroNinetyEightWebServiceProvider, SMSDeliveryCommandTests, ZeroZeroNinetyEightWebServiceTests
|
||||||
|
|
||||||
### Community 34 - "ProductInStore"
|
|
||||||
Cohesion: 0.33
|
|
||||||
Nodes (3): ProductInStore, Meta, MovementType
|
|
||||||
|
|
||||||
### Community 35 - "admin_panel/templates/admin_panel/baseHesabdari.html"
|
### Community 35 - "admin_panel/templates/admin_panel/baseHesabdari.html"
|
||||||
Cohesion: 0.13
|
Cohesion: 0.13
|
||||||
@@ -374,7 +382,7 @@ Cohesion: 0.19
|
|||||||
Nodes (11): admin_panel:customerNew, admin_panel:pardakhtNesiye, /panel/passCheck/, all_sarresid, mandehesabKP, admin_panel.ViewReportMali, jQuery, pDatepicker JS Library (+3 more)
|
Nodes (11): admin_panel:customerNew, admin_panel:pardakhtNesiye, /panel/passCheck/, all_sarresid, mandehesabKP, admin_panel.ViewReportMali, jQuery, pDatepicker JS Library (+3 more)
|
||||||
|
|
||||||
### Community 39 - "requests"
|
### Community 39 - "requests"
|
||||||
Cohesion: 0.21
|
Cohesion: 0.25
|
||||||
Nodes (4): AnnouncementRejected, place_announcement_call(), place_voip_call(), VoipCallResult
|
Nodes (4): AnnouncementRejected, place_announcement_call(), place_voip_call(), VoipCallResult
|
||||||
|
|
||||||
### Community 40 - "CustomerSmsHistoryTests"
|
### Community 40 - "CustomerSmsHistoryTests"
|
||||||
@@ -386,40 +394,40 @@ Cohesion: 0.15
|
|||||||
Nodes (12): **جدول کدهای خطای لینک ارتباطی**, **جدول کدهای خطای وبسرویس و وضعیتهای دلیوری**, **راهنمای استفاده از Web Service**, **راهنمای استفاده از لینک ارتباطی (URL GET)**, **راهنمای استفاده از لینک ارتباطی و وبسرویس سامانه پیامک (0098vip.com و 0098sms.com)**, **فهرست مطالب**, **مقدمات اتصال**, **نمونه کدهای پیادهسازی** (+4 more)
|
Nodes (12): **جدول کدهای خطای لینک ارتباطی**, **جدول کدهای خطای وبسرویس و وضعیتهای دلیوری**, **راهنمای استفاده از Web Service**, **راهنمای استفاده از لینک ارتباطی (URL GET)**, **راهنمای استفاده از لینک ارتباطی و وبسرویس سامانه پیامک (0098vip.com و 0098sms.com)**, **فهرست مطالب**, **مقدمات اتصال**, **نمونه کدهای پیادهسازی** (+4 more)
|
||||||
|
|
||||||
### Community 43 - "enqueue.py"
|
### Community 43 - "enqueue.py"
|
||||||
Cohesion: 0.19
|
Cohesion: 0.13
|
||||||
Nodes (5): format_amount(), format_jalali(), render_template(), _SafeDict, _sample_context()
|
Nodes (6): customer_balance(), format_amount(), format_jalali(), render_template(), _SafeDict, _sample_context()
|
||||||
|
|
||||||
### Community 44 - "django_shortcuts"
|
### Community 44 - "django_shortcuts"
|
||||||
Cohesion: 0.13
|
Cohesion: 0.13
|
||||||
Nodes (3): Migration, ArticleType, Sarresid
|
Nodes (5): ArticleType, Sarresid, index(), `nest=` is a max, so it cannot tell a long function from a tangled one, The quality panel
|
||||||
|
|
||||||
### Community 45 - "stock_in"
|
### Community 45 - "test_inventory.py"
|
||||||
Cohesion: 0.12
|
Cohesion: 0.11
|
||||||
Nodes (11): Factor, FactorIteam, Product, ProductInStore, StoreRoom, VisitQueue, current_stock(), stock_in() (+3 more)
|
Nodes (11): Factor, FactorIteam, Product, ProductInStore, StoreRoom, VisitQueue, current_stock(), stock_in() (+3 more)
|
||||||
|
|
||||||
### Community 46 - "admin_panel/templates/admin_panel/customerAction.html"
|
### Community 46 - "admin_panel/templates/admin_panel/customerAction.html"
|
||||||
Cohesion: 0.18
|
Cohesion: 0.18
|
||||||
Nodes (9): admin_panel/templates/admin_panel/customerActionInc.html, admin_panel:customerNewAction URL, Action Data Model, DataTables Library, jQuery, Django 'url' template tag, Bulma CSS, DataTables.js (+1 more)
|
Nodes (9): admin_panel/templates/admin_panel/customerActionInc.html, admin_panel:customerNewAction URL, Action Data Model, DataTables Library, jQuery, Django 'url' template tag, Bulma CSS, DataTables.js (+1 more)
|
||||||
|
|
||||||
### Community 47 - "parts_count"
|
### Community 47 - "SmsReportTests"
|
||||||
Cohesion: 0.16
|
Cohesion: 0.10
|
||||||
Nodes (6): describe(), detect_encoding(), encoded_length(), is_gsm7(), parts_count(), SmsPartsTests
|
Nodes (7): describe(), detect_encoding(), encoded_length(), is_gsm7(), parts_count(), SmsPartsTests, SmsReportTests
|
||||||
|
|
||||||
### Community 48 - "write_tree"
|
### Community 48 - "write_tree"
|
||||||
Cohesion: 0.21
|
Cohesion: 0.21
|
||||||
Nodes (6): Counts, format_header(), generate_snapshot(), normalized_path(), write_tree(), visit()
|
Nodes (6): Counts, format_header(), generate_snapshot(), normalized_path(), write_tree(), visit()
|
||||||
|
|
||||||
### Community 50 - "test_sms.py"
|
### Community 50 - "TestCase"
|
||||||
Cohesion: 0.21
|
Cohesion: 0.20
|
||||||
Nodes (4): enqueue_sms(), is_sms_enabled(), EnqueueIdempotencyTests, RuleTests
|
Nodes (3): is_sms_enabled(), EnqueueIdempotencyTests, RuleTests
|
||||||
|
|
||||||
### Community 51 - "Phone Cleanup Guide"
|
### Community 51 - "Phone Cleanup Guide"
|
||||||
Cohesion: 0.17
|
Cohesion: 0.17
|
||||||
Nodes (11): اجرا روی سرور اصلی (production), اجرای دستور, اصلاح دستی شمارههای قدیمی مشتریان, راه جایگزین: مهاجرت (migration), قاعدهٔ پاکسازی, مشاهدهٔ نتیجه, نکتهها, چرا؟ (+3 more)
|
Nodes (11): اجرا روی سرور اصلی (production), اجرای دستور, اصلاح دستی شمارههای قدیمی مشتریان, راه جایگزین: مهاجرت (migration), قاعدهٔ پاکسازی, مشاهدهٔ نتیجه, نکتهها, چرا؟ (+3 more)
|
||||||
|
|
||||||
### Community 52 - ".run_command"
|
### Community 52 - "Customer"
|
||||||
Cohesion: 0.13
|
Cohesion: 0.10
|
||||||
Nodes (6): ApplyTests, CommandTestMixin, DryRunTests, ResilienceTests, flaky(), sha256_of()
|
Nodes (8): Customer, ApplyTests, CommandTestMixin, DatasetFixtureMixin, DryRunTests, ResilienceTests, flaky(), sha256_of()
|
||||||
|
|
||||||
### Community 53 - "project_tree.py"
|
### Community 53 - "project_tree.py"
|
||||||
Cohesion: 0.23
|
Cohesion: 0.23
|
||||||
@@ -429,21 +437,13 @@ Nodes (6): is_excluded_file(), main(), non_negative_integer(), parse_args(), sor
|
|||||||
Cohesion: 0.18
|
Cohesion: 0.18
|
||||||
Nodes (8): 1. Codebase Navigation & Analysis (Ripwire vs. Graphify), 2. Dynamic Help, Self-Correction & Staleness, 3. Git Operations & Safety, 4. Development Server Lifecycle, 5. Environment, Frontend & PWA Safety, 6. End-to-End Browser Tests (Playwright), Project Operating & Agent Guidelines, Routing Gate & Pipeline
|
Nodes (8): 1. Codebase Navigation & Analysis (Ripwire vs. Graphify), 2. Dynamic Help, Self-Correction & Staleness, 3. Git Operations & Safety, 4. Development Server Lifecycle, 5. Environment, Frontend & PWA Safety, 6. End-to-End Browser Tests (Playwright), Project Operating & Agent Guidelines, Routing Gate & Pipeline
|
||||||
|
|
||||||
### Community 55 - "Command"
|
|
||||||
Cohesion: 0.05
|
|
||||||
Nodes (12): Command, build_name_index(), DatasetError, decide_prefix(), Decision, load_name_index(), NameIndex, normalize_name_key() (+4 more)
|
|
||||||
|
|
||||||
### Community 57 - "normalize_mobile"
|
|
||||||
Cohesion: 0.18
|
|
||||||
Nodes (5): to_display(), _clean(), normalize_mobile(), to_local(), NormalizeTests
|
|
||||||
|
|
||||||
### Community 58 - "Financial Data Objects"
|
### Community 58 - "Financial Data Objects"
|
||||||
Cohesion: 0.20
|
Cohesion: 0.20
|
||||||
Nodes (9): Customer Data Object, Factor Data Object, Sarresid Data Object, admin_panel:customerNew URL, admin_panel:pardakhtNesiye URL, /panel/passCheck/ URL, admin_panel.ViewReportMali Permission, jQuery JS Library (+1 more)
|
Nodes (9): Customer Data Object, Factor Data Object, Sarresid Data Object, admin_panel:customerNew URL, admin_panel:pardakhtNesiye URL, /panel/passCheck/ URL, admin_panel.ViewReportMali Permission, jQuery JS Library (+1 more)
|
||||||
|
|
||||||
### Community 60 - ".run_cmd"
|
### Community 60 - ".run_cmd"
|
||||||
Cohesion: 0.11
|
Cohesion: 0.10
|
||||||
Nodes (5): ApplyTests, BatchAndCheckpointTests, flaky(), CommandTestCase, OverflowTests
|
Nodes (6): ApplyTests, BatchAndCheckpointTests, flaky(), CommandTestCase, DryRunTests, make_phone()
|
||||||
|
|
||||||
### Community 62 - "SMS Timing Migration"
|
### Community 62 - "SMS Timing Migration"
|
||||||
Cohesion: 0.22
|
Cohesion: 0.22
|
||||||
@@ -461,6 +461,10 @@ Nodes (7): Client-side script in customerIDFactorBackup.html, URL: admin_panel:c
|
|||||||
Cohesion: 0.36
|
Cohesion: 0.36
|
||||||
Nodes (4): BaleBotProvider, ConsoleBaleProvider, get_bale_provider(), NotificationResult
|
Nodes (4): BaleBotProvider, ConsoleBaleProvider, get_bale_provider(), NotificationResult
|
||||||
|
|
||||||
|
### Community 66 - "os"
|
||||||
|
Cohesion: 0.20
|
||||||
|
Nodes (3): check_financial_discrepancy(), run_360_audit(), run_audit()
|
||||||
|
|
||||||
### Community 67 - "Graphify Tooling Docs"
|
### Community 67 - "Graphify Tooling Docs"
|
||||||
Cohesion: 0.22
|
Cohesion: 0.22
|
||||||
Nodes (8): Copilot Chat, graphify, graphify-out/graph.json, graphify-out/GRAPH_REPORT.md, graphify-out/wiki/index.md, graphify explain, graphify path, graphify query
|
Nodes (8): Copilot Chat, graphify, graphify-out/graph.json, graphify-out/GRAPH_REPORT.md, graphify-out/wiki/index.md, graphify explain, graphify path, graphify query
|
||||||
@@ -473,9 +477,9 @@ Nodes (6): Customer Data Structure, kamaDatepicker Library, URL: admin_panel:cus
|
|||||||
Cohesion: 0.25
|
Cohesion: 0.25
|
||||||
Nodes (7): admin_panel:newFactor URL, Category Data, Client-side Form Logic, Customer Data, CSRF Token, custom_tags, static
|
Nodes (7): admin_panel:newFactor URL, Category Data, Client-side Form Logic, Customer Data, CSRF Token, custom_tags, static
|
||||||
|
|
||||||
### Community 73 - "SMSResult"
|
### Community 73 - "django_conf"
|
||||||
Cohesion: 0.11
|
Cohesion: 0.13
|
||||||
Nodes (8): SMSProvider, SMSResult, webotp_message(), get_provider_class(), get_sms_provider(), reset_sms_provider(), HTTPSMSProvider, _FailingProvider
|
Nodes (7): SMSProvider, SMSResult, webotp_message(), console_write(), ConsoleSMSProvider, HTTPSMSProvider, _FailingProvider
|
||||||
|
|
||||||
### Community 76 - "Timezone Migration History"
|
### Community 76 - "Timezone Migration History"
|
||||||
Cohesion: 0.29
|
Cohesion: 0.29
|
||||||
@@ -489,6 +493,10 @@ Nodes (5): 3.1 Install a prebuilt binary, 3.2 Build from source, 3.3 Install the
|
|||||||
Cohesion: 0.40
|
Cohesion: 0.40
|
||||||
Nodes (5): In the numbers, In the output, In the tests, The honesty contract, What it misses, and what to run next
|
Nodes (5): In the numbers, In the output, In the tests, The honesty contract, What it misses, and what to run next
|
||||||
|
|
||||||
|
### Community 85 - "PhoneNumber"
|
||||||
|
Cohesion: 0.20
|
||||||
|
Nodes (4): PhoneNumber, IntegrationAndEfficiencyTests, OverflowTests, WelcomeTriggerTests
|
||||||
|
|
||||||
### Community 86 - "Output Format Specs"
|
### Community 86 - "Output Format Specs"
|
||||||
Cohesion: 0.50
|
Cohesion: 0.50
|
||||||
Nodes (4): 6.1 Output example, 6.2 Exit codes, 6.3 JSON output, 6. Output format
|
Nodes (4): 6.1 Output example, 6.2 Exit codes, 6.3 JSON output, 6. Output format
|
||||||
@@ -502,16 +510,16 @@ Cohesion: 0.67
|
|||||||
Nodes (3): SMS Gateway Integration Guide, SMS Settings UI, Python Dependencies
|
Nodes (3): SMS Gateway Integration Guide, SMS Settings UI, Python Dependencies
|
||||||
|
|
||||||
### Community 89 - "CustomerNameFixJob"
|
### Community 89 - "CustomerNameFixJob"
|
||||||
Cohesion: 0.25
|
Cohesion: 0.18
|
||||||
Nodes (6): CustomerNameFixJob, Meta, Status, مدل وضعیت job, چرا؟, سریعترین مسیر روی Production (اگر همین یک دستور را میخواهی)
|
Nodes (7): CacheTable, CustomerNameFixJob, Meta, Status, مدل وضعیت job, چرا؟, سریعترین مسیر روی Production (اگر همین یک دستور را میخواهی)
|
||||||
|
|
||||||
### Community 90 - "Customer Modal UI"
|
### Community 90 - "Customer Modal UI"
|
||||||
Cohesion: 0.67
|
Cohesion: 0.67
|
||||||
Nodes (3): createCS click handler, openRow click handler, Customer Modals UI
|
Nodes (3): createCS click handler, openRow click handler, Customer Modals UI
|
||||||
|
|
||||||
### Community 91 - "PanelTestCase"
|
### Community 91 - "decide_prefix"
|
||||||
Cohesion: 0.24
|
Cohesion: 0.18
|
||||||
Nodes (3): DeletedStaysDeletedTests, PanelTestCase, SentQueueProtectionTests
|
Nodes (4): decide_prefix(), Decision, NameIndex, DecidePrefixTests
|
||||||
|
|
||||||
### Community 92 - "Infrastructure Services"
|
### Community 92 - "Infrastructure Services"
|
||||||
Cohesion: 0.67
|
Cohesion: 0.67
|
||||||
@@ -525,14 +533,6 @@ Nodes (3): 1. (Optional) Register the MCP server, 2. Install the skills, Set it
|
|||||||
Cohesion: 0.67
|
Cohesion: 0.67
|
||||||
Nodes (3): 4.1 Make the first map, 4.2 Use the common commands, 4. First use
|
Nodes (3): 4.1 Make the first map, 4.2 Use the common commands, 4. First use
|
||||||
|
|
||||||
### Community 95 - "ConsoleSMSProvider"
|
|
||||||
Cohesion: 0.21
|
|
||||||
Nodes (3): console_write(), ConsoleSMSProvider, ConsoleEncodingTests
|
|
||||||
|
|
||||||
### Community 96 - "PhoneNumber"
|
|
||||||
Cohesion: 0.22
|
|
||||||
Nodes (4): PhoneNumber, DryRunTests, make_phone(), WelcomeTriggerTests
|
|
||||||
|
|
||||||
### Community 97 - "تجمیع شمارههای پیامک مشتریان"
|
### Community 97 - "تجمیع شمارههای پیامک مشتریان"
|
||||||
Cohesion: 0.14
|
Cohesion: 0.14
|
||||||
Nodes (13): الگوریتم (برای هر مشتری), بازگردانی (اگر نتیجه را نخواستی), بررسی صحت داده (SQL), تجمیع شمارههای پیامک مشتریان, تعریف کلیدهای گزارش, خطاها و اقدام درست, عملکرد و بار روی دیتابیس, نمونهها (+5 more)
|
Nodes (13): الگوریتم (برای هر مشتری), بازگردانی (اگر نتیجه را نخواستی), بررسی صحت داده (SQL), تجمیع شمارههای پیامک مشتریان, تعریف کلیدهای گزارش, خطاها و اقدام درست, عملکرد و بار روی دیتابیس, نمونهها (+5 more)
|
||||||
@@ -541,46 +541,38 @@ Nodes (13): الگوریتم (برای هر مشتری), بازگردانی (ا
|
|||||||
Cohesion: 0.15
|
Cohesion: 0.15
|
||||||
Nodes (11): Base Template, Customer Base Template, New Customer Base Template, Create Factor View, Customer List View, Django Custom Tags: custom_tags, URL: admin_panel:newFactor, URL: admin_panel:operations (+3 more)
|
Nodes (11): Base Template, Customer Base Template, New Customer Base Template, Create Factor View, Customer List View, Django Custom Tags: custom_tags, URL: admin_panel:newFactor, URL: admin_panel:operations (+3 more)
|
||||||
|
|
||||||
### Community 100 - "test_customer_name_fix.py"
|
### Community 100 - "test_sms.py"
|
||||||
Cohesion: 0.09
|
Cohesion: 0.10
|
||||||
Nodes (3): digit_string(), is_landline(), PureHelperTests
|
Nodes (3): digit_string(), is_landline(), PureHelperTests
|
||||||
|
|
||||||
### Community 102 - "customer_phone_entries"
|
|
||||||
Cohesion: 0.20
|
|
||||||
Nodes (3): customer_phone_entries(), _iter_sources(), PhoneEntry
|
|
||||||
|
|
||||||
### Community 103 - "manage.py"
|
### Community 103 - "manage.py"
|
||||||
Cohesion: 0.24
|
Cohesion: 0.22
|
||||||
Nodes (5): entrypoint.sh script, main(), run.sh script, start_service(), usage()
|
Nodes (5): entrypoint.sh script, main(), run.sh script, start_service(), usage()
|
||||||
|
|
||||||
### Community 104 - "افزودن پیشوند «آقای/خانم» به نام مشتریان"
|
### Community 104 - "افزودن پیشوند «آقای/خانم» به نام مشتریان"
|
||||||
Cohesion: 0.15
|
Cohesion: 0.15
|
||||||
Nodes (12): SQLite، قفل دیتابیس و بار روی سایت, افزودن پیشوند «آقای/خانم» به نام مشتریان, بررسی صحت داده (SQL، پیش از/پس از اجرا), بکاپ/بازیابی خودکار نیست, بکاپ پیش از اجرای واقعی, تستها, تعریف کلیدهای آماری, خطاهای رایج و اقدام درست (+4 more)
|
Nodes (12): SQLite، قفل دیتابیس و بار روی سایت, افزودن پیشوند «آقای/خانم» به نام مشتریان, بررسی صحت داده (SQL، پیش از/پس از اجرا), بکاپ/بازیابی خودکار نیست, بکاپ پیش از اجرای واقعی, تستها, تعریف کلیدهای آماری, خطاهای رایج و اقدام درست (+4 more)
|
||||||
|
|
||||||
### Community 105 - "بازمحاسبهٔ «آخرین بازدید» مشتریان"
|
### Community 107 - "signals.py"
|
||||||
Cohesion: 0.20
|
Cohesion: 0.12
|
||||||
Nodes (9): اجرا روی production, بازمحاسبهٔ «آخرین بازدید» مشتریان, بررسی صحت داده (SQL، پیش از اجرا), دستور, دقیقاً چه میکند؟, نمونهٔ خروجی, گزینهها, ۱) اول «شبیهسازی» — بدون هیچ تغییری (+1 more)
|
Nodes (4): AdminConfig, AdminPanelConfig, make_std_streams_unicode_safe(), configure_sqlite_connection()
|
||||||
|
|
||||||
### Community 113 - "primary_phones"
|
### Community 113 - "recipients.py"
|
||||||
Cohesion: 0.25
|
Cohesion: 0.33
|
||||||
Nodes (5): primary_phones(), primary_phones(), customerEdit(), _parse_phone_rows(), ⚠️ هشدار معنای داده (مهمترین نکتهٔ این سند)
|
Nodes (3): primary_phones(), primary_phones(), ⚠️ هشدار معنای داده (مهمترین نکتهٔ این سند)
|
||||||
|
|
||||||
### Community 163 - "Customer"
|
### Community 163 - "Customer"
|
||||||
Cohesion: 0.29
|
Cohesion: 0.29
|
||||||
Nodes (4): Customer, Meta, notify_welcome(), customersAdd()
|
Nodes (3): Customer, Meta, PhoneNumber
|
||||||
|
|
||||||
### Community 164 - "SMSQueue"
|
### Community 164 - "factory.py"
|
||||||
Cohesion: 0.29
|
Cohesion: 0.29
|
||||||
Nodes (4): SMSQueue, DeliveryStatus, Meta, Status
|
Nodes (3): get_provider_class(), get_sms_provider(), reset_sms_provider()
|
||||||
|
|
||||||
### Community 166 - "توقف و ادامه (Resume / Checkpoint)"
|
### Community 166 - "توقف و ادامه (Resume / Checkpoint)"
|
||||||
Cohesion: 0.40
|
Cohesion: 0.40
|
||||||
Nodes (5): اتمیک بودن, توقف و ادامه (Resume / Checkpoint), سلسلهمراتب تصمیم نقطهٔ شروع, فایل وضعیت, گزینههای ادامه
|
Nodes (5): اتمیک بودن, توقف و ادامه (Resume / Checkpoint), سلسلهمراتب تصمیم نقطهٔ شروع, فایل وضعیت, گزینههای ادامه
|
||||||
|
|
||||||
### Community 169 - "index"
|
|
||||||
Cohesion: 0.67
|
|
||||||
Nodes (3): index(), `nest=` is a max, so it cannot tell a long function from a tangled one, The quality panel
|
|
||||||
|
|
||||||
### Community 170 - "الگوریتم تصمیم (به همین ترتیب)"
|
### Community 170 - "الگوریتم تصمیم (به همین ترتیب)"
|
||||||
Cohesion: 0.50
|
Cohesion: 0.50
|
||||||
Nodes (4): الگوریتم تصمیم (به همین ترتیب), برخورد با نامهای تکراری و متناقض, نرمالسازی (فقط برای کلید جستوجو), نمونههای رفتاری
|
Nodes (4): الگوریتم تصمیم (به همین ترتیب), برخورد با نامهای تکراری و متناقض, نرمالسازی (فقط برای کلید جستوجو), نمونههای رفتاری
|
||||||
@@ -613,32 +605,28 @@ Nodes (3): ادامهٔ job پس از crash / قطع برق, دستورهای د
|
|||||||
Cohesion: 0.67
|
Cohesion: 0.67
|
||||||
Nodes (3): اجرا روی سرور Production, ترتیب دقیق, نکتهها برای Production
|
Nodes (3): اجرا روی سرور Production, ترتیب دقیق, نکتهها برای Production
|
||||||
|
|
||||||
### Community 189 - "django_contrib_auth_models"
|
|
||||||
Cohesion: 0.12
|
|
||||||
Nodes (6): VisitQueue, ProductCategory, ProductStockHistory, Meta, StoreRoomOwner, UserProfile
|
|
||||||
|
|
||||||
## Ambiguous Edges - Review These
|
## Ambiguous Edges - Review These
|
||||||
- `admin_panel/templates/admin_panel/baseHesabdari.html` → `Base Template` [AMBIGUOUS]
|
- `admin_panel/templates/admin_panel/baseHesabdari.html` → `Base Template` [AMBIGUOUS]
|
||||||
admin_panel/templates/admin_panel/baseHesabdari.html · relation: conceptually_related_to
|
admin_panel/templates/admin_panel/baseHesabdari.html · relation: conceptually_related_to
|
||||||
|
|
||||||
## Knowledge Gaps
|
## Knowledge Gaps
|
||||||
- **118 isolated node(s):** `base.html`, `toCurrency`, `DataTables Library`, `Customer Data Object`, `Factor Data Object` (+113 more)
|
- **118 isolated node(s):** `base.html`, `toCurrency`, `DataTables Library`, `Customer Data Object`, `Factor Data Object` (+113 more)
|
||||||
These have ≤1 connection - possible missing edges or undocumented components. (Counts symbols only; 841 node(s) total have ≤1 connection when file, concept and rationale nodes are included.)
|
These have ≤1 connection - possible missing edges or undocumented components. (Counts symbols only; 858 node(s) total have ≤1 connection when file, concept and rationale nodes are included.)
|
||||||
- **89 thin communities (<3 nodes) omitted from report** — run `graphify query` to explore isolated nodes.
|
- **120 thin communities (<3 nodes) omitted from report** — run `graphify query` to explore isolated nodes.
|
||||||
|
|
||||||
## Suggested Questions
|
## Suggested Questions
|
||||||
_Questions this graph is uniquely positioned to answer:_
|
_Questions this graph is uniquely positioned to answer:_
|
||||||
|
|
||||||
- **Why does `make_customer()` connect `make_customer` to `run_all`, `PhoneCleanupTests`, `resolve_recipients`, `EventTriggerTests`, `Customer`, `factories.py`, `make_user`, `QueueReviewTests`, `NoticeMechanismTests`, `CustomerSmsHistoryTests`, `stock_in`, `CustomerInfoCardUITests`, `test_sms.py`, `Command`, `.run_cmd`, `IntegrationAndEfficiencyTests`, `PanelTestCase`, `ConsoleSMSProvider`, `PhoneNumber`, `test_customer_name_fix.py`, `customer_phone_entries`, `QueueStatsTests`?**
|
- **Why does `make_customer()` connect `make_customer` to `run_all`, `PhoneCleanupTests`, `PanelTestCase`, `EventTriggerTests`, `InactiveCustomerHiddenTests`, `factories.py`, `make_user`, `QueueReviewTests`, `NoticeMechanismTests`, `CustomerSmsHistoryTests`, `test_inventory.py`, `CustomerInfoCardUITests`, `TestCase`, `.test_deleted_visit_reminder_rearms_next_period_only`, `Customer`, `activate_sms`, `.run_cmd`, `.test_visit_period_comes_from_template`, `.test_period_start_filters_out_very_old_customers`, `PhoneNumber`, `ConsoleEncodingTests`, `test_factors.py`, `test_sms.py`, `WorkerTests`, `QueueStatsTests`?**
|
||||||
_High betweenness centrality (0.075) - this node is a cross-community bridge._
|
_High betweenness centrality (0.071) - this node is a cross-community bridge._
|
||||||
- **Are the 2 inferred relationships involving `make_customer()` (e.g. with `Customer` and `PhoneNumber`) actually correct?**
|
- **Are the 2 inferred relationships involving `make_customer()` (e.g. with `Customer` and `PhoneNumber`) actually correct?**
|
||||||
_`make_customer()` has 2 INFERRED edges - model-reasoned connections that need verification._
|
_`make_customer()` has 2 INFERRED edges - model-reasoned connections that need verification._
|
||||||
- **What connects `base.html`, `toCurrency`, `DataTables Library` to the rest of the system?**
|
- **What connects `base.html`, `toCurrency`, `DataTables Library` to the rest of the system?**
|
||||||
_118 weakly-connected nodes found - possible documentation gaps or missing edges._
|
_118 weakly-connected nodes found - possible documentation gaps or missing edges._
|
||||||
- **Should `views/sms.py` be split into smaller, more focused modules?**
|
- **Should `views/sms.py` be split into smaller, more focused modules?**
|
||||||
_Cohesion score 0.1226890756302521 - nodes in this community are weakly interconnected._
|
_Cohesion score 0.11746031746031746 - nodes in this community are weakly interconnected._
|
||||||
- **Why does `Reference guide` connect `Reference guide` to `.load`, `Installation Guide`, `Output Format Specs`, `Rip'n Fast. Fewer Tokens. Better Code.`, `First Use Guide`?**
|
- **Why does `Rip'n Fast. Fewer Tokens. Better Code.` connect `Rip'n Fast. Fewer Tokens. Better Code.` to `Reference guide`, `.load`, `django_shortcuts`, `Project Honesty Contract`, `Agent Operating Guidelines`, `Performance Benchmarks`, `Agent Setup Guide`?**
|
||||||
_High betweenness centrality (0.038) - this node is a cross-community bridge._
|
_High betweenness centrality (0.053) - this node is a cross-community bridge._
|
||||||
- **Are the 2 inferred relationships involving `normalize_mobile()` (e.g. with `الگوریتم (برای هر مشتری)` and `قالبِ ذخیرهشده: `+989xxxxxxxxx``) actually correct?**
|
- **Are the 2 inferred relationships involving `normalize_mobile()` (e.g. with `الگوریتم (برای هر مشتری)` and `قالبِ ذخیرهشده: `+989xxxxxxxxx``) actually correct?**
|
||||||
_`normalize_mobile()` has 2 INFERRED edges - model-reasoned connections that need verification._
|
_`normalize_mobile()` has 2 INFERRED edges - model-reasoned connections that need verification._
|
||||||
|
|
||||||
|
|||||||
@@ -63,12 +63,12 @@
|
|||||||
</div>
|
</div>
|
||||||
<div id="legend"></div>
|
<div id="legend"></div>
|
||||||
</div>
|
</div>
|
||||||
<div id="stats">1960 nodes · 3584 edges · 184 communities</div>
|
<div id="stats">1991 nodes · 3632 edges · 207 communities</div>
|
||||||
</div>
|
</div>
|
||||||
<script>
|
<script>
|
||||||
const RAW_NODES = [{"id": "admin_panel_management_commands_sms_scan_command", "label": "Command", "color": {"background": "#F28E2B", "border": "#F28E2B", "highlight": {"background": "#ffffff", "border": "#F28E2B"}}, "size": 12.2, "font": {"size": 0, "color": "#ffffff"}, "title": "Command", "community": 1, "community_name": "run_all", "source_file": "admin_panel/management/commands/sms_scan.py", "file_type": "code", "degree": 7}, {"id": "tests_test_sms_autosendtests", "label": "AutoSendTests", "color": {"background": "#F28E2B", "border": "#F28E2B", "highlight": {"background": "#ffffff", "border": "#F28E2B"}}, "size": 13.8, "font": {"size": 0, "color": "#ffffff"}, "title": "AutoSendTests", "community": 1, "community_name": "run_all", "source_file": "tests/test_sms.py", "file_type": "code", "degree": 12}, {"id": "tests_test_sms_coldstarttests", "label": "ColdStartTests", "color": {"background": "#F28E2B", "border": "#F28E2B", "highlight": {"background": "#ffffff", "border": "#F28E2B"}}, "size": 13.8, "font": {"size": 0, "color": "#ffffff"}, "title": "ColdStartTests", "community": 1, "community_name": "run_all", "source_file": "tests/test_sms.py", "file_type": "code", "degree": 12}, {"id": "tests_test_sms_scannertests", "label": "ScannerTests", "color": {"background": "#F28E2B", "border": "#F28E2B", "highlight": {"background": "#ffffff", "border": "#F28E2B"}}, "size": 14.7, "font": {"size": 12, "color": "#ffffff"}, "title": "ScannerTests", "community": 1, "community_name": "run_all", "source_file": "tests/test_sms.py", "file_type": "code", "degree": 15}, {"id": "tests_test_sms_workertests", "label": "WorkerTests", "color": {"background": "#F28E2B", "border": "#F28E2B", "highlight": {"background": "#ffffff", "border": "#F28E2B"}}, "size": 12.8, "font": {"size": 0, "color": "#ffffff"}, "title": "WorkerTests", "community": 1, "community_name": "run_all", "source_file": "tests/test_sms.py", "file_type": "code", "degree": 9}, {"id": "admin_panel_migrations_0001_initial_migration", "label": "Migration", "color": {"background": "#4E79A7", "border": "#4E79A7", "highlight": {"background": "#ffffff", "border": "#4E79A7"}}, "size": 10.3, "font": {"size": 0, "color": "#ffffff"}, "title": "Migration", "community": 10, "community_name": "django_conf", "source_file": "admin_panel/migrations/0001_initial.py", "file_type": "code", "degree": 1}, {"id": "admin_panel_migrations_0007_productinstore_factor_migration", "label": "Migration", "color": {"background": "#4E79A7", "border": "#4E79A7", "highlight": {"background": "#ffffff", "border": "#4E79A7"}}, "size": 10.3, "font": {"size": 0, "color": "#ffffff"}, "title": "Migration", "community": 10, "community_name": "django_conf", "source_file": "admin_panel/migrations/0007_productinstore_factor.py", "file_type": "code", "degree": 1}, {"id": "admin_panel_migrations_0008_storeroom_user_migration", "label": "Migration", "color": {"background": "#4E79A7", "border": "#4E79A7", "highlight": {"background": "#ffffff", "border": "#4E79A7"}}, "size": 10.3, "font": {"size": 0, "color": "#ffffff"}, "title": "Migration", "community": 10, "community_name": "django_conf", "source_file": "admin_panel/migrations/0008_storeroom_user.py", "file_type": "code", "degree": 1}, {"id": "admin_panel_migrations_0011_storeroom_user_migration", "label": "Migration", "color": {"background": "#4E79A7", "border": "#4E79A7", "highlight": {"background": "#ffffff", "border": "#4E79A7"}}, "size": 10.3, "font": {"size": 0, "color": "#ffffff"}, "title": "Migration", "community": 10, "community_name": "django_conf", "source_file": "admin_panel/migrations/0011_storeroom_user.py", "file_type": "code", "degree": 1}, {"id": "admin_panel_migrations_0012_visitqueue_user_migration", "label": "Migration", "color": {"background": "#4E79A7", "border": "#4E79A7", "highlight": {"background": "#ffffff", "border": "#4E79A7"}}, "size": 10.3, "font": {"size": 0, "color": "#ffffff"}, "title": "Migration", "community": 10, "community_name": "django_conf", "source_file": "admin_panel/migrations/0012_visitqueue_user.py", "file_type": "code", "degree": 1}, {"id": "admin_panel_migrations_0015_auto_20220124_0858_migration", "label": "Migration", "color": {"background": "#4E79A7", "border": "#4E79A7", "highlight": {"background": "#ffffff", "border": "#4E79A7"}}, "size": 10.3, "font": {"size": 0, "color": "#ffffff"}, "title": "Migration", "community": 10, "community_name": "django_conf", "source_file": "admin_panel/migrations/0015_auto_20220124_0858.py", "file_type": "code", "degree": 1}, {"id": "admin_panel_migrations_0016_factor_user_migration", "label": "Migration", "color": {"background": "#4E79A7", "border": "#4E79A7", "highlight": {"background": "#ffffff", "border": "#4E79A7"}}, "size": 10.3, "font": {"size": 0, "color": "#ffffff"}, "title": "Migration", "community": 10, "community_name": "django_conf", "source_file": "admin_panel/migrations/0016_factor_user.py", "file_type": "code", "degree": 1}, {"id": "admin_panel_migrations_0018_alterLine truncated
|
const RAW_NODES = [{"id": "tests_test_sms_autosendtests", "label": "AutoSendTests", "color": {"background": "#F28E2B", "border": "#F28E2B", "highlight": {"background": "#ffffff", "border": "#F28E2B"}}, "size": 13.7, "font": {"size": 0, "color": "#ffffff"}, "title": "AutoSendTests", "community": 1, "community_name": "run_all", "source_file": "tests/test_sms.py", "file_type": "code", "degree": 12}, {"id": "admin_panel_migrations_0001_initial_migration", "label": "Migration", "color": {"background": "#4E79A7", "border": "#4E79A7", "highlight": {"background": "#ffffff", "border": "#4E79A7"}}, "size": 10.3, "font": {"size": 0, "color": "#ffffff"}, "title": "Migration", "community": 10, "community_name": "django_db_models_deletion", "source_file": "admin_panel/migrations/0001_initial.py", "file_type": "code", "degree": 1}, {"id": "admin_panel_migrations_0007_productinstore_factor_migration", "label": "Migration", "color": {"background": "#4E79A7", "border": "#4E79A7", "highlight": {"background": "#ffffff", "border": "#4E79A7"}}, "size": 10.3, "font": {"size": 0, "color": "#ffffff"}, "title": "Migration", "community": 10, "community_name": "django_db_models_deletion", "source_file": "admin_panel/migrations/0007_productinstore_factor.py", "file_type": "code", "degree": 1}, {"id": "admin_panel_migrations_0008_storeroom_user_migration", "label": "Migration", "color": {"background": "#4E79A7", "border": "#4E79A7", "highlight": {"background": "#ffffff", "border": "#4E79A7"}}, "size": 10.3, "font": {"size": 0, "color": "#ffffff"}, "title": "Migration", "community": 10, "community_name": "django_db_models_deletion", "source_file": "admin_panel/migrations/0008_storeroom_user.py", "file_type": "code", "degree": 1}, {"id": "admin_panel_migrations_0011_storeroom_user_migration", "label": "Migration", "color": {"background": "#4E79A7", "border": "#4E79A7", "highlight": {"background": "#ffffff", "border": "#4E79A7"}}, "size": 10.3, "font": {"size": 0, "color": "#ffffff"}, "title": "Migration", "community": 10, "community_name": "django_db_models_deletion", "source_file": "admin_panel/migrations/0011_storeroom_user.py", "file_type": "code", "degree": 1}, {"id": "admin_panel_migrations_0012_visitqueue_user_migration", "label": "Migration", "color": {"background": "#4E79A7", "border": "#4E79A7", "highlight": {"background": "#ffffff", "border": "#4E79A7"}}, "size": 10.3, "font": {"size": 0, "color": "#ffffff"}, "title": "Migration", "community": 10, "community_name": "django_db_models_deletion", "source_file": "admin_panel/migrations/0012_visitqueue_user.py", "file_type": "code", "degree": 1}, {"id": "admin_panel_migrations_0016_factor_user_migration", "label": "Migration", "color": {"background": "#4E79A7", "border": "#4E79A7", "highlight": {"background": "#ffffff", "border": "#4E79A7"}}, "size": 10.3, "font": {"size": 0, "color": "#ffffff"}, "title": "Migration", "community": 10, "community_name": "django_db_models_deletion", "source_file": "admin_panel/migrations/0016_factor_user.py", "file_type": "code", "degree": 1}, {"id": "admin_panel_migrations_0018_alter_factoriteam_product_migration", "label": "Migration", "color": {"background": "#4E79A7", "border": "#4E79A7", "highlight": {"background": "#ffffff", "border": "#4E79A7"}}, "size": 10.3, "font": {"size": 0, "color": "#ffffff"}, "title": "Migration", "community": 10, "community_name": "django_db_models_deletion", "source_file": "admin_panel/migrations/0018_alter_factoriteam_product.py", "file_type": "code", "degree": 1}, {"id": "admin_panel_migrations_0019_article_articletype_migration", "label": "Migration", "color": {"background": "#4E79A7", "border": "#4E79A7", "highlight": {"background": "#ffffff", "border": "#4E79A7"}}, "size": 10.3, "font": {"size": 0, "color": "#ffffff"}, "title": "Migration", "community": 10, "community_name": "django_db_models_deletion", "source_file": "admin_panel/migrations/0019_article_articletype.py", "file_type": "code", "degree": 1}, {"id": "admin_panel_migrations_0024_action_migration", "label": "Migration", "color": {"background": "#4E79A7", "border": "#4E79A7", "highlight": {"background": "#ffffff", "border": "#4E79A7"}}, "size": 10.3, "font": {"size": 0, "color": "#ffffff"}, "title": "Migration", "community": 10, "community_name": "django_db_models_deletion", "source_file": "admin_panel/migrations/0024_action.py", "file_type": "code", "degree": 1}, {"id": "admin_panel_migrations_0036_sarresid_migration", "label": "Migration", "color": {"background": "#4E79A7", "border": "#4E79A7", "highlight": {"background": "#ffffff", "border": "#4E79A7"}}, "size": 10.3, "font": {"size": 0, "color": "#ffffff"}, "title": "Migration", "community": 10, "community_name": "django_db_models_deletion", "source_file": "admin_panel/migrations/0036_sarresid.py", "file_type": "code", "degree": 1}, {"id": "admin_panel_migrations_0038_auto_20240213_1735_migration", "label": "Migration", "color": {"background": "#4E79A7", "border": "#4E79A7", "highlight": {"backgrounLine truncated
|
||||||
const RAW_EDGES = [{"from": "admin_panel_management_commands_sms_scan", "to": "admin_panel_management_commands_sms_scan_command", "label": "contains", "title": "contains [EXTRACTED]", "dashes": false, "width": 2, "color": {"opacity": 0.7}, "confidence": "EXTRACTED"}, {"from": "admin_panel_management_commands_sms_scan_command", "to": "admin_panel_management_commands_sms_scan_py_basecommand", "label": "inherits", "title": "inherits [EXTRACTED]", "dashes": false, "width": 2, "color": {"opacity": 0.7}, "confidence": "EXTRACTED"}, {"from": "admin_panel_management_commands_sms_scan_command", "to": "admin_panel_management_commands_sms_scan_command_init", "label": "method", "title": "method [EXTRACTED]", "dashes": false, "width": 2, "color": {"opacity": 0.7}, "confidence": "EXTRACTED"}, {"from": "admin_panel_management_commands_sms_scan_command", "to": "admin_panel_management_commands_sms_scan_command_add_arguments", "label": "method", "title": "method [EXTRACTED]", "dashes": false, "width": 2, "color": {"opacity": 0.7}, "confidence": "EXTRACTED"}, {"from": "admin_panel_management_commands_sms_scan_command", "to": "admin_panel_management_commands_sms_scan_command_handle", "label": "method", "title": "method [EXTRACTED]", "dashes": false, "width": 2, "color": {"opacity": 0.7}, "confidence": "EXTRACTED"}, {"from": "admin_panel_management_commands_sms_scan_command", "to": "admin_panel_management_commands_sms_scan_command_handle_signal", "label": "method", "title": "method [EXTRACTED]", "dashes": false, "width": 2, "color": {"opacity": 0.7}, "confidence": "EXTRACTED"}, {"from": "admin_panel_management_commands_sms_scan_command", "to": "admin_panel_management_commands_sms_scan_command_emit", "label": "method", "title": "method [EXTRACTED]", "dashes": false, "width": 2, "color": {"opacity": 0.7}, "confidence": "EXTRACTED"}, {"from": "tests_test_sms", "to": "tests_test_sms_autosendtests", "label": "contains", "title": "contains [EXTRACTED]", "dashes": false, "width": 2, "color": {"opacity": 0.7}, "confidence": "EXTRACTED"}, {"from": "tests_test_sms_autosendtests", "to": "tests_test_sms_paneltestcase", "label": "inherits", "title": "inherits [EXTRACTED]", "dashes": false, "width": 2, "color": {"opacity": 0.7}, "confidence": "EXTRACTED"}, {"from": "tests_test_sms_autosendtests", "to": "tests_test_sms_autosendtests_overdue_visit_customer", "label": "method", "title": "method [EXTRACTED]", "dashes": false, "width": 2, "color": {"opacity": 0.7}, "confidence": "EXTRACTED"}, {"from": "tests_test_sms_autosendtests", "to": "tests_test_sms_autosendtests_test_default_is_pending_so_manager_must_approve", "label": "method", "title": "method [EXTRACTED]", "dashes": false, "width": 2, "color": {"opacity": 0.7}, "confidence": "EXTRACTED"}, {"from": "tests_test_sms_autosendtests", "to": "tests_test_sms_autosendtests_test_auto_send_enqueues_approved_without_manager", "label": "method", "title": "method [EXTRACTED]", "dashes": false, "width": 2, "color": {"opacity": 0.7}, "confidence": "EXTRACTED"}, {"from": "tests_test_sms_autosendtests", "to": "tests_test_sms_autosendtests_test_auto_send_does_not_bypass_opt_out_or_disabled", "label": "method", "title": "method [EXTRACTED]", "dashes": false, "width": 2, "color": {"opacity": 0.7}, "confidence": "EXTRACTED"}, {"from": "tests_test_sms_autosendtests", "to": "tests_test_sms_autosendtests_test_welcome_auto_send_skips_approval_on_customer_creation", "label": "method", "title": "method [EXTRACTED]", "dashes": false, "width": 2, "color": {"opacity": 0.7}, "confidence": "EXTRACTED"}, {"from": "tests_test_sms_autosendtests", "to": "tests_test_sms_autosendtests_test_template_edit_persists_auto_send", "label": "method", "title": "method [EXTRACTED]", "dashes": false, "width": 2, "color": {"opacity": 0.7}, "confidence": "EXTRACTED"}, {"from": "tests_test_sms_autosendtests", "to": "tests_test_sms_autosendtests_test_templates_page_shows_auto_send_switch", "label": "method", "title": "method [EXTRACTED]", "dashes": false, "width": 2, "color": {"opacity": 0.7}, "confidence": "EXTRACTED"}, {"from": "tests_test_sms_rationale_1069", "to": "tests_test_sms_autosendtests", "label": "rationale_for", "title": "rationale_for [EXTRACTED]", "dashes": false, "width": 2, "color": {"opacity": 0.7}, "confidence": "EXTRACTED"}, {"from": "tests_test_sms_autosendtests", "to": "admin_models_phonenumber", "label": "uses", "title": "uses [INFERRED]", "dashes": true, "width": 1, "color": {"opacity": 0.35}, "confidence": "INFERRED"}, {"from": "tests_test_sms_autosendtests", "to": "admin_models_customer", "label": "uses", "title": "uses [INFERRED]", "dashes": true, "width": 1, "color": {"opacity": 0.35}, "confidence": "INFERRED"}, {"from": "tests_test_sms", "to": "tests_test_sms_coldstarttests", "label": "contains", "title": "contains [EXTRACTED]", "dashes": false, "width": 2, "color": {"opacity": 0.7}, "confidence": "EXTRACTED"}, {"from": "tests_test_sms_coldstarttests", "to": "tests_test_sms_py_testcase", "label": "inherits", "Line truncated
|
const RAW_EDGES = [{"from": "tests_test_sms", "to": "tests_test_sms_autosendtests", "label": "contains", "title": "contains [EXTRACTED]", "dashes": false, "width": 2, "color": {"opacity": 0.7}, "confidence": "EXTRACTED"}, {"from": "tests_test_sms_autosendtests", "to": "tests_test_sms_paneltestcase", "label": "inherits", "title": "inherits [EXTRACTED]", "dashes": false, "width": 2, "color": {"opacity": 0.7}, "confidence": "EXTRACTED"}, {"from": "tests_test_sms_autosendtests", "to": "tests_test_sms_autosendtests_overdue_visit_customer", "label": "method", "title": "method [EXTRACTED]", "dashes": false, "width": 2, "color": {"opacity": 0.7}, "confidence": "EXTRACTED"}, {"from": "tests_test_sms_autosendtests", "to": "tests_test_sms_autosendtests_test_default_is_pending_so_manager_must_approve", "label": "method", "title": "method [EXTRACTED]", "dashes": false, "width": 2, "color": {"opacity": 0.7}, "confidence": "EXTRACTED"}, {"from": "tests_test_sms_autosendtests", "to": "tests_test_sms_autosendtests_test_auto_send_enqueues_approved_without_manager", "label": "method", "title": "method [EXTRACTED]", "dashes": false, "width": 2, "color": {"opacity": 0.7}, "confidence": "EXTRACTED"}, {"from": "tests_test_sms_autosendtests", "to": "tests_test_sms_autosendtests_test_auto_send_does_not_bypass_opt_out_or_disabled", "label": "method", "title": "method [EXTRACTED]", "dashes": false, "width": 2, "color": {"opacity": 0.7}, "confidence": "EXTRACTED"}, {"from": "tests_test_sms_autosendtests", "to": "tests_test_sms_autosendtests_test_welcome_auto_send_skips_approval_on_customer_creation", "label": "method", "title": "method [EXTRACTED]", "dashes": false, "width": 2, "color": {"opacity": 0.7}, "confidence": "EXTRACTED"}, {"from": "tests_test_sms_autosendtests", "to": "tests_test_sms_autosendtests_test_template_edit_persists_auto_send", "label": "method", "title": "method [EXTRACTED]", "dashes": false, "width": 2, "color": {"opacity": 0.7}, "confidence": "EXTRACTED"}, {"from": "tests_test_sms_autosendtests", "to": "tests_test_sms_autosendtests_test_templates_page_shows_auto_send_switch", "label": "method", "title": "method [EXTRACTED]", "dashes": false, "width": 2, "color": {"opacity": 0.7}, "confidence": "EXTRACTED"}, {"from": "tests_test_sms_rationale_1183", "to": "tests_test_sms_autosendtests", "label": "rationale_for", "title": "rationale_for [EXTRACTED]", "dashes": false, "width": 2, "color": {"opacity": 0.7}, "confidence": "EXTRACTED"}, {"from": "tests_test_sms_autosendtests", "to": "admin_models_phonenumber", "label": "uses", "title": "uses [INFERRED]", "dashes": true, "width": 1, "color": {"opacity": 0.35}, "confidence": "INFERRED"}, {"from": "tests_test_sms_autosendtests", "to": "admin_models_customer", "label": "uses", "title": "uses [INFERRED]", "dashes": true, "width": 1, "color": {"opacity": 0.35}, "confidence": "INFERRED"}, {"from": "admin_panel_migrations_0001_initial", "to": "admin_panel_migrations_0001_initial_migration", "label": "contains", "title": "contains [EXTRACTED]", "dashes": false, "width": 2, "color": {"opacity": 0.7}, "confidence": "EXTRACTED"}, {"from": "admin_panel_migrations_0007_productinstore_factor", "to": "admin_panel_migrations_0007_productinstore_factor_migration", "label": "contains", "title": "contains [EXTRACTED]", "dashes": false, "width": 2, "color": {"opacity": 0.7}, "confidence": "EXTRACTED"}, {"from": "admin_panel_migrations_0008_storeroom_user", "to": "admin_panel_migrations_0008_storeroom_user_migration", "label": "contains", "title": "contains [EXTRACTED]", "dashes": false, "width": 2, "color": {"opacity": 0.7}, "confidence": "EXTRACTED"}, {"from": "admin_panel_migrations_0011_storeroom_user", "to": "admin_panel_migrations_0011_storeroom_user_migration", "label": "contains", "title": "contains [EXTRACTED]", "dashes": false, "width": 2, "color": {"opacity": 0.7}, "confidence": "EXTRACTED"}, {"from": "admin_panel_migrations_0012_visitqueue_user", "to": "admin_panel_migrations_0012_visitqueue_user_migration", "label": "contains", "title": "contains [EXTRACTED]", "dashes": false, "width": 2, "color": {"opacity": 0.7}, "confidence": "EXTRACTED"}, {"from": "admin_panel_migrations_0016_factor_user", "to": "admin_panel_migrations_0016_factor_user_migration", "label": "contains", "title": "contains [EXTRACTED]", "dashes": false, "width": 2, "color": {"opacity": 0.7}, "confidence": "EXTRACTED"}, {"from": "admin_panel_migrations_0018_alter_factoriteam_product", "to": "admin_panel_migrations_0018_alter_factoriteam_product_migration", "label": "contains", "title": "contains [EXTRACTED]", "dashes": false, "width": 2, "color": {"opacity": 0.7}, "confidence": "EXTRACTED"}, {"from": "admin_panel_migrations_0019_article_articletype", "to": "admin_panel_migrations_0019_article_articletype_migration", "label": "contains", "title": "contains [EXTRACTED]", "dashes": false, "width": 2, "color": {"opacity": 0.7}, "confidence": "EXTRACTED"}, {"from": "admin_panel_migrations_0024_action", "to": "adminLine truncated
|
||||||
const LEGEND = [{"cid": 0, "color": "#4E79A7", "label": "views/sms.py", "count": 35}, {"cid": 1, "color": "#F28E2B", "label": "run_all", "count": 65}, {"cid": 2, "color": "#E15759", "label": "django_db", "count": 79}, {"cid": 3, "color": "#76B7B2", "label": "create_transfer_request", "count": 42}, {"cid": 4, "color": "#59A14F", "label": "ModelsPackageContractTests", "count": 14}, {"cid": 5, "color": "#EDC948", "label": "PhoneCleanupTests", "count": 33}, {"cid": 6, "color": "#B07AA1", "label": ".load", "count": 19}, {"cid": 7, "color": "#FF9DA7", "label": "models/sms.py", "count": 16}, {"cid": 8, "color": "#9C755F", "label": "models/__init__.py", "count": 39}, {"cid": 9, "color": "#BAB0AC", "label": "views/customers.py", "count": 19}, {"cid": 10, "color": "#4E79A7", "label": "django_conf", "count": 50}, {"cid": 11, "color": "#F28E2B", "label": "make_customer", "count": 20}, {"cid": 12, "color": "#E15759", "label": "resolve_recipients", "count": 19}, {"cid": 13, "color": "#76B7B2", "label": "EventTriggerTests", "count": 22}, {"cid": 14, "color": "#59A14F", "label": "views/finance.py", "count": 27}, {"cid": 15, "color": "#EDC948", "label": "views/store.py", "count": 26}, {"cid": 16, "color": "#B07AA1", "label": "sms0098ws.py", "count": 10}, {"cid": 17, "color": "#FF9DA7", "label": "Admin Panel Customization", "count": 22}, {"cid": 18, "color": "#9C755F", "label": "User", "count": 24}, {"cid": 19, "color": "#BAB0AC", "label": "timing_for", "count": 20}, {"cid": 20, "color": "#4E79A7", "label": "Customer", "count": 22}, {"cid": 21, "color": "#F28E2B", "label": "phones.py", "count": 14}, {"cid": 22, "color": "#E15759", "label": "Login Template Forms", "count": 19}, {"cid": 23, "color": "#76B7B2", "label": "cheque_inquiry.py", "count": 20}, {"cid": 24, "color": "#59A14F", "label": "factories.py", "count": 21}, {"cid": 25, "color": "#EDC948", "label": "Rip'n Fast. Fewer Tokens. Better Code.", "count": 18}, {"cid": 26, "color": "#B07AA1", "label": "views/factors.py", "count": 25}, {"cid": 27, "color": "#FF9DA7", "label": "sms0098.py", "count": 14}, {"cid": 28, "color": "#9C755F", "label": "make_user", "count": 23}, {"cid": 29, "color": "#BAB0AC", "label": "QueueReviewTests", "count": 9}, {"cid": 30, "color": "#4E79A7", "label": "NoticeMechanismTests", "count": 16}, {"cid": 31, "color": "#F28E2B", "label": "SmsReportTests", "count": 17}, {"cid": 32, "color": "#E15759", "label": "scanner.py", "count": 16}, {"cid": 33, "color": "#76B7B2", "label": "ZeroZeroNinetyEightWebServiceTests", "count": 38}, {"cid": 34, "color": "#59A14F", "label": "ProductInStore", "count": 6}, {"cid": 35, "color": "#EDC948", "label": "admin_panel/templates/admin_panel/baseHesabdari.html", "count": 15}, {"cid": 36, "color": "#B07AA1", "label": "Reference guide", "count": 14}, {"cid": 37, "color": "#FF9DA7", "label": "Factor Creation UI", "count": 14}, {"cid": 38, "color": "#9C755F", "label": "hesabdariResidNashode.html", "count": 14}, {"cid": 39, "color": "#BAB0AC", "label": "requests", "count": 13}, {"cid": 40, "color": "#4E79A7", "label": "CustomerSmsHistoryTests", "count": 24}, {"cid": 41, "color": "#F28E2B", "label": "TemplatesPageUITests", "count": 14}, {"cid": 42, "color": "#E15759", "label": "SMS Integration Docs", "count": 13}, {"cid": 43, "color": "#76B7B2", "label": "enqueue.py", "count": 14}, {"cid": 44, "color": "#59A14F", "label": "django_shortcuts", "count": 15}, {"cid": 45, "color": "#EDC948", "label": "stock_in", "count": 27}, {"cid": 46, "color": "#B07AA1", "label": "admin_panel/templates/admin_panel/customerAction.html", "count": 13}, {"cid": 47, "color": "#FF9DA7", "label": "parts_count", "count": 22}, {"cid": 48, "color": "#9C755F", "label": "write_tree", "count": 14}, {"cid": 49, "color": "#BAB0AC", "label": "CustomerInfoCardUITests", "count": 12}, {"cid": 50, "color": "#4E79A7", "label": "test_sms.py", "count": 14}, {"cid": 51, "color": "#F28E2B", "label": "Phone Cleanup Guide", "count": 12}, {"cid": 52, "color": "#E15759", "label": ".run_command", "count": 40}, {"cid": 53, "color": "#76B7B2", "label": "project_tree.py", "count": 14}, {"cid": 54, "color": "#59A14F", "label": "Agent Operating Guidelines", "count": 11}, {"cid": 55, "color": "#EDC948", "label": "Command", "count": 72}, {"cid": 56, "color": "#B07AA1", "label": "BehinSMSProvider", "count": 8}, {"cid": 57, "color": "#FF9DA7", "label": "normalize_mobile", "count": 17}, {"cid": 58, "color": "#9C755F", "label": "Financial Data Objects", "count": 10}, {"cid": 59, "color": "#BAB0AC", "label": "Hybrid Migration Command", "count": 10}, {"cid": 60, "color": "#4E79A7", "label": ".run_cmd", "count": 34}, {"cid": 61, "color": "#F28E2B", "label": "Resource Cleanup Handler", "count": 10}, {"cid": 62, "color": "#E15759", "label": "SMS Timing Migration", "count": 10}, {"cid": 63, "color": "#76B7B2", "label": "admin_panel/templates/admin_panel/bedehi.html", "count": 10}, {"cid": 64, "color": "#59A14F", "label": "customerIDFactorBackup.html", "count": 10}, {"cid": 65, "colLine truncated
|
const LEGEND = [{"cid": 0, "color": "#4E79A7", "label": "views/sms.py", "count": 36}, {"cid": 1, "color": "#F28E2B", "label": "run_all", "count": 15}, {"cid": 2, "color": "#E15759", "label": "django_db", "count": 41}, {"cid": 3, "color": "#76B7B2", "label": "views/store.py", "count": 73}, {"cid": 4, "color": "#59A14F", "label": "ModelsPackageContractTests", "count": 8}, {"cid": 5, "color": "#EDC948", "label": "PhoneCleanupTests", "count": 31}, {"cid": 6, "color": "#B07AA1", "label": ".load", "count": 13}, {"cid": 7, "color": "#FF9DA7", "label": "models/sms.py", "count": 32}, {"cid": 8, "color": "#9C755F", "label": "django_db_models", "count": 27}, {"cid": 9, "color": "#BAB0AC", "label": "views/customers.py", "count": 32}, {"cid": 10, "color": "#4E79A7", "label": "django_db_models_deletion", "count": 39}, {"cid": 11, "color": "#F28E2B", "label": "PanelTestCase", "count": 16}, {"cid": 12, "color": "#E15759", "label": "make_customer", "count": 37}, {"cid": 13, "color": "#76B7B2", "label": "EventTriggerTests", "count": 22}, {"cid": 14, "color": "#59A14F", "label": "views/finance.py", "count": 24}, {"cid": 15, "color": "#EDC948", "label": "django_contrib", "count": 7}, {"cid": 16, "color": "#B07AA1", "label": "sms0098ws.py", "count": 23}, {"cid": 17, "color": "#FF9DA7", "label": "Admin Panel Customization", "count": 22}, {"cid": 18, "color": "#9C755F", "label": "User", "count": 49}, {"cid": 19, "color": "#BAB0AC", "label": "apply_timing", "count": 22}, {"cid": 20, "color": "#4E79A7", "label": "InactiveCustomerHiddenTests", "count": 16}, {"cid": 21, "color": "#F28E2B", "label": "normalize_mobile", "count": 31}, {"cid": 22, "color": "#E15759", "label": "Login Template Forms", "count": 19}, {"cid": 23, "color": "#76B7B2", "label": "cheque_inquiry.py", "count": 20}, {"cid": 24, "color": "#59A14F", "label": "factories.py", "count": 13}, {"cid": 25, "color": "#EDC948", "label": "Rip'n Fast. Fewer Tokens. Better Code.", "count": 18}, {"cid": 26, "color": "#B07AA1", "label": "views/factors.py", "count": 29}, {"cid": 27, "color": "#FF9DA7", "label": "sms0098.py", "count": 14}, {"cid": 28, "color": "#9C755F", "label": "make_user", "count": 23}, {"cid": 29, "color": "#BAB0AC", "label": "QueueReviewTests", "count": 9}, {"cid": 30, "color": "#4E79A7", "label": "NoticeMechanismTests", "count": 16}, {"cid": 31, "color": "#F28E2B", "label": "name_fix.py", "count": 24}, {"cid": 32, "color": "#E15759", "label": "enqueue_sms", "count": 18}, {"cid": 33, "color": "#76B7B2", "label": "ZeroZeroNinetyEightWebServiceTests", "count": 35}, {"cid": 34, "color": "#59A14F", "label": "ScannerTests", "count": 23}, {"cid": 35, "color": "#EDC948", "label": "admin_panel/templates/admin_panel/baseHesabdari.html", "count": 15}, {"cid": 36, "color": "#B07AA1", "label": "Reference guide", "count": 14}, {"cid": 37, "color": "#FF9DA7", "label": "Factor Creation UI", "count": 14}, {"cid": 38, "color": "#9C755F", "label": "hesabdariResidNashode.html", "count": 14}, {"cid": 39, "color": "#BAB0AC", "label": "requests", "count": 11}, {"cid": 40, "color": "#4E79A7", "label": "CustomerSmsHistoryTests", "count": 24}, {"cid": 41, "color": "#F28E2B", "label": "TemplatesPageUITests", "count": 14}, {"cid": 42, "color": "#E15759", "label": "SMS Integration Docs", "count": 13}, {"cid": 43, "color": "#76B7B2", "label": "enqueue.py", "count": 20}, {"cid": 44, "color": "#59A14F", "label": "django_shortcuts", "count": 15}, {"cid": 45, "color": "#EDC948", "label": "test_inventory.py", "count": 30}, {"cid": 46, "color": "#B07AA1", "label": "admin_panel/templates/admin_panel/customerAction.html", "count": 13}, {"cid": 47, "color": "#FF9DA7", "label": "SmsReportTests", "count": 39}, {"cid": 48, "color": "#9C755F", "label": "write_tree", "count": 14}, {"cid": 49, "color": "#BAB0AC", "label": "CustomerInfoCardUITests", "count": 13}, {"cid": 50, "color": "#4E79A7", "label": "TestCase", "count": 10}, {"cid": 51, "color": "#F28E2B", "label": "Phone Cleanup Guide", "count": 12}, {"cid": 52, "color": "#E15759", "label": "Customer", "count": 47}, {"cid": 53, "color": "#76B7B2", "label": "project_tree.py", "count": 13}, {"cid": 54, "color": "#59A14F", "label": "Agent Operating Guidelines", "count": 11}, {"cid": 55, "color": "#EDC948", "label": "Command", "count": 28}, {"cid": 56, "color": "#B07AA1", "label": "BehinSMSProvider", "count": 8}, {"cid": 57, "color": "#FF9DA7", "label": "activate_sms", "count": 15}, {"cid": 58, "color": "#9C755F", "label": "Financial Data Objects", "count": 10}, {"cid": 59, "color": "#BAB0AC", "label": "Hybrid Migration Command", "count": 10}, {"cid": 60, "color": "#4E79A7", "label": ".run_cmd", "count": 37}, {"cid": 61, "color": "#F28E2B", "label": "Resource Cleanup Handler", "count": 10}, {"cid": 62, "color": "#E15759", "label": "SMS Timing Migration", "count": 10}, {"cid": 63, "color": "#76B7B2", "label": "admin_panel/templates/admin_panel/bedehi.html", "count": 10}, {"cid": 64, "color": "#59A14F", "label": "customerIDFactorBackup.html", "count":Line truncated
|
||||||
|
|
||||||
// HTML-escape helper — prevents XSS when injecting graph data into innerHTML
|
// HTML-escape helper — prevents XSS when injecting graph data into innerHTML
|
||||||
function esc(s) {
|
function esc(s) {
|
||||||
|
|||||||
+7981
-7064
File diff suppressed because it is too large.
Load diff
+50
-26
@@ -960,9 +960,9 @@
|
|||||||
"semantic_hash": "364f4190f934261a082c189d8f44d29c"
|
"semantic_hash": "364f4190f934261a082c189d8f44d29c"
|
||||||
},
|
},
|
||||||
"admin_panel/views/customers.py": {
|
"admin_panel/views/customers.py": {
|
||||||
"mtime": 1791711853.5219991,
|
"mtime": 1792159253.0096061,
|
||||||
"seen": 1791714098.633808,
|
"seen": 1792493391.7541184,
|
||||||
"ast_hash": "43945c8ffdbde356447635b1203b9566",
|
"ast_hash": "a4371b66153ff8afa68452aa35026d84",
|
||||||
"semantic_hash": ""
|
"semantic_hash": ""
|
||||||
},
|
},
|
||||||
"admin_panel/views/factors.py": {
|
"admin_panel/views/factors.py": {
|
||||||
@@ -1098,9 +1098,9 @@
|
|||||||
"semantic_hash": "be04ce55506d075f0afe266cadeda08e"
|
"semantic_hash": "be04ce55506d075f0afe266cadeda08e"
|
||||||
},
|
},
|
||||||
"admin_panel/models/sms.py": {
|
"admin_panel/models/sms.py": {
|
||||||
"mtime": 1791719506.0225618,
|
"mtime": 1792159401.822662,
|
||||||
"seen": 1791720362.5829337,
|
"seen": 1792493391.7461972,
|
||||||
"ast_hash": "8e4bb3e917888a16e675f3f139207b1b",
|
"ast_hash": "61c0b5b802c2bb76777d999b07092ed9",
|
||||||
"semantic_hash": ""
|
"semantic_hash": ""
|
||||||
},
|
},
|
||||||
"admin_panel/signals.py": {
|
"admin_panel/signals.py": {
|
||||||
@@ -1116,10 +1116,10 @@
|
|||||||
"semantic_hash": "22f7602ea6f852c9a1cf8a71a40c7f5c"
|
"semantic_hash": "22f7602ea6f852c9a1cf8a71a40c7f5c"
|
||||||
},
|
},
|
||||||
"admin_panel/sms/enqueue.py": {
|
"admin_panel/sms/enqueue.py": {
|
||||||
"mtime": 1791626998.7211082,
|
"mtime": 1792159171.2898316,
|
||||||
"seen": 1791702801.3150773,
|
"seen": 1792493391.7481978,
|
||||||
"ast_hash": "7d00e585643244a5e694427047972c74",
|
"ast_hash": "65e5b86d2fe970cb81658198f0f66d44",
|
||||||
"semantic_hash": "7d00e585643244a5e694427047972c74"
|
"semantic_hash": ""
|
||||||
},
|
},
|
||||||
"admin_panel/sms/phones.py": {
|
"admin_panel/sms/phones.py": {
|
||||||
"mtime": 1791567752.8740702,
|
"mtime": 1791567752.8740702,
|
||||||
@@ -1134,10 +1134,10 @@
|
|||||||
"semantic_hash": "19bd0d12c9932809424ddcc58d9d5ccd"
|
"semantic_hash": "19bd0d12c9932809424ddcc58d9d5ccd"
|
||||||
},
|
},
|
||||||
"admin_panel/sms/render.py": {
|
"admin_panel/sms/render.py": {
|
||||||
"mtime": 1791564403.1354272,
|
"mtime": 1792159186.946252,
|
||||||
"seen": 1791702801.3150773,
|
"seen": 1792493391.749565,
|
||||||
"ast_hash": "9b82ac87329178259ffa6929b1f0bd94",
|
"ast_hash": "a70f4e1d403843e2b6f0f1f08e03d22b",
|
||||||
"semantic_hash": "9b82ac87329178259ffa6929b1f0bd94"
|
"semantic_hash": ""
|
||||||
},
|
},
|
||||||
"admin_panel/sms/rules.py": {
|
"admin_panel/sms/rules.py": {
|
||||||
"mtime": 1791708897.995327,
|
"mtime": 1791708897.995327,
|
||||||
@@ -1146,21 +1146,21 @@
|
|||||||
"semantic_hash": ""
|
"semantic_hash": ""
|
||||||
},
|
},
|
||||||
"admin_panel/sms/scanner.py": {
|
"admin_panel/sms/scanner.py": {
|
||||||
"mtime": 1791708785.8918655,
|
"mtime": 1792157807.5642593,
|
||||||
"seen": 1791710591.5772536,
|
"seen": 1792493391.7505708,
|
||||||
"ast_hash": "e9d675f9673e33de929cd9c06fe7de41",
|
"ast_hash": "f03e830b2c7a3344fb2ed18acfeff919",
|
||||||
"semantic_hash": ""
|
"semantic_hash": ""
|
||||||
},
|
},
|
||||||
"admin_panel/sms/timing.py": {
|
"admin_panel/sms/timing.py": {
|
||||||
"mtime": 1791708745.8135014,
|
"mtime": 1792156624.858224,
|
||||||
"seen": 1791710591.5772536,
|
"seen": 1792493391.7505708,
|
||||||
"ast_hash": "4819c52781b07b14c5c19f66a89bea9c",
|
"ast_hash": "03ffaeaf60bd13c964f61f1c2429a4ca",
|
||||||
"semantic_hash": ""
|
"semantic_hash": ""
|
||||||
},
|
},
|
||||||
"admin_panel/views/sms.py": {
|
"admin_panel/views/sms.py": {
|
||||||
"mtime": 1791720558.729439,
|
"mtime": 1792159340.4347801,
|
||||||
"seen": 1791721256.78409,
|
"seen": 1792493391.7541184,
|
||||||
"ast_hash": "c0041541c95e34dd74b4d5439a701f73",
|
"ast_hash": "9aa47348420d891e99ca67401c677898",
|
||||||
"semantic_hash": ""
|
"semantic_hash": ""
|
||||||
},
|
},
|
||||||
"integrations/__init__.py": {
|
"integrations/__init__.py": {
|
||||||
@@ -1260,9 +1260,9 @@
|
|||||||
"semantic_hash": "d181c7e91c9fc824e45ee36afe852884"
|
"semantic_hash": "d181c7e91c9fc824e45ee36afe852884"
|
||||||
},
|
},
|
||||||
"tests/test_sms.py": {
|
"tests/test_sms.py": {
|
||||||
"mtime": 1791720635.36276,
|
"mtime": 1792159900.5047233,
|
||||||
"seen": 1791721256.78409,
|
"seen": 1792493391.763808,
|
||||||
"ast_hash": "797136590c528a8a52e6884b355122d2",
|
"ast_hash": "e1b1259ac6fd3522b73f30f4b8af04f8",
|
||||||
"semantic_hash": ""
|
"semantic_hash": ""
|
||||||
},
|
},
|
||||||
"admin_panel/templates/admin_panel/_customer_sms.html": {
|
"admin_panel/templates/admin_panel/_customer_sms.html": {
|
||||||
@@ -1516,5 +1516,29 @@
|
|||||||
"seen": 1792154233.652624,
|
"seen": 1792154233.652624,
|
||||||
"ast_hash": "315d8f435e2837683f3c4b797f7928b9",
|
"ast_hash": "315d8f435e2837683f3c4b797f7928b9",
|
||||||
"semantic_hash": ""
|
"semantic_hash": ""
|
||||||
|
},
|
||||||
|
"admin_panel/migrations/0075_cheque_funding_before_due.py": {
|
||||||
|
"mtime": 1792156733.9907367,
|
||||||
|
"seen": 1792493391.7441702,
|
||||||
|
"ast_hash": "662e63fec04ca3cbfb417730e6b3e08f",
|
||||||
|
"semantic_hash": ""
|
||||||
|
},
|
||||||
|
"admin_panel/migrations/0076_alter_smstemplate_text_help.py": {
|
||||||
|
"mtime": 1792157913.085637,
|
||||||
|
"seen": 1792493391.7441702,
|
||||||
|
"ast_hash": "c755f747ccb441234920652de8f30c34",
|
||||||
|
"semantic_hash": ""
|
||||||
|
},
|
||||||
|
"admin_panel/migrations/0077_alter_smstemplate_text_balance_help.py": {
|
||||||
|
"mtime": 1792159653.211569,
|
||||||
|
"seen": 1792493391.7441702,
|
||||||
|
"ast_hash": "04743cd90961374dffadd8345b0ba9c6",
|
||||||
|
"semantic_hash": ""
|
||||||
|
},
|
||||||
|
"admin_panel/sms/balance.py": {
|
||||||
|
"mtime": 1792160598.9153934,
|
||||||
|
"seen": 1792493391.7481978,
|
||||||
|
"ast_hash": "60c36802c8ec0752be3effd8cbc63e68",
|
||||||
|
"semantic_hash": ""
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
+83
-14
@@ -1,4 +1,5 @@
|
|||||||
"""تستهای قابلیت پیامک: نرمالسازی، idempotency، opt-out، worker و تریگرها."""
|
"""تستهای قابلیت پیامک: نرمالسازی، idempotency، opt-out، worker و تریگرها."""
|
||||||
|
import datetime
|
||||||
import json
|
import json
|
||||||
import re
|
import re
|
||||||
from datetime import timedelta
|
from datetime import timedelta
|
||||||
@@ -16,9 +17,10 @@ from admin_panel.sms.enqueue import enqueue_sms
|
|||||||
from admin_panel.sms.phones import customer_phone_entries
|
from admin_panel.sms.phones import customer_phone_entries
|
||||||
from admin_panel.sms.recipients import resolve_recipients
|
from admin_panel.sms.recipients import resolve_recipients
|
||||||
from admin_panel.sms.rules import is_sms_enabled
|
from admin_panel.sms.rules import is_sms_enabled
|
||||||
from admin_panel.sms.scanner import run_all
|
from admin_panel.sms.scanner import run_all, scan_visit
|
||||||
from admin_panel.sms.timing import apply_timing, current_timing_value, timing_for
|
from admin_panel.sms.timing import apply_timing, current_timing_value, timing_for
|
||||||
from admin_panel.sms.triggers import notify_welcome
|
from admin_panel.sms.triggers import notify_welcome
|
||||||
|
from admin_panel.visit_policy import visit_period
|
||||||
from integrations.sms.base import SMSProvider, SMSResult
|
from integrations.sms.base import SMSProvider, SMSResult
|
||||||
from integrations.sms.normalize import normalize_mobile, to_local
|
from integrations.sms.normalize import normalize_mobile, to_local
|
||||||
|
|
||||||
@@ -427,7 +429,11 @@ class ScannerTests(TestCase):
|
|||||||
self.assertEqual(SMSQueue.objects.filter(customer=old).count(), 0)
|
self.assertEqual(SMSQueue.objects.filter(customer=old).count(), 0)
|
||||||
|
|
||||||
def test_deleted_visit_reminder_rearms_next_period_only(self):
|
def test_deleted_visit_reminder_rearms_next_period_only(self):
|
||||||
"""حذفِ یادآوری بازدید فقط همان دوره را کنسل میکند؛ دورهٔ بعد دوباره ساخته میشود."""
|
"""حذفِ یادآوری بازدید فقط همان دوره را کنسل میکند؛ دورهٔ بعد دوباره ساخته میشود.
|
||||||
|
|
||||||
|
نکته: اسکنر با ``now`` تزریقی اجرا میشود تا این تست به ساعت واقعی سیستم وابسته نباشد
|
||||||
|
(قبلاً با ``run_all`` تمام اسکنها اجرا میشدند و ماتریس دوره به تاریخ واقعی گره میخورد).
|
||||||
|
"""
|
||||||
customer = make_customer('مشتری بازدید دورهای')
|
customer = make_customer('مشتری بازدید دورهای')
|
||||||
PhoneNumber.objects.create(customer=customer, phone_number='09120000007')
|
PhoneNumber.objects.create(customer=customer, phone_number='09120000007')
|
||||||
Customer.objects.filter(pk=customer.pk).update(
|
Customer.objects.filter(pk=customer.pk).update(
|
||||||
@@ -436,31 +442,88 @@ class ScannerTests(TestCase):
|
|||||||
template = SMSTemplate.objects.get(sms_type=SMSType.VISIT)
|
template = SMSTemplate.objects.get(sms_type=SMSType.VISIT)
|
||||||
apply_timing(template, 9)
|
apply_timing(template, 9)
|
||||||
template.save()
|
template.save()
|
||||||
period = timedelta(weeks=int(9 * 4.3))
|
self.assertEqual(visit_period().months, 9)
|
||||||
days_to_next = period.days - (300 % period.days) # تا شروع دورهٔ بعد
|
period = visit_period()
|
||||||
|
install = timezone.localdate() - timedelta(days=300)
|
||||||
today = timezone.localdate()
|
today = timezone.localdate()
|
||||||
|
# روز ورود به چرخهٔ بعد = موعد تقویمی بعدی (install + 9m، +18m، ...)
|
||||||
|
from dateutil.relativedelta import relativedelta
|
||||||
|
|
||||||
|
from admin_panel.visit_policy import visit_cycle_index
|
||||||
|
|
||||||
|
next_due = install + period
|
||||||
|
while next_due <= today:
|
||||||
|
next_due = next_due + period
|
||||||
|
days_to_next = (next_due - today).days
|
||||||
|
self.assertGreater(
|
||||||
|
visit_cycle_index(install, next_due), visit_cycle_index(install, today),
|
||||||
|
msg='شمارهٔ چرخه باید در موعد تقویمی بعدی افزایش یابد',
|
||||||
|
)
|
||||||
|
|
||||||
def keys():
|
def keys():
|
||||||
return list(
|
return list(
|
||||||
SMSQueue.objects.filter(customer=customer).values_list('dedup_key', flat=True)
|
SMSQueue.objects.filter(customer=customer).values_list('dedup_key', flat=True)
|
||||||
)
|
)
|
||||||
|
|
||||||
run_all(today)
|
def scan(day):
|
||||||
|
"""اسکن در «روز» دادهشده؛ مبنای زمانی هم همان روز است (بدون وابستگی به ساعت واقعی)."""
|
||||||
|
moment = timezone.make_aware(
|
||||||
|
datetime.datetime.combine(day, datetime.time(12, 0)),
|
||||||
|
timezone.get_current_timezone(),
|
||||||
|
)
|
||||||
|
return scan_visit(day, now=moment)
|
||||||
|
|
||||||
|
# کنترل جریان: پنجرهٔ یادآوری با «امروز» واقعی (زمان پنجرهٔ اسکن) سنجیده میشود،
|
||||||
|
# پس خط شروع را به همان اندازه عقب میبریم تا این تست به تاریخ اجرا وابسته نباشد.
|
||||||
|
config = SMSProviderConfig.load()
|
||||||
|
config.scan_since = timezone.now() - timedelta(days=600)
|
||||||
|
config.allow_backlog = True
|
||||||
|
config.backlog_max = 50
|
||||||
|
config.save()
|
||||||
|
|
||||||
|
# پیشبردن «اکنون» به روزهای آینده به تغییر موقت ساعت ارجاعی نیاز دارد
|
||||||
|
# (بازههای ماهانه تقویمیاند و با جابهجایی خط شروع شبیهسازی نمیشوند).
|
||||||
|
import admin_panel.sms.scanner as scanner_module
|
||||||
|
|
||||||
|
real_timezone_now = scanner_module.timezone.now
|
||||||
|
|
||||||
|
def scan(day):
|
||||||
|
"""اسکن در «روز» دادهشده؛ مبنای زمانی هم همان روز است (بدون وابستگی به ساعت واقعی)."""
|
||||||
|
moment = timezone.make_aware(
|
||||||
|
datetime.datetime.combine(day, datetime.time(12, 0)),
|
||||||
|
timezone.get_current_timezone(),
|
||||||
|
)
|
||||||
|
scanner_module.timezone.now = lambda: moment
|
||||||
|
try:
|
||||||
|
return scan_visit(day, now=moment)
|
||||||
|
finally:
|
||||||
|
scanner_module.timezone.now = real_timezone_now
|
||||||
|
|
||||||
|
scan(today)
|
||||||
first = keys()
|
first = keys()
|
||||||
self.assertEqual(len(first), 1)
|
self.assertEqual(len(first), 1)
|
||||||
|
|
||||||
# مدیر مورد را حذف میکند ⇒ در همان دوره دوباره ساخته نمیشود.
|
# مدیر مورد را حذف میکند ⇒ در همان دوره دوباره ساخته نمیشود.
|
||||||
SMSQueue.objects.filter(customer=customer).delete()
|
SMSQueue.objects.filter(customer=customer).delete()
|
||||||
run_all(today)
|
scan(today)
|
||||||
self.assertEqual(keys(), [])
|
self.assertEqual(keys(), [])
|
||||||
# حتی نزدیک پایان دوره هم برنمیگردد.
|
# حتی نزدیک پایان دوره هم برنمیگردد.
|
||||||
run_all(today + timedelta(days=days_to_next - 1))
|
scan(today + timedelta(days=days_to_next - 1))
|
||||||
self.assertEqual(keys(), [])
|
self.assertEqual(keys(), [])
|
||||||
|
|
||||||
# در دورهٔ بعد کلیدِ جدید ساخته میشود (دوباره ارسال میشود، نه خاموشِ دائمی).
|
# در دورهٔ بعد کلیدِ تازه ساخته میشود (یادآوری برای همیشه خاموش نمیماند).
|
||||||
run_all(today + timedelta(days=days_to_next))
|
# نکته: شمارهگذاری چرخه از «لنگر ثابت» (تاریخ نصب) محاسبه میشود، پس اولین
|
||||||
self.assertEqual(len(keys()), 1)
|
# چرخهٔ کاملِ بعد از حذف، چرخهٔ شمارهٔ ۲ است؛ حلقه تا رسیدن به کلید تازه
|
||||||
self.assertNotEqual(keys(), first)
|
# ادامه میدهد تا تست به محاسبهٔ دقیق تقویمی گره نخورد.
|
||||||
|
second = None
|
||||||
|
for step in range(1, 5):
|
||||||
|
scan(today + timedelta(days=days_to_next * step))
|
||||||
|
current = keys()
|
||||||
|
if current:
|
||||||
|
second = current
|
||||||
|
break
|
||||||
|
self.assertIsNotNone(second, msg='یادآوری دورهٔ بعد باید دوباره ساخته شود')
|
||||||
|
self.assertNotEqual(second, first)
|
||||||
|
|
||||||
def test_disabled_template_is_skipped(self):
|
def test_disabled_template_is_skipped(self):
|
||||||
today = timezone.localdate()
|
today = timezone.localdate()
|
||||||
@@ -2190,13 +2253,19 @@ class EventTriggerTests(PanelTestCase):
|
|||||||
|
|
||||||
# ---------- ثبت اقدام از نوع بازدید ----------
|
# ---------- ثبت اقدام از نوع بازدید ----------
|
||||||
|
|
||||||
def _post_action(self, customer, action_type):
|
def _post_action(self, customer, action_type, *, is_done=True):
|
||||||
url = reverse('admin_panel:customerNewAction', kwargs={'c_id': customer.id})
|
url = reverse('admin_panel:customerNewAction', kwargs={'c_id': customer.id})
|
||||||
return self.client.post(url, {
|
payload = {
|
||||||
'actionType': action_type,
|
'actionType': action_type,
|
||||||
'dateTimeAnjam': '1700000000000',
|
'dateTimeAnjam': '1700000000000',
|
||||||
'desc': 'توضیح', 'userID': self.user.id,
|
'desc': 'توضیح', 'userID': self.user.id,
|
||||||
})
|
}
|
||||||
|
if is_done:
|
||||||
|
# اعتبارسنجی HTTP: بازدید انجامشده باید تاریخ اتمام داشته باشد؛
|
||||||
|
# فیلد خالی در سرور با زمان ثبت پر میشود.
|
||||||
|
payload['is_done'] = 'on'
|
||||||
|
payload['FinishDateTime'] = '1700000000000'
|
||||||
|
return self.client.post(url, payload)
|
||||||
|
|
||||||
def test_bazdid_action_enqueues_visit_created(self):
|
def test_bazdid_action_enqueues_visit_created(self):
|
||||||
customer = self._customer_with_phone()
|
customer = self._customer_with_phone()
|
||||||
|
|||||||
@@ -0,0 +1,816 @@
|
|||||||
|
"""تستهای «آخرین بازدید» (visit_policy) و دستور ترمیم.
|
||||||
|
|
||||||
|
این ماژول قواعد طلایی زیر را قفل میکند:
|
||||||
|
|
||||||
|
* فقط اقدام ``bazdid + finish`` در محاسبهٔ آخرین بازدید دیده میشود؛
|
||||||
|
«هماهنگی» هرگز.
|
||||||
|
* تاریخ مؤثر = ``actionDateTime`` اگر از حالا جلوتر نباشد، وگرنه ``finishDateTime``.
|
||||||
|
* بازمحاسبه و ترمیم، هیچ رکورد اقدامی را تغییر نمیدهند.
|
||||||
|
* بازدید انجامشده با تاریخ بیش از ۳۰ روز آینده در لایهٔ HTTP رد میشود.
|
||||||
|
"""
|
||||||
|
import datetime
|
||||||
|
import io
|
||||||
|
import os
|
||||||
|
from datetime import timedelta
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from django.core.exceptions import ValidationError
|
||||||
|
from django.core.management import CommandError, call_command
|
||||||
|
from django.db import connection
|
||||||
|
from django.test import TestCase
|
||||||
|
from django.utils import timezone
|
||||||
|
|
||||||
|
from admin_panel.models import Action, Customer
|
||||||
|
from admin_panel.visit_policy import (active_deferral, as_aware, last_visit_dt,
|
||||||
|
last_visit_map, refresh_last_bazdid,
|
||||||
|
validate_action_payload,
|
||||||
|
visit_cycle_index, visit_due_date)
|
||||||
|
|
||||||
|
from .factories import make_customer, make_user
|
||||||
|
|
||||||
|
|
||||||
|
_BASE_NOW = timezone.now()
|
||||||
|
|
||||||
|
|
||||||
|
def at(days=0, hours=0):
|
||||||
|
"""لحظهٔ aware نسبت به «حالا» (ثابت در طول یک ماژول، بدون drift میکروثانیه)."""
|
||||||
|
return _BASE_NOW + timedelta(days=days, hours=hours)
|
||||||
|
|
||||||
|
|
||||||
|
def make_visit(customer, *, action_dt=None, finish_dt=None, status='finish',
|
||||||
|
action_type='bazdid'):
|
||||||
|
return Action.objects.create(
|
||||||
|
customer=customer, actionType=action_type, status=status,
|
||||||
|
actionDateTime=action_dt, finishDateTime=finish_dt,
|
||||||
|
desc='تست', result='نتیجه',
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def action_snapshot(action):
|
||||||
|
"""همهٔ فیلدهای یک اقدام، برای اثبات دستنخورده ماندن آن."""
|
||||||
|
action.refresh_from_db()
|
||||||
|
return {field.name: getattr(action, field.name)
|
||||||
|
for field in action._meta.concrete_fields}
|
||||||
|
|
||||||
|
|
||||||
|
class EffectiveVisitDateTests(TestCase):
|
||||||
|
"""قاعدهٔ «تاریخ مؤثر» و انتخاب بزرگترین مقدار."""
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
self.customer = make_customer('مشتری تاریخ مؤثر')
|
||||||
|
|
||||||
|
def test_finish_before_action_uses_action_when_action_is_past(self):
|
||||||
|
# هر دو در گذشتهاند ⇒ قاعدهٔ جدید تاریخ واقعی بازدید (action) را میگیرد.
|
||||||
|
make_visit(self.customer, action_dt=at(-30), finish_dt=at(-31))
|
||||||
|
self.assertEqual(last_visit_dt(self.customer), as_aware(at(-30)))
|
||||||
|
|
||||||
|
def test_future_action_falls_back_to_finish(self):
|
||||||
|
make_visit(self.customer, action_dt=at(+10), finish_dt=at(-2))
|
||||||
|
self.assertLessEqual(last_visit_dt(self.customer), timezone.now())
|
||||||
|
|
||||||
|
def test_future_action_and_future_finish_yields_nothing(self):
|
||||||
|
make_visit(self.customer, action_dt=at(+10), finish_dt=at(+11))
|
||||||
|
self.assertIsNone(last_visit_dt(self.customer))
|
||||||
|
|
||||||
|
def test_only_action(self):
|
||||||
|
make_visit(self.customer, action_dt=at(-5))
|
||||||
|
self.assertEqual(last_visit_dt(self.customer), as_aware(at(-5)))
|
||||||
|
|
||||||
|
def test_only_finish(self):
|
||||||
|
make_visit(self.customer, finish_dt=at(-4))
|
||||||
|
self.assertEqual(last_visit_dt(self.customer), as_aware(at(-4)))
|
||||||
|
|
||||||
|
def test_no_dates(self):
|
||||||
|
make_visit(self.customer)
|
||||||
|
self.assertIsNone(last_visit_dt(self.customer))
|
||||||
|
|
||||||
|
def test_latest_finish_wins_over_future_planned_visit(self):
|
||||||
|
make_visit(self.customer, action_dt=at(-90), finish_dt=at(-90))
|
||||||
|
make_visit(self.customer, action_dt=at(+20), finish_dt=at(-3))
|
||||||
|
self.assertEqual(last_visit_dt(self.customer), as_aware(at(-3)))
|
||||||
|
|
||||||
|
def test_pending_visit_is_ignored(self):
|
||||||
|
make_visit(self.customer, action_dt=at(-200), finish_dt=at(-200))
|
||||||
|
make_visit(self.customer, action_dt=at(-1), status='waite')
|
||||||
|
self.assertEqual(last_visit_dt(self.customer), as_aware(at(-200)))
|
||||||
|
|
||||||
|
|
||||||
|
class HamahangiIsolationTests(TestCase):
|
||||||
|
"""هماهنگی هرگز نباید در «آخرین بازدید» دیده شود."""
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
self.customer = make_customer('مشتری هماهنگی')
|
||||||
|
|
||||||
|
def test_only_hamahangi_gives_no_visit(self):
|
||||||
|
make_visit(self.customer, action_dt=at(-1), finish_dt=at(-1),
|
||||||
|
action_type='hamahangi')
|
||||||
|
self.assertIsNone(last_visit_dt(self.customer))
|
||||||
|
|
||||||
|
def test_newer_hamahangi_does_not_override_visit(self):
|
||||||
|
make_visit(self.customer, action_dt=at(-60), finish_dt=at(-60))
|
||||||
|
make_visit(self.customer, action_dt=at(-2), finish_dt=at(-2),
|
||||||
|
action_type='hamahangi')
|
||||||
|
self.assertEqual(last_visit_dt(self.customer), as_aware(at(-60)))
|
||||||
|
|
||||||
|
def test_refresh_does_not_clear_value_without_visit(self):
|
||||||
|
Customer.objects.filter(pk=self.customer.pk).update(last_bazdid_dt=at(-10))
|
||||||
|
self.customer.refresh_from_db()
|
||||||
|
make_visit(self.customer, action_dt=at(-1), action_type='hamahangi')
|
||||||
|
changed = refresh_last_bazdid(self.customer)
|
||||||
|
self.customer.refresh_from_db()
|
||||||
|
self.assertFalse(changed)
|
||||||
|
self.assertEqual(as_aware(self.customer.last_bazdid_dt), as_aware(at(-10)))
|
||||||
|
|
||||||
|
def test_waiting_hamahangi_is_not_a_visit_but_is_a_deferral(self):
|
||||||
|
make_visit(self.customer, action_dt=at(+5), status='waite',
|
||||||
|
action_type='hamahangi')
|
||||||
|
self.assertIsNone(last_visit_dt(self.customer))
|
||||||
|
self.assertIsNotNone(active_deferral(self.customer))
|
||||||
|
|
||||||
|
def test_refresh_keeps_action_untouched(self):
|
||||||
|
action = make_visit(self.customer, action_dt=at(-30), finish_dt=at(-31))
|
||||||
|
before = action_snapshot(action)
|
||||||
|
refresh_last_bazdid(self.customer)
|
||||||
|
self.assertEqual(action_snapshot(action), before)
|
||||||
|
|
||||||
|
|
||||||
|
class RefreshLastBazdidTests(TestCase):
|
||||||
|
"""نوشتن مقدار مشتری: فقط وقتی تغییر کند، و فقط همان ستون."""
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
self.customer = make_customer('مشتری بازمحاسبه')
|
||||||
|
|
||||||
|
def test_writes_action_date_not_finish_date(self):
|
||||||
|
make_visit(self.customer, action_dt=at(-20), finish_dt=at(-1))
|
||||||
|
self.assertTrue(refresh_last_bazdid(self.customer))
|
||||||
|
self.customer.refresh_from_db()
|
||||||
|
self.assertLessEqual(as_aware(self.customer.last_bazdid_dt), as_aware(at(-20)))
|
||||||
|
|
||||||
|
def test_is_idempotent(self):
|
||||||
|
make_visit(self.customer, action_dt=at(-20), finish_dt=at(-20))
|
||||||
|
self.assertTrue(refresh_last_bazdid(self.customer))
|
||||||
|
self.assertFalse(refresh_last_bazdid(self.customer))
|
||||||
|
|
||||||
|
def test_other_customer_fields_untouched(self):
|
||||||
|
make_visit(self.customer, action_dt=at(-20), finish_dt=at(-20))
|
||||||
|
before = Customer.objects.values().get(pk=self.customer.pk)
|
||||||
|
refresh_last_bazdid(self.customer)
|
||||||
|
after = Customer.objects.values().get(pk=self.customer.pk)
|
||||||
|
changed = {key for key in before if before[key] != after[key]}
|
||||||
|
self.assertEqual(changed, {'last_bazdid_dt'})
|
||||||
|
|
||||||
|
|
||||||
|
class ValidationTests(TestCase):
|
||||||
|
"""اعتبارسنجی لایهٔ HTTP (قاعدهٔ ۳۰ روز فقط برای بازدید انجامشده)."""
|
||||||
|
|
||||||
|
def payload(self, **overrides):
|
||||||
|
base = {'actionType': 'bazdid', 'status': 'finish', 'actionDateTime': at(-1)}
|
||||||
|
base.update(overrides)
|
||||||
|
return base
|
||||||
|
|
||||||
|
def test_finished_visit_far_in_future_is_rejected(self):
|
||||||
|
with self.assertRaises(ValidationError):
|
||||||
|
validate_action_payload(self.payload(actionDateTime=at(+60)))
|
||||||
|
|
||||||
|
def test_finished_visit_today_is_accepted(self):
|
||||||
|
self.assertTrue(validate_action_payload(self.payload(actionDateTime=at(0))))
|
||||||
|
|
||||||
|
def test_finished_visit_in_past_is_accepted(self):
|
||||||
|
self.assertTrue(validate_action_payload(self.payload(actionDateTime=at(-90))))
|
||||||
|
|
||||||
|
def test_hamahangi_in_future_is_accepted(self):
|
||||||
|
self.assertTrue(validate_action_payload(
|
||||||
|
self.payload(actionType='hamahangi', status='waite', actionDateTime=at(+40))
|
||||||
|
))
|
||||||
|
|
||||||
|
def test_invalid_action_type_is_rejected(self):
|
||||||
|
with self.assertRaises(ValidationError):
|
||||||
|
validate_action_payload(self.payload(actionType='انتخاب کنید'))
|
||||||
|
|
||||||
|
def test_invalid_status_is_rejected(self):
|
||||||
|
with self.assertRaises(ValidationError):
|
||||||
|
validate_action_payload(self.payload(status='done'))
|
||||||
|
|
||||||
|
def test_end_of_day_tehran_is_accepted(self):
|
||||||
|
# ۲۳:۵۹ امروز به وقت تهران (= ۲۰:۲۹ UTC) نباید رد شود.
|
||||||
|
tz = timezone.get_current_timezone()
|
||||||
|
today = timezone.localdate()
|
||||||
|
moment = datetime.datetime.combine(
|
||||||
|
today, datetime.time(23, 59), tzinfo=tz
|
||||||
|
)
|
||||||
|
self.assertTrue(validate_action_payload(self.payload(actionDateTime=moment)))
|
||||||
|
|
||||||
|
def test_start_of_day_tehran_is_accepted(self):
|
||||||
|
# ۰۰:۳۰ امروز تهران در UTC روز قبل است؛ نباید بهاشتباه رد یا آینده حساب شود.
|
||||||
|
tz = timezone.get_current_timezone()
|
||||||
|
today = timezone.localdate()
|
||||||
|
moment = datetime.datetime.combine(today, datetime.time(0, 30), tzinfo=tz)
|
||||||
|
self.assertTrue(validate_action_payload(self.payload(actionDateTime=moment)))
|
||||||
|
|
||||||
|
def test_naive_payload_is_made_aware(self):
|
||||||
|
# ورودی naive (بقایای کد قدیمی) باید با تایمزون پروژه aware شود.
|
||||||
|
naive = datetime.datetime.now() - timedelta(days=1)
|
||||||
|
self.assertTrue(validate_action_payload(self.payload(actionDateTime=naive)))
|
||||||
|
self.assertFalse(timezone.is_naive(as_aware(naive)))
|
||||||
|
|
||||||
|
|
||||||
|
def run_command(*args):
|
||||||
|
out = io.StringIO()
|
||||||
|
call_command('recalc_last_bazdid', *args, stdout=out)
|
||||||
|
return out.getvalue()
|
||||||
|
|
||||||
|
|
||||||
|
def plan_command(*args):
|
||||||
|
"""برنامهٔ تغییرات را از همان مسیر ``handle`` میگیرد، بدون نوشتن/بکاپ."""
|
||||||
|
from django.core.management.base import CommandParser
|
||||||
|
|
||||||
|
from admin_panel.management.commands.recalc_last_bazdid import Command
|
||||||
|
|
||||||
|
command = Command()
|
||||||
|
parser = CommandParser(add_help=False)
|
||||||
|
command.add_arguments(parser)
|
||||||
|
options = parser.parse_args(list(args))
|
||||||
|
defaults = {'no_color': True, 'force_color': False, 'skip_checks': False,
|
||||||
|
'traceback': False, 'verbosity': 0, 'pythonpath': None,
|
||||||
|
'settings': None, 'stdout': io.StringIO(), 'stderr': io.StringIO()}
|
||||||
|
defaults.update(vars(options))
|
||||||
|
return command._plan(
|
||||||
|
defaults['exact'], defaults['clear_orphans'], defaults['batch_size'],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class RecalcCommandTests(TestCase):
|
||||||
|
"""دستور ترمیم: فقط Customer را مینویسد، هماهنگی را دست نمیزند."""
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
self.forward = make_customer('مشتری رو به جلو')
|
||||||
|
self.backward = make_customer('مشتری رو به عقب')
|
||||||
|
self.ham = make_customer('مشتری هماهنگی')
|
||||||
|
# رو به جلو: مقدار فعلی قدیمیتر از واقعیت است.
|
||||||
|
make_visit(self.forward, action_dt=at(-30), finish_dt=at(-30))
|
||||||
|
Customer.objects.filter(pk=self.forward.pk).update(last_bazdid_dt=at(-400))
|
||||||
|
# رو به عقب: مقدار فعلی جلوتر از واقعیت است (drift ویرایش).
|
||||||
|
make_visit(self.backward, action_dt=at(-100), finish_dt=at(-100))
|
||||||
|
Customer.objects.filter(pk=self.backward.pk).update(last_bazdid_dt=at(-2))
|
||||||
|
# هماهنگی: مقدارش دقیقاً از تاریخ هماهنگی است.
|
||||||
|
self.ham_action = make_visit(
|
||||||
|
self.ham, action_dt=at(-5), finish_dt=at(-5), action_type='hamahangi',
|
||||||
|
)
|
||||||
|
Customer.objects.filter(pk=self.ham.pk).update(last_bazdid_dt=at(-5))
|
||||||
|
|
||||||
|
def _values(self):
|
||||||
|
return dict(Customer.objects.values_list('id', 'last_bazdid_dt'))
|
||||||
|
|
||||||
|
def test_dry_run_writes_nothing(self):
|
||||||
|
before = self._values()
|
||||||
|
run_command('--exact', '--dry-run')
|
||||||
|
self.assertEqual(self._values(), before)
|
||||||
|
|
||||||
|
def test_exact_requires_confirm(self):
|
||||||
|
with self.assertRaises(CommandError):
|
||||||
|
run_command('--exact')
|
||||||
|
|
||||||
|
def test_plan_default_mode_ignores_backward_drift(self):
|
||||||
|
stats, _, pending, _ = plan_command()
|
||||||
|
self.assertEqual(stats['forward'], 1)
|
||||||
|
self.assertEqual(stats['backward'], 0)
|
||||||
|
self.assertNotIn(self.backward.pk, [customer_id for customer_id, _ in pending])
|
||||||
|
|
||||||
|
def test_plan_exact_mode_fixes_both_directions(self):
|
||||||
|
stats, _, pending, _ = plan_command('--exact')
|
||||||
|
targets = dict(pending)
|
||||||
|
self.assertEqual(stats['forward'], 1)
|
||||||
|
self.assertEqual(stats['backward'], 1)
|
||||||
|
self.assertEqual(as_aware(targets[self.forward.pk]), as_aware(at(-30)))
|
||||||
|
self.assertEqual(as_aware(targets[self.backward.pk]), as_aware(at(-100)))
|
||||||
|
|
||||||
|
def test_apply_writes_only_customer_column(self):
|
||||||
|
from admin_panel.management.commands.recalc_last_bazdid import Command as Cmd
|
||||||
|
|
||||||
|
_, _, pending, _ = plan_command('--exact')
|
||||||
|
before = action_snapshot(self.ham_action)
|
||||||
|
Cmd()._apply(pending)
|
||||||
|
values = self._values()
|
||||||
|
self.assertEqual(as_aware(values[self.forward.pk]), as_aware(at(-30)))
|
||||||
|
self.assertEqual(as_aware(values[self.backward.pk]), as_aware(at(-100)))
|
||||||
|
self.assertEqual(action_snapshot(self.ham_action), before)
|
||||||
|
|
||||||
|
def test_plan_is_idempotent_after_apply(self):
|
||||||
|
from admin_panel.management.commands.recalc_last_bazdid import Command as Cmd
|
||||||
|
|
||||||
|
_, _, pending, _ = plan_command('--exact')
|
||||||
|
Cmd()._apply(pending)
|
||||||
|
stats, _, pending_again, _ = plan_command('--exact')
|
||||||
|
self.assertEqual(pending_again, [])
|
||||||
|
self.assertEqual(stats['no_change'], stats['scanned'])
|
||||||
|
|
||||||
|
def test_hamahangi_value_is_untouched_without_clear_orphans(self):
|
||||||
|
plan_command('--exact')
|
||||||
|
self.assertEqual(as_aware(self._values()[self.ham.pk]), as_aware(at(-5)))
|
||||||
|
|
||||||
|
def test_hamahangi_value_is_cleared_with_clear_orphans(self):
|
||||||
|
_, _, pending, _ = plan_command('--exact', '--clear-orphans')
|
||||||
|
self.assertIn((self.ham.pk, None), pending)
|
||||||
|
|
||||||
|
def test_hamahangi_suspect_heuristic(self):
|
||||||
|
_, _, _, suspects = plan_command('--exact')
|
||||||
|
self.assertEqual(suspects, 1) # فقط مشتری هماهنگی
|
||||||
|
|
||||||
|
|
||||||
|
class EntezarScannerConsistencyTests(TestCase):
|
||||||
|
"""صفحهٔ «انتظار» و اسکنر پیامک باید یک جواب بدهند."""
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
self.user = make_user('entezarAdmin', superuser=True)
|
||||||
|
|
||||||
|
def _entezar_ids(self):
|
||||||
|
self.client.force_login(self.user)
|
||||||
|
response = self.client.get('/panel/entezar/')
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
return {c.id for c in response.context['customers']}
|
||||||
|
|
||||||
|
def test_same_customer_state_on_both_sides(self):
|
||||||
|
from admin_panel.sms.scanner import scan_visit
|
||||||
|
from admin_panel.sms.timing import DEFAULT_VISIT_MONTHS
|
||||||
|
|
||||||
|
months = DEFAULT_VISIT_MONTHS
|
||||||
|
stale = make_customer('مشتری معوق مشترک')
|
||||||
|
fresh = make_customer('مشتری تازه بازدیدشده')
|
||||||
|
Customer.objects.filter(pk=stale.pk).update(
|
||||||
|
installDate=timezone.localdate() - timedelta(days=400)
|
||||||
|
)
|
||||||
|
Customer.objects.filter(pk=fresh.pk).update(
|
||||||
|
installDate=timezone.localdate() - timedelta(days=400)
|
||||||
|
)
|
||||||
|
make_visit(fresh, action_dt=timezone.now() - timedelta(days=5),
|
||||||
|
finish_dt=timezone.now() - timedelta(days=5))
|
||||||
|
|
||||||
|
ids = self._entezar_ids()
|
||||||
|
self.assertIn(stale.pk, ids)
|
||||||
|
self.assertNotIn(fresh.pk, ids)
|
||||||
|
|
||||||
|
# اسکنر هم باید فقط مشتری معوق را عقبافتاده بداند.
|
||||||
|
from admin_panel.visit_policy import active_deferrals, last_visit_map, visit_period
|
||||||
|
period = visit_period(months)
|
||||||
|
now = timezone.now()
|
||||||
|
visits = last_visit_map(now=now)
|
||||||
|
deferrals = active_deferrals(now=now)
|
||||||
|
|
||||||
|
def is_stale(customer):
|
||||||
|
anchor = visits.get(customer.pk) or as_aware(customer.installDate)
|
||||||
|
return (customer.pk not in deferrals) and (anchor + period <= now)
|
||||||
|
|
||||||
|
self.assertTrue(is_stale(Customer.objects.get(pk=stale.pk)))
|
||||||
|
self.assertFalse(is_stale(Customer.objects.get(pk=fresh.pk)))
|
||||||
|
|
||||||
|
def test_active_deferral_hides_customer(self):
|
||||||
|
deferred = make_customer('مشتری تعویقدار')
|
||||||
|
Customer.objects.filter(pk=deferred.pk).update(
|
||||||
|
installDate=timezone.localdate() - timedelta(days=400)
|
||||||
|
)
|
||||||
|
make_visit(deferred, action_dt=at(+3), status='waite',
|
||||||
|
action_type='hamahangi')
|
||||||
|
ids = self._entezar_ids()
|
||||||
|
self.assertNotIn(deferred.pk, ids)
|
||||||
|
|
||||||
|
|
||||||
|
class VisitCadenceTests(TestCase):
|
||||||
|
"""قاعدهٔ «هر ۹ ماه یکبار»: موعد و چرخه از یک تقویم میآیند."""
|
||||||
|
|
||||||
|
MONTHS = 9
|
||||||
|
|
||||||
|
def test_due_date_is_calendar_nine_months(self):
|
||||||
|
anchor = as_aware(datetime.date(2024, 3, 4))
|
||||||
|
self.assertEqual(visit_due_date(anchor, months=self.MONTHS).date(),
|
||||||
|
datetime.date(2024, 12, 4))
|
||||||
|
|
||||||
|
def test_cycle_increments_exactly_on_each_due_date(self):
|
||||||
|
anchor = as_aware(datetime.date(2024, 3, 4))
|
||||||
|
self.assertEqual(visit_cycle_index(anchor, anchor, months=self.MONTHS), 0)
|
||||||
|
for k in range(1, 6):
|
||||||
|
due = visit_due_date(anchor, months=self.MONTHS * k)
|
||||||
|
# در روز موعدِ k-ام، «دورهٔ جاری» همان k است.
|
||||||
|
self.assertEqual(visit_cycle_index(anchor, due, months=self.MONTHS), k,
|
||||||
|
msg=f'چرخه در موعد {k} باید {k} باشد')
|
||||||
|
|
||||||
|
def test_cycle_does_not_advance_before_due_date(self):
|
||||||
|
anchor = as_aware(datetime.date(2024, 3, 4))
|
||||||
|
due = visit_due_date(anchor, months=self.MONTHS)
|
||||||
|
before = due - timedelta(days=1)
|
||||||
|
self.assertEqual(visit_cycle_index(anchor, before, months=self.MONTHS), 0)
|
||||||
|
self.assertEqual(visit_cycle_index(anchor, due, months=self.MONTHS), 1)
|
||||||
|
|
||||||
|
def test_each_period_has_a_distinct_key(self):
|
||||||
|
"""بازگشت باگ اصلی: کلید هر دوره باید یکتا باشد (نه تکرار، نه پرش)."""
|
||||||
|
anchor = as_aware(datetime.date(2024, 1, 1))
|
||||||
|
keys = []
|
||||||
|
for k in range(1, 7):
|
||||||
|
due = visit_due_date(anchor, months=self.MONTHS * k)
|
||||||
|
keys.append((visit_cycle_index(anchor, due, months=self.MONTHS),
|
||||||
|
due.strftime('%Y-%m')))
|
||||||
|
self.assertEqual(len({key for _cycle, key in keys}), len(keys))
|
||||||
|
self.assertEqual([c for c, _k in keys], [1, 2, 3, 4, 5, 6])
|
||||||
|
|
||||||
|
def test_customer_without_any_visit_gets_every_period(self):
|
||||||
|
"""مشتری که هرگز بازدید نشده، برای هر ۹ ماه یک کلید تازه میگیرد."""
|
||||||
|
from admin_panel.models import PhoneNumber, SMSProviderConfig, SMSQueue
|
||||||
|
from admin_panel.sms.scanner import scan_visit
|
||||||
|
|
||||||
|
customer = make_customer('مشتری بدون بازدید')
|
||||||
|
PhoneNumber.objects.create(customer=customer, phone_number='09120000123')
|
||||||
|
Customer.objects.filter(pk=customer.pk).update(
|
||||||
|
installDate=datetime.date(2023, 1, 1)
|
||||||
|
)
|
||||||
|
customer.refresh_from_db()
|
||||||
|
config = SMSProviderConfig.load()
|
||||||
|
# «اکنون» این تست در سال ۲۰۲۳–۲۰۲۵ است، پس پنجرهٔ یادآوری هم همانجا باز میشود.
|
||||||
|
config.allow_backlog = True
|
||||||
|
config.backlog_max = 100
|
||||||
|
config.scan_since = timezone.make_aware(
|
||||||
|
datetime.datetime(2023, 1, 2, 0, 0), timezone.get_current_timezone(),
|
||||||
|
)
|
||||||
|
config.save()
|
||||||
|
|
||||||
|
seen_keys = set()
|
||||||
|
cursor = datetime.date(2023, 1, 2)
|
||||||
|
while cursor <= datetime.date(2026, 1, 1):
|
||||||
|
moment = timezone.make_aware(
|
||||||
|
datetime.datetime.combine(cursor, datetime.time(12, 0)),
|
||||||
|
timezone.get_current_timezone(),
|
||||||
|
)
|
||||||
|
scan_visit(cursor, now=moment)
|
||||||
|
seen_keys.update(
|
||||||
|
SMSQueue.objects.filter(customer=customer)
|
||||||
|
.values_list('dedup_key', flat=True)
|
||||||
|
)
|
||||||
|
cursor += timedelta(days=14)
|
||||||
|
|
||||||
|
# ۹ ماه اول (۲۰۲۳-۱۰) ، ۱۸ ماه (۲۰۲۴-۰۷) و ۲۷ ماه (۲۰۲۵-۰۴) ⇒ سه دورهٔ یکتا.
|
||||||
|
# نکته: اسکنر همیشه «تازهترین موعد رسیده» را هدف میگیرد؛ پس اگر بین دو اجرا
|
||||||
|
# بیش از یک دوره گذشته باشد، فقط آخرین دوره صف میشود (نه ارسال انبوه).
|
||||||
|
due_months = {key.rsplit(':', 1)[1] for key in seen_keys}
|
||||||
|
self.assertEqual(
|
||||||
|
due_months, {'2023-10', '2024-07', '2025-04'},
|
||||||
|
msg=f'کلیدهای ساختهشده: {sorted(seen_keys)}',
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class VisitDecisionTests(TestCase):
|
||||||
|
"""تصمیمهای محصولی: یک پیامک برای جدیدترین دوره، بازگشت پس از حذف، ریست با نصب."""
|
||||||
|
|
||||||
|
MONTHS = 9
|
||||||
|
|
||||||
|
def _scan_at(self, day):
|
||||||
|
from admin_panel.sms.scanner import scan_visit
|
||||||
|
moment = timezone.make_aware(
|
||||||
|
datetime.datetime.combine(day, datetime.time(12, 0)),
|
||||||
|
timezone.get_current_timezone(),
|
||||||
|
)
|
||||||
|
return scan_visit(day, now=moment)
|
||||||
|
|
||||||
|
def _prep(self, install_date):
|
||||||
|
from admin_panel.models import PhoneNumber, SMSProviderConfig, SMSQueue
|
||||||
|
|
||||||
|
customer = make_customer('مشتری تصمیم')
|
||||||
|
PhoneNumber.objects.create(customer=customer, phone_number='09120000777')
|
||||||
|
# ثبت مستقیم: `installDate` با auto_now_add پر میشود، پس با update() بازنویسی میکنیم.
|
||||||
|
Customer.objects.filter(pk=customer.pk).update(installDate=install_date)
|
||||||
|
customer.refresh_from_db()
|
||||||
|
self.assertEqual(customer.installDate, install_date)
|
||||||
|
config = SMSProviderConfig.load()
|
||||||
|
config.allow_backlog = True
|
||||||
|
config.backlog_max = 100
|
||||||
|
config.scan_since = timezone.make_aware(
|
||||||
|
datetime.datetime(2020, 1, 1, 0, 0), timezone.get_current_timezone(),
|
||||||
|
)
|
||||||
|
config.save()
|
||||||
|
return customer, SMSQueue
|
||||||
|
|
||||||
|
# --- تصمیم ۱: فقط یک پیامک برای جدیدترین دوره ---
|
||||||
|
def test_absent_25_months_gets_exactly_one_reminder(self):
|
||||||
|
customer, SMSQueue = self._prep(datetime.date(2023, 1, 1))
|
||||||
|
self._scan_at(datetime.date(2025, 2, 10)) # ۲۵ ماه بعد
|
||||||
|
keys = list(SMSQueue.objects.filter(customer=customer).values_list('dedup_key', flat=True))
|
||||||
|
self.assertEqual(len(keys), 1, msg=f'کلیدها: {keys}')
|
||||||
|
# جدیدترین دورهٔ رسیده: ۱۸ ماهگی (۲۰۲۴-۰۷) — نه ۹ ماهگی.
|
||||||
|
self.assertEqual(keys[0].rsplit(':', 1)[1], '2024-07')
|
||||||
|
|
||||||
|
# --- تصمیم ۲: حذف یادآوری = کنسل همان دوره تا ۹ ماه ---
|
||||||
|
def test_deleted_reminder_stays_cancelled_until_next_period(self):
|
||||||
|
customer, SMSQueue = self._prep(datetime.date(2023, 1, 1))
|
||||||
|
self._scan_at(datetime.date(2024, 1, 10)) # دورهٔ ۹ ماهگی
|
||||||
|
self.assertEqual(SMSQueue.objects.filter(customer=customer).count(), 1)
|
||||||
|
|
||||||
|
# مدیر از پنل صف، پیامک را حذف میکند (مسیر واقعی «کنسل دوره»).
|
||||||
|
from django.urls import reverse
|
||||||
|
|
||||||
|
from tests.factories import make_user
|
||||||
|
|
||||||
|
manager = make_user('smsQueueManager', superuser=True)
|
||||||
|
self.client.force_login(manager)
|
||||||
|
item = SMSQueue.objects.get(customer=customer)
|
||||||
|
response = self.client.post(
|
||||||
|
reverse('admin_panel:sms_queue_delete', kwargs={'q_id': item.id}),
|
||||||
|
)
|
||||||
|
self.assertEqual(response.status_code, 302)
|
||||||
|
self.assertFalse(SMSQueue.objects.filter(customer=customer).exists())
|
||||||
|
|
||||||
|
# تا پایان دورهٔ اول (موعد بعدی = ۲۰۲۴-۰۷-۰۱) هیچ یادآوری تازهای ساخته نمیشود.
|
||||||
|
for day in (datetime.date(2024, 2, 10), datetime.date(2024, 6, 10)):
|
||||||
|
self._scan_at(day)
|
||||||
|
self.assertEqual(SMSQueue.objects.filter(customer=customer).count(), 0,
|
||||||
|
msg='حذف باید همان دوره را کنسل نگه دارد')
|
||||||
|
# دورهٔ دوم (۱۸ ماهگی) ⇒ یادآوری تازه با کلید دیگر.
|
||||||
|
self._scan_at(datetime.date(2024, 7, 1))
|
||||||
|
self.assertEqual(SMSQueue.objects.filter(customer=customer).count(), 1)
|
||||||
|
self.assertEqual(
|
||||||
|
SMSQueue.objects.get(customer=customer).dedup_key.rsplit(':', 1)[1], '2024-07',
|
||||||
|
)
|
||||||
|
|
||||||
|
# --- تصمیم ۳: نصب چرخه را ریست میکند ---
|
||||||
|
def test_nasb_action_resets_the_cycle(self):
|
||||||
|
customer, SMSQueue = self._prep(datetime.date(2023, 1, 1))
|
||||||
|
from admin_panel.models import Action
|
||||||
|
|
||||||
|
Action.objects.create(
|
||||||
|
customer=customer, actionType='nasb', status='finish',
|
||||||
|
actionDateTime=timezone.make_aware(
|
||||||
|
datetime.datetime(2024, 1, 1, 10, 0), timezone.get_current_timezone(),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
# ۸ ماه بعد از نصب ⇒ هنوز موعد نرسیده
|
||||||
|
self._scan_at(datetime.date(2024, 9, 1))
|
||||||
|
self.assertEqual(SMSQueue.objects.filter(customer=customer).count(), 0)
|
||||||
|
# ۹ ماه کامل بعد از نصب ⇒ یادآوری
|
||||||
|
self._scan_at(datetime.date(2024, 10, 5))
|
||||||
|
self.assertEqual(SMSQueue.objects.filter(customer=customer).count(), 1)
|
||||||
|
|
||||||
|
def test_phone_call_does_not_reset_the_cycle(self):
|
||||||
|
customer, SMSQueue = self._prep(datetime.date(2023, 1, 1))
|
||||||
|
from admin_panel.models import Action
|
||||||
|
|
||||||
|
Action.objects.create(
|
||||||
|
customer=customer, actionType='outCall', status='finish',
|
||||||
|
actionDateTime=timezone.make_aware(
|
||||||
|
datetime.datetime(2024, 5, 1, 10, 0), timezone.get_current_timezone(),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
# ۹ ماه از نصب گذشته ⇒ باید معوق بماند، چون تماس تلفنی ریست نمیکند
|
||||||
|
self._scan_at(datetime.date(2024, 10, 5))
|
||||||
|
self.assertEqual(SMSQueue.objects.filter(customer=customer).count(), 1)
|
||||||
|
|
||||||
|
# --- تصمیم ۴: پایان تعویق ⇒ اگر بازدید قدیمی است، بلافاصله معوق ---
|
||||||
|
def test_after_deferral_ends_old_visit_makes_customer_due_immediately(self):
|
||||||
|
customer, SMSQueue = self._prep(datetime.date(2023, 1, 1))
|
||||||
|
from admin_panel.models import Action
|
||||||
|
|
||||||
|
# آخرین بازدید واقعی: ۱۰ ماه پیش از پایان تعویق (۲۰۲۵-۰۳-۱۵)
|
||||||
|
Action.objects.create(
|
||||||
|
customer=customer, actionType='bazdid', status='finish',
|
||||||
|
actionDateTime=timezone.make_aware(
|
||||||
|
datetime.datetime(2025, 3, 15, 10, 0), timezone.get_current_timezone(),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
# تعویق فعال تا ۲۰۲۶-۰۱-۱۵ ⇒ در این بازه یادآوری نمیگیرد
|
||||||
|
Action.objects.create(
|
||||||
|
customer=customer, actionType='hamahangi', status='waite',
|
||||||
|
actionDateTime=timezone.make_aware(
|
||||||
|
datetime.datetime(2026, 1, 15, 10, 0), timezone.get_current_timezone(),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
self._scan_at(datetime.date(2026, 1, 10))
|
||||||
|
self.assertEqual(SMSQueue.objects.filter(customer=customer).count(), 0)
|
||||||
|
# تعویق تمام شد؛ ۱۰ ماه از بازدید گذشته ⇒ همان روز یادآوری میگیرد
|
||||||
|
self._scan_at(datetime.date(2026, 1, 20))
|
||||||
|
self.assertEqual(SMSQueue.objects.filter(customer=customer).count(), 1)
|
||||||
|
|
||||||
|
|
||||||
|
class NextAppointmentTests(TestCase):
|
||||||
|
"""تصمیم: هماهنگیِ «انجامشده» و بازدیدِ برنامهریزیشده هم مشتری را از فهرست خارج میکنند."""
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
from tests.factories import make_user
|
||||||
|
|
||||||
|
self.user = make_user('apptExpert', superuser=True)
|
||||||
|
self.client.force_login(self.user)
|
||||||
|
|
||||||
|
def _customer(self, name, install_days=400):
|
||||||
|
customer = make_customer(name)
|
||||||
|
Customer.objects.filter(pk=customer.pk).update(
|
||||||
|
installDate=timezone.localdate() - timedelta(days=install_days)
|
||||||
|
)
|
||||||
|
customer.refresh_from_db()
|
||||||
|
return customer
|
||||||
|
|
||||||
|
def _entezar_ids(self):
|
||||||
|
response = self.client.get('/panel/entezar/')
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
return {c.id for c in response.context['customers']}
|
||||||
|
|
||||||
|
def _add(self, customer, action_type, status, when, **extra):
|
||||||
|
return Action.objects.create(
|
||||||
|
customer=customer, actionType=action_type, status=status,
|
||||||
|
actionDateTime=when, **extra,
|
||||||
|
)
|
||||||
|
|
||||||
|
# --- خواستهٔ ۱: هماهنگیِ finish ---
|
||||||
|
def test_finished_hamahangi_hides_customer_until_appointment(self):
|
||||||
|
customer = self._customer('هماهنگی انجامشده')
|
||||||
|
# همین حالا تماس گرفتیم و برای هفتهٔ بعد توافق شد؛ هماهنگی «انجام شد».
|
||||||
|
self._add(customer, 'hamahangi', 'finish', at(+7), finishDateTime=at(0))
|
||||||
|
self.assertNotIn(customer.pk, self._entezar_ids())
|
||||||
|
|
||||||
|
def test_finished_hamahangi_with_past_date_does_not_hide(self):
|
||||||
|
customer = self._customer('هماهنگی گذشته')
|
||||||
|
self._add(customer, 'hamahangi', 'finish', at(-2), finishDateTime=at(-2))
|
||||||
|
self.assertIn(customer.pk, self._entezar_ids())
|
||||||
|
|
||||||
|
def test_finished_hamahangi_falls_back_to_finish_datetime(self):
|
||||||
|
customer = self._customer('هماهنگی بدون تاریخ اقدام')
|
||||||
|
self._add(customer, 'hamahangi', 'finish', None, finishDateTime=at(+5))
|
||||||
|
self.assertNotIn(customer.pk, self._entezar_ids())
|
||||||
|
|
||||||
|
# --- خواستهٔ ۲: بازدید waite با تاریخ آینده ---
|
||||||
|
def test_scheduled_visit_hides_customer(self):
|
||||||
|
customer = self._customer('بازدید برنامهریزیشده')
|
||||||
|
self._add(customer, 'bazdid', 'waite', at(+5))
|
||||||
|
self.assertNotIn(customer.pk, self._entezar_ids())
|
||||||
|
|
||||||
|
def test_scheduled_visit_in_the_past_does_not_hide(self):
|
||||||
|
customer = self._customer('بازدید عقبافتاده')
|
||||||
|
self._add(customer, 'bazdid', 'waite', at(-5))
|
||||||
|
self.assertIn(customer.pk, self._entezar_ids())
|
||||||
|
|
||||||
|
# --- پس از تاریخ قرار، مشتری برمیگردد ---
|
||||||
|
def test_customer_returns_after_appointment_date(self):
|
||||||
|
from admin_panel.visit_policy import next_appointment_map
|
||||||
|
|
||||||
|
customer = self._customer('قرار منقضی')
|
||||||
|
self._add(customer, 'bazdid', 'waite', at(+3))
|
||||||
|
self.assertIn(customer.pk, next_appointment_map(now=at(0)))
|
||||||
|
self.assertNotIn(customer.pk, next_appointment_map(now=at(+4)))
|
||||||
|
|
||||||
|
# --- هماهنگی بازدید نیست ---
|
||||||
|
def test_hamahangi_never_changes_last_visit(self):
|
||||||
|
customer = self._customer('هماهنگی بر بازدید بیاثر')
|
||||||
|
self._add(customer, 'hamahangi', 'finish', at(+7), finishDateTime=at(0))
|
||||||
|
self.assertIsNone(last_visit_dt(customer))
|
||||||
|
|
||||||
|
# --- همترازی صفحه و اسکنر ---
|
||||||
|
def test_scanner_also_skips_customer_with_appointment(self):
|
||||||
|
from admin_panel.models import PhoneNumber, SMSProviderConfig, SMSQueue
|
||||||
|
from admin_panel.sms.scanner import scan_visit
|
||||||
|
|
||||||
|
customer = self._customer('قرار + اسکنر')
|
||||||
|
PhoneNumber.objects.create(customer=customer, phone_number='09120000999')
|
||||||
|
config = SMSProviderConfig.load()
|
||||||
|
config.allow_backlog = True
|
||||||
|
config.backlog_max = 50
|
||||||
|
config.scan_since = at(-2000)
|
||||||
|
config.save()
|
||||||
|
self._add(customer, 'bazdid', 'waite', at(+5))
|
||||||
|
|
||||||
|
now = at(0)
|
||||||
|
scan_visit(timezone.localdate(), now=now)
|
||||||
|
self.assertFalse(SMSQueue.objects.filter(customer=customer).exists())
|
||||||
|
|
||||||
|
|
||||||
|
class LastVisitMapEquivalenceTests(TestCase):
|
||||||
|
"""نسخهٔ SQL ``last_visit_map`` باید دقیقاً همان خروجی نسخهٔ پایتونی را بدهد."""
|
||||||
|
|
||||||
|
NOW = datetime.datetime(2026, 6, 15, 12, 0, tzinfo=datetime.timezone.utc)
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
# رکوردهای مرزی که قاعده را به چالش میکشند.
|
||||||
|
cases = [
|
||||||
|
# (action, finish) هر دو در گذشته ⇒ action ملاک است.
|
||||||
|
(self.NOW - timedelta(days=30), self.NOW - timedelta(days=31)),
|
||||||
|
# action در آینده ⇒ finish ملاک است.
|
||||||
|
(self.NOW + timedelta(days=10), self.NOW - timedelta(days=2)),
|
||||||
|
# هر دو در آینده ⇒ هیچ تاریخ معتبری ندارد.
|
||||||
|
(self.NOW + timedelta(days=10), self.NOW + timedelta(days=11)),
|
||||||
|
# فقط action.
|
||||||
|
(self.NOW - timedelta(days=5), None),
|
||||||
|
# فقط finish.
|
||||||
|
(None, self.NOW - timedelta(days=4)),
|
||||||
|
# هیچکدام.
|
||||||
|
(None, None),
|
||||||
|
# دقیقاً برابر «حالا».
|
||||||
|
(self.NOW, self.NOW),
|
||||||
|
]
|
||||||
|
for index, (action_dt, finish_dt) in enumerate(cases):
|
||||||
|
customer = make_customer(f'همسانی {index}')
|
||||||
|
Action.objects.create(
|
||||||
|
customer=customer, actionType='bazdid', status='finish',
|
||||||
|
actionDateTime=action_dt, finishDateTime=finish_dt,
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_sql_matches_python_reference(self):
|
||||||
|
from admin_panel.visit_policy import _last_visit_map_python
|
||||||
|
|
||||||
|
py = _last_visit_map_python(now=self.NOW)
|
||||||
|
sql = last_visit_map(now=self.NOW)
|
||||||
|
self.assertEqual(py, sql)
|
||||||
|
|
||||||
|
def test_sql_matches_reference_for_other_clocks(self):
|
||||||
|
from admin_panel.visit_policy import _last_visit_map_python
|
||||||
|
|
||||||
|
for delta in (timedelta(days=-365), timedelta(days=1), timedelta(days=400)):
|
||||||
|
now = self.NOW + delta
|
||||||
|
self.assertEqual(
|
||||||
|
_last_visit_map_python(now=now), last_visit_map(now=now),
|
||||||
|
msg=f'clock shift {delta}',
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_timezone_cutoff_is_utc_not_local(self):
|
||||||
|
"""مرز زمانی نباید با منطقهٔ محلی جابهجا شود (باگ ۳:۳۰ ساعتهٔ تهران)."""
|
||||||
|
customer = make_customer('مرز زمانی')
|
||||||
|
# رکوردی که ۵ دقیقه بعد از «حالا» است ⇒ نباید حساب شود.
|
||||||
|
Action.objects.create(
|
||||||
|
customer=customer, actionType='bazdid', status='finish',
|
||||||
|
actionDateTime=self.NOW + timedelta(minutes=5),
|
||||||
|
finishDateTime=self.NOW + timedelta(minutes=5),
|
||||||
|
)
|
||||||
|
self.assertNotIn(customer.pk, last_visit_map(now=self.NOW))
|
||||||
|
|
||||||
|
|
||||||
|
class NextAppointmentEquivalenceTests(TestCase):
|
||||||
|
"""نسخهٔ SQL ``next_appointment_map`` باید همان نگاشت مرجع باشد."""
|
||||||
|
|
||||||
|
NOW = datetime.datetime(2026, 6, 15, 12, 0, tzinfo=datetime.timezone.utc)
|
||||||
|
|
||||||
|
def test_matches_reference(self):
|
||||||
|
from admin_panel.visit_policy import next_appointment_map
|
||||||
|
|
||||||
|
customer_a = make_customer('قرار الف')
|
||||||
|
customer_b = make_customer('قرار ب')
|
||||||
|
customer_c = make_customer('قرار ج')
|
||||||
|
Action.objects.create(customer=customer_a, actionType='bazdid', status='waite',
|
||||||
|
actionDateTime=self.NOW + timedelta(days=5))
|
||||||
|
Action.objects.create(customer=customer_a, actionType='bazdid', status='waite',
|
||||||
|
actionDateTime=self.NOW + timedelta(days=9))
|
||||||
|
Action.objects.create(customer=customer_b, actionType='hamahangi', status='finish',
|
||||||
|
actionDateTime=self.NOW + timedelta(days=3),
|
||||||
|
finishDateTime=self.NOW)
|
||||||
|
Action.objects.create(customer=customer_c, actionType='bazdid', status='waite',
|
||||||
|
actionDateTime=self.NOW - timedelta(days=5))
|
||||||
|
|
||||||
|
result = next_appointment_map(now=self.NOW)
|
||||||
|
self.assertEqual(result[customer_a.pk], self.NOW + timedelta(days=5))
|
||||||
|
self.assertEqual(result[customer_b.pk], self.NOW + timedelta(days=3))
|
||||||
|
self.assertNotIn(customer_c.pk, result)
|
||||||
|
|
||||||
|
|
||||||
|
class VisitDedupCleanupTests(TestCase):
|
||||||
|
"""پاکسازی کلیدهای آلودهٔ دفتر یکتاسازی (بدون لمس دادهٔ مشتری)."""
|
||||||
|
|
||||||
|
def _keys(self):
|
||||||
|
from admin_panel.models import SMSDedup
|
||||||
|
return set(SMSDedup.objects.values_list('key', flat=True))
|
||||||
|
|
||||||
|
def test_removes_only_absurd_keys_by_default(self):
|
||||||
|
from admin_panel.models import SMSDedup
|
||||||
|
|
||||||
|
SMSDedup.objects.bulk_create([
|
||||||
|
SMSDedup(key='visit:10:24663'), # عدد بیمعنا (باگ تقسیم قدیمی)
|
||||||
|
SMSDedup(key='visit:11:1'), # قدیمی ولی منطقی
|
||||||
|
SMSDedup(key='visit:12:2026-03'), # قالب جدید
|
||||||
|
SMSDedup(key='welcome:5'), # نوع دیگر
|
||||||
|
])
|
||||||
|
call_command('cleanup_visit_dedup', stdout=io.StringIO())
|
||||||
|
self.assertEqual(
|
||||||
|
self._keys(),
|
||||||
|
{'visit:11:1', 'visit:12:2026-03', 'welcome:5'},
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_all_legacy_flag_removes_numeric_keys_too(self):
|
||||||
|
from admin_panel.models import SMSDedup
|
||||||
|
|
||||||
|
SMSDedup.objects.bulk_create([
|
||||||
|
SMSDedup(key='visit:11:1'),
|
||||||
|
SMSDedup(key='visit:12:2026-03'),
|
||||||
|
])
|
||||||
|
call_command('cleanup_visit_dedup', '--all-legacy', stdout=io.StringIO())
|
||||||
|
self.assertEqual(self._keys(), {'visit:12:2026-03'})
|
||||||
|
|
||||||
|
def test_dry_run_deletes_nothing(self):
|
||||||
|
from admin_panel.models import SMSDedup
|
||||||
|
|
||||||
|
SMSDedup.objects.create(key='visit:10:24663')
|
||||||
|
call_command('cleanup_visit_dedup', '--dry-run', stdout=io.StringIO())
|
||||||
|
self.assertEqual(self._keys(), {'visit:10:24663'})
|
||||||
|
|
||||||
|
def test_customer_and_action_rows_untouched(self):
|
||||||
|
from admin_panel.models import SMSDedup
|
||||||
|
|
||||||
|
customer = make_customer('مشتری پاکسازی کلید')
|
||||||
|
action = make_visit(customer, action_dt=at(-30), finish_dt=at(-30))
|
||||||
|
before_action = action_snapshot(action)
|
||||||
|
before_customer = Customer.objects.values().get(pk=customer.pk)
|
||||||
|
SMSDedup.objects.create(key='visit:1:24663')
|
||||||
|
|
||||||
|
call_command('cleanup_visit_dedup', stdout=io.StringIO())
|
||||||
|
|
||||||
|
self.assertEqual(action_snapshot(action), before_action)
|
||||||
|
self.assertEqual(Customer.objects.values().get(pk=customer.pk), before_customer)
|
||||||
|
self.assertEqual(self._keys(), set())
|
||||||
Reference in new issue
Block a user